Background task runner for SvelteKit with real-time progress streaming via Server-Sent Events (SSE).
Heads up: This is an in-memory, single-process task manager. It's designed as an easy drop-in for small self-hosted projects — not for production systems that need horizontal scaling or persistence. See Limitations for details.
timed_out status)TaskEventSource) with auto-reconnect and event replayTaskItem component with snippet-based per-status renderingmaxHistory)npm install sveltekit-tasks
pnpm add sveltekit-tasks
bun add sveltekit-tasks
deno add npm:sveltekit-tasks
Peer dependencies: svelte ^5.0.0, @sveltejs/kit ^2.0.0
// src/lib/server/my-tasks.ts
import { TaskManager } from "sveltekit-tasks/server";
export const tasks = new TaskManager();
tasks.register("import-data", async (ctx) => {
for (let i = 0; i < 100; i++) {
if (ctx.isCanceled()) return;
ctx.progress("Processing...", i + 1, 100);
await doWork(i);
}
});
// Auto-cancel after 5 minutes
tasks.register("slow-sync", handler, { timeout: 300_000 });
// src/routes/tasks/sse/+server.ts
import { tasks } from "$lib/server/my-tasks.js";
export const GET = tasks.createSSEHandler();
// src/routes/tasks/start/+server.ts
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "@sveltejs/kit";
import { tasks } from "$lib/server/my-tasks.js";
export const POST: RequestHandler = async ({ request }) => {
const { taskId } = await request.json();
if (typeof taskId !== "string" || !taskId) error(400, "Invalid taskId");
tasks.start(taskId);
return json({ ok: true });
};
<script lang="ts">
import { TaskEventSource, TaskItem } from "sveltekit-tasks/client";
const taskEvents = new TaskEventSource("/tasks/sse");
const taskList = $derived([...taskEvents.tasks.values()]);
</script>
{#each taskList as task (task.id)}
<TaskItem {task} onstart={startTask} oncancel={cancelTask} />
{/each}
TaskManager (server)import { TaskManager } from "sveltekit-tasks/server";
import { dev } from "$app/environment";
const tasks = new TaskManager({
debug: dev,
maxHistory: 100, // keep only the 100 most recent terminal tasks
eventBufferSize: 1000, // buffer 1000 events for Last-Event-ID replay
});
tasks.register(id, handler); // Register a task
tasks.register(id, handler, { timeout: 60_000 }); // Register with 60s timeout
tasks.start(taskId); // Start a task (fire-and-forget)
tasks.cancel(taskId); // Cancel a running task
tasks.getState(taskId); // Get single task state
tasks.getAllStates(); // Get all task states
tasks.subscribe(callback); // Subscribe to updates (returns unsubscribe fn)
The handler receives a TaskContext:
ctx.progress(message, current?, total?) — report progressctx.isCanceled() — check if task was canceledctx.signal — AbortSignal for passing to fetch(), etc.Note:
start()andcancel()are silent no-ops when the task id is unknown or the task is already running/not running respectively. Enabledebug: trueto log these cases to the console during development.
Cancellation is cooperative. Calling
cancel()aborts theAbortSignaland transitions the task to"canceled"status, but the handler continues running until it checksctx.isCanceled()or itsctx.signalis observed. If you callcancel()then immediatelystart(), two handler instances run concurrently for a brief period — the library's generation counter prevents the old run from clobbering state, but the old handler still performs work until it cooperatively exits.
maxHistoryLimits the number of tasks in terminal states (completed, error, canceled, timed_out) kept in memory. When exceeded, the oldest terminal tasks (by lastRun) are evicted from all internal maps. Useful for long-running servers with dynamically registered tasks.
eventBufferSizeEnables event buffering for Last-Event-ID replay. When a client reconnects, it sends its last received event ID. If the buffer can satisfy the request, only missed events are replayed instead of a full state dump. Set to 0 (default) to disable.
tasks.createSSEHandler(options?)export const GET = tasks.createSSEHandler({
authorize: (event) => event.locals.user?.isAdmin, // optional auth check
heartbeatInterval: 30_000, // keepalive interval (ms)
});
TaskEventSource (client)import { TaskEventSource } from "sveltekit-tasks/client";
const taskEvents = new TaskEventSource("/sse-url", {
reconnectDelay: 1000, // initial reconnect delay (ms), default 1000
maxReconnectDelay: 30_000, // max backoff delay (ms), default 30000
maxRetries: 10, // max reconnect attempts, default 10
onError: (event) => {}, // error callback
});
taskEvents.tasks — reactive SvelteMap<string, TaskState>, updated in real-time from SSE messagestaskEvents.connected — reactive boolean, true while the SSE connection is openReconnects with exponential backoff on disconnect, sending the last event ID for replay when available.
TaskItem (client)Renders task UI based on status. Per-status snippets receive narrowed types — e.g. the completed snippet receives { id, status: "completed", lastRun } so task.lastRun is directly accessible without type narrowing:
<TaskItem {task} onstart={handleStart} oncancel={handleCancel}>
{#snippet running(task)}
<p>{task.progress?.message}</p>
{/snippet}
{#snippet completed(task)}
<p>Done at {new Date(task.lastRun).toLocaleTimeString()}</p>
{/snippet}
{#snippet error(task)}
<p>Failed: {task.error}</p>
{/snippet}
{#snippet canceled(task)}...{/snippet}
{#snippet timed_out(task)}...{/snippet}
{#snippet pending(task)}...{/snippet}
</TaskItem>
Falls back to a default UI with Start/Cancel/Retry buttons when snippets are not provided.
TaskState is a discriminated union on status — narrow via task.status === "running" etc. to access status-specific fields:
import type {
TaskStatus, // "pending" | "running" | "completed" | "error" | "canceled" | "timed_out"
TaskProgress, // { message, current?, total? }
TaskState, // discriminated union (see below)
TaskContext, // { progress(), isCanceled(), signal }
} from "sveltekit-tasks";
| Status | Fields |
|---|---|
"pending" |
id |
"running" |
id, progress? |
"completed" |
id, lastRun |
"error" |
id, lastRun, error |
"canceled" |
id, lastRun |
"timed_out" |
id, lastRun |
Map. In multi-process or multi-server deployments, tasks on one instance are not visible to SSE connections on another.ctx.progress() call emits an SSE message. If your task reports progress in a tight loop, consider adding your own debounce/throttle to avoid flooding clients.These are not currently planned but could be added in the future:
maxConcurrent option to limit how many tasks run simultaneously, with a queue for excess.MIT