Tasks
A task is a named piece of server work that no browser calls: a cleanup, a report, a seed, a migration. It runs when code calls runTask, on a schedule, from a queue, from the command line, from a secured URL or when the server starts. All of them run the same run function.
Writing a task
A task is a file in server/tasks/ (or in a layer) whose default export is defineTask. Its name is the path below server/tasks/ without the extension, with / turned into :: server/tasks/reports/clean.ts is reports:clean, server/tasks/report.ts is report. index is not special. Files and folders whose name starts with _ or ., *.d.ts, *.test.* and *.spec.* are skipped, and each name segment holds A-Z, a-z, 0-9, _ and -. A layer's task of the same name as the app's is replaced by the app's; two files of one root with one name fail the build.
import { defineTask, useStorage } from "silgi/server";
const daysInput = {
"~standard": {
version: 1 as const,
vendor: "docs",
validate: (value: unknown) => {
if (value === undefined) return { value: { days: 30 } };
const days = (value as { days?: unknown }).days;
return typeof days === "number" && days > 0 ? { value: { days } } : { issues: [{ message: "days must be a positive number", path: ["days"] }] };
},
},
};
export default defineTask({
description: "Delete cached reports older than a number of days",
input: daysInput,
async run({ days }: { days: number }, ctx) {
const reports = useStorage("reports");
const keys = await reports.getKeys();
ctx.log.info("cleaning", { days, keys: keys.length });
return { checked: keys.length };
},
});
run(payload, ctx) takes what the caller passed, then what Silgi gives, as a handler does. input is a Standard Schema: a payload it refuses rejects the run with 400 Validation failed and data.issues, and run does not run. Without input the payload arrives as sent. A scheduled run, a runOnStart run and silgi task run without --payload pass undefined, so a task with an input that refuses undefined fails each time it is scheduled; give the schema a default.
ctx has name, scheduledTime and cron (on a scheduled run), signal (aborts when a bun or node server drains; never on the serverless presets), waitUntil and log, the server logger with { task: name } added. The build reads the file as text: description is a string literal and the options of defineTask are written out, not spread. Another key fails: defineTask has no "meta"; it takes description, input, run.
Modules add tasks with addTask (Modules); @silgi/db adds db:migrate.
Running a task from code
runTask(name, payload) runs a task and resolves to what run returned. An unknown name rejects with 404 Task "x" not found, and a throw in run rejects with that value.
import { defineHandler, enqueue, runTask } from "silgi/server";
export const cleanNow = defineHandler({
route: "/api/admin/clean",
method: "POST",
async handler() {
const { checked } = (await runTask("reports:clean", { days: 30 })) as { checked: number };
await enqueue("reports:clean", { days: 7 }, { delay: 3600 });
return { checked };
},
});
- A call while a run of the same name and the same payload is pending joins that run and gets its result. The payload is copied at the call. A run that failed is not joined again, and a run never joins itself.
- The call awaits the server's plugins first, so a plugin cannot call it: there it rejects at once and tells you to list the task in
tasks.runOnStart. - Typegen writes
.silgi/types/tasks.d.ts, sorunTask("reports:clen")and a payload of the wrong type failtsc.TaskName,TaskPayloadandTaskResultare in the Server API.
A task is not a handler
A task runs with no request. useEvent() inside it throws useEvent() was called in task "reports:clean", which runs outside a request, and so does a handler called in process: TypeError: posts.ts#getPost was called in task "report", which runs outside a request; move the shared code into a function both import. A handler may call runTask; a task may not call a handler. Put what both need in a plain function in a server module (server/posts.ts), which the handler and the task file (server/tasks/print-post.ts) import:
import { createError, defineHandler } from "silgi/server";
export function loadPost(id: string) {
if (!id) throw createError({ status: 400, message: "id is required" });
return { id, title: `Post ${id}` };
}
export const getPost = defineHandler({
route: "/api/posts/:id",
method: "GET",
handler: ({ id }: { id: string }) => loadPost(id),
});
import { defineTask } from "silgi/server";
import { loadPost } from "./tasks-plain-function.ts";
export default defineTask({
description: "Print a post",
run: (_: undefined, ctx) => ctx.log.info("post", loadPost("1")),
});
Schedules
scheduledTasks maps a cron expression to a task name or a list of names, run one after another, each with payload undefined.
import { defineConfig } from "silgi";
export default defineConfig({
framework: "react",
runtimeConfig: { tasksToken: "" },
scheduledTasks: {
"*/15 * * * *": "cache:warm",
"0 9 * * 1": "report",
"@daily": ["reports:clean", "report"],
},
tasks: {
http: { token: { runtimeConfig: "tasksToken" } },
runOnStart: ["cache:warm"],
queue: { name: "jobs" },
},
devServer: { tasks: true },
$development: { tasks: { runOnStart: ["db:seed"] } },
});
Every schedule is in UTC on every preset: Vercel, Netlify and Cloudflare run cron in UTC and have no zone setting.
An expression has five fields (minute hour day-of-month month day-of-week) with *, numbers, ranges, steps and lists, the names JAN-DEC and SUN-SAT in any case (0 and 7 are Sunday), or one of @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly. When both day fields are restricted, a time matches when either does. Six-field expressions, full names such as Monday, L, #, ? and a/n are refused. A bad expression, an unknown task, an expression that never runs (0 0 30 2 *) and a preset with no scheduler fail the build with a message that names the key.
The weekday field
The platforms read the weekday differently: Cloudflare counts 1 as Sunday, the others count 1 as Monday. Silgi parses each expression once and writes it the way each platform reads it, so 0 9 * * 1 is Monday 09:00 UTC everywhere:
| Where | Written as | 0 9 * * 1 | @weekly |
|---|---|---|---|
bun, node, silgi dev | numbers | 0 9 * * 1 | 0 0 * * 0 |
Vercel crons, Netlify schedule | numbers 0-6 | 0 9 * * 1 | 0 0 * * 0 |
Cloudflare triggers.crons | names | 0 9 * * MON | 0 0 * * SUN |
ctx.cron is the expression as you wrote it. Write names (MON) if you want the config to read the same on every platform.
Where schedules run
| Preset | Scheduler |
|---|---|
bun | one Bun.cron job per expression, started after the server listens and stopped at drain |
node | one timer per expression, set after each run settles |
vercel | crons in .vercel/output/config.json, calling /_silgi/cron under your app.baseURL. The function answers only a request with authorization: Bearer <CRON_SECRET>: set CRON_SECRET in the project's environment. Unset, the endpoint is 404 and one error line says so |
netlify | one scheduled function per expression |
cloudflare | triggers.crons in .output/wrangler.json and the Worker's scheduled handler, which resolves env and runtime config from its env before it runs the tasks |
static | none: a build error |
silgi dev | off unless devServer.tasks is true |
A run is never stacked on itself: on bun and node the next time is computed after the run settles, and a time the process was down for is skipped. Every process schedules: three replicas run a schedule three times. For several instances keep scheduledTasks on one mode only (an $env.<mode> block, Configuration) or trigger the task from outside. A platform may deliver one cron call twice, so write tasks that can run twice.
A scheduled run joins only a pending run of the same task, payload and cron, so ctx.cron is always the schedule that fired.
From the command line
silgi task list
silgi task run db:seed --payload '{"count":3}'
silgi task run db:migrate --prod
silgi task list prints each task, its description and its schedules without importing a task. silgi task run <name> prints the result as one line of JSON. Without --prod, it posts to the silgi dev of this root when one runs (--port names it), and otherwise builds the dev server in process and runs the task there with the dev config: $development, devStorage and a connection's dev. With --prod it runs in process on Bun with the production config and .env.production, which is how a deploy step runs a task. Errors exit 1: Task "db:seed" failed: <message>, --payload is not JSON: …, Task "x" not found (tasks: db:seed, report).
--prod runs on Bun whatever the preset. On a cloudflare build the bindings (a d1 connection, the cloudflare-r2 blob driver, a KV, R2 or Cache mount) exist only in the deployed Worker, so the first use of one fails the run: Task "db:seed" failed: db.connections.default uses the d1 binding "DB", which exists only in the deployed worker; run the task there through tasks.http (POST /_silgi/tasks/db:seed).
Over HTTP
Under silgi dev, GET /_silgi/tasks lists the tasks and POST /_silgi/tasks/<name> with { "payload": … } runs one; a build answers 404 for both.
To run tasks in a build from outside (a deploy hook, a scheduler of your own, a Worker that has bindings), set tasks.http:
curl -X POST https://example.com/_silgi/tasks/reports:clean \
-H "authorization: Bearer $SILGI_TASKS_TOKEN" \
-H "content-type: application/json" \
-d '{"payload":{"days":30}}'
The token is { runtimeConfig: "<key>" } only; a literal token in silgi.config.ts fails at load. Set SILGI_TASKS_TOKEN where the server runs. It must be at least 32 characters; unset or shorter, the endpoint answers 503 Task endpoint is not configured. A missing or wrong token answers 401, and an unknown task 404 only after the token passed, so names do not leak. The answer is { result }; a result JSON cannot hold answers 500. The route runs after the server middleware.
At server start
tasks.runOnStart runs those tasks once, in order, with payload undefined, after the plugins and before a bun or node server reports ready. Under silgi dev it runs once per runner start; a hot rebuild does not run it again. A failure is logged and reaches task:error, and the server goes on. $development: { tasks: { runOnStart: ["db:seed"] } } seeds in development only.
A serverless preset has no server start, so runOnStart fails the build there and says how to run the task instead: silgi task run --prod in the deploy step on vercel and netlify, tasks.http after the deploy on cloudflare. On static the tasks run once in the build, before the prerender.
Queues and enqueue
enqueue(name, payload, { delay }) hands a run to a queue and resolves when the queue took it, not when the task ran. The payload is always passed (enqueue("report", undefined, { delay: 3600 })), written with the payload serializer at the call, so a value it cannot write rejects there. delay is whole seconds from 0 to 43200.
| Preset | Where the run goes | Durable | delay |
|---|---|---|---|
bun, node, silgi dev | this process, after the answer | no | a timer |
cloudflare with tasks.queue | a Cloudflare Queue | yes | yes |
vercel with tasks.queue | a Vercel Queues topic (beta) | yes | yes |
cloudflare, vercel without it, netlify | this process, through the platform's waitUntil | no | refused |
static, the prerender | this process, awaited before the build ends | no | refused |
In process, enqueue is not durable. A restart or a crash loses the runs still waiting, and the drain drops them with one warn line: 3 enqueued task runs were dropped at shutdown (enqueue is not durable on the node preset). A function stops soon after its answer, so a delay without a queue is refused: enqueue delay needs a queue on the netlify preset: a function stops after its answer.
One key sets the queue on both platforms: tasks.queue: { name, binding? }. name is the Cloudflare queue or the Vercel topic (a-z, 0-9, _ and -, at most 63 characters). binding is the Cloudflare producer binding, TASKS_QUEUE by default; on Vercel it logs a build warning.
- Cloudflare. The build adds the producer and a consumer for
nameto.output/wrangler.json, and the Worker'squeuehandler runs the task of each message: a run that resolves acknowledges the message, a throw retries it, and Cloudflare's retries and dead-letter queue apply, so configure a dead-letter queue. A message over 128 KB is refused at the call (enqueue payload is 140 KB; Cloudflare Queues take 128 KB): store the payload and enqueue its key. A producer in your own wrangler config must send to the same queue astasks.queue.name, or the build fails. - Vercel.
enqueueposts to the topic with the deployment's OIDC token, so call it while a request, a cron or a queue message is handled, not at module load. The build writes a private consumer function,_silgi/vercel/queue.func, which runs each message's task, acknowledges it when the task resolved and leaves it for redelivery when it threw. Vercel Queues is a beta feature and the build says so. - Delivery is at least once on both: write tasks that can run twice.
Each enqueue is a run of its own; it never joins another run of the same payload. A failing run is logged and reaches task:error with trigger: "enqueue"; in process there is no retry.
Errors, logs and spans
A run nobody awaits (a schedule, a queue message, runOnStart, an in-process enqueue) that fails is logged at error with { task, trigger }, trigger being "schedule", "queue", "start" or "enqueue", and passed to the task:error server hook. The error hook is not called, because it takes an event and these runs have none. A run a caller awaits (runTask, silgi task run, the endpoints) rejects to that caller.
import { definePlugin } from "silgi/server";
export default definePlugin((server) => {
server.hooks.hook("task:error", (error, { name, trigger, scheduledTime }) => {
console.error(`task ${name} (${trigger}) failed`, scheduledTime, error);
});
});
With tracing on, each run is a task <name> span with silgi.task.name and silgi.task.trigger, a child of the caller's span (Observability).
On bun and node, the drain aborts every ctx.signal, then waits for runs in progress inside its 10 seconds (Deploy).