Scripts
A cron script is an ordinary Node module. Each time the schedule in its filename is due, the runner loads the module and runs it to completion. There is no framework to import and no base class to extend — the only contract is how you signal work and how you signal done.
What a script may export
Two forms are accepted. Export an async function and the runner calls it with a
ctx and awaits the returned promise:
// digest.every.30m.cron.js
module.exports = async (ctx) => {
const rows = await collectSince(ctx.firedAt);
await sendDigest(rows);
};
Or do the work at the top level — the run is the module's own execution, and completion is process exit:
// heartbeat.every.1m.cron.js
const os = require('os');
console.log('load', os.loadavg()[0]);
Prefer the exported-function form when the work is asynchronous: the runner can await it, hand it the
ctx, and know exactly when it finished. A synchronous export
(module.exports = (ctx) => {…}) is also fine — anything not returning a promise is
treated as done the moment it returns.
The ctx object
The runner passes one argument to an exported function proposed:
| Field | Type | Meaning |
|---|---|---|
name | string | The job's
base name — e.g. digest. Shared by every
file with the same base name. |
firedAt | Date | The scheduled instant this run
represents. Use it, not new Date(), when a run needs to reason about "its" time — it
stays stable even if the run starts a moment late. |
signal | AbortSignal | Tripped when the runner is
shutting down or otherwise cancelling. Check ctx.signal.aborted, or pass it to
fetch and other abortable APIs, so a long job can stop cleanly. |
module.exports = async (ctx) => {
const res = await fetch(url, { signal: ctx.signal });
if (ctx.signal.aborted) return;
await save(ctx.name, ctx.firedAt, await res.json());
};
Success & failure
A run succeeds when its promise resolves, its synchronous body returns, or its top-level process exits zero. A run fails when it:
| Outcome | Result |
|---|---|
| Throws an uncaught error | Failed run — the error and stack are logged. |
| Returns a rejected promise | Failed run — the rejection reason is logged. |
| Exits with a non-zero code | Failed run — the exit code is recorded. |
Either way the run is recorded with its start time, stop time, duration, exit status and captured
stdout/stderr. A failure never retries automatically and never blocks the next
scheduled tick — the schedule simply continues. Pull the logs back with the client's
--pull-logs flag to see what happened.
The single-instance rule
Restated precisely: the runner holds at most one running process per base name at any instant, across all schedules. When a tick becomes due, the runner checks whether a run with that base name is still active; if one is, the new tick is dropped — it is not queued, delayed or coalesced. When the active run settles, the mutex is released and the next due tick may start. This is what makes the duplicate idiom safe and what causes a slow job to stretch its own cadence rather than overlap.
Supporting modules & resolution
Only top-level, schedule-named files are entry points. Any other file or sub-folder is a supporting
module: synced with the tree and imported normally, never invoked on its own.
require/import resolve exactly as they would in a normal Node project rooted at
the content root — relative paths resolve against the importing file, and a node_modules
folder in the tree is honoured.
// report.daily.09.cron.js
const { format } = require('./lib/format'); // a supporting module
const { send } = require('./lib/mailer');
module.exports = async (ctx) => {
await send(format(await gather(ctx.firedAt)));
};
See the Files reference for exactly which paths are entry points, which are supporting files, and which are ignored.
Secrets — _env.json
Secrets should not live in synced files. The convention proposed is an
_env.json at the content root: the leading _ means it is
never synced, so it stays on the server, out of the mirrored tree and
out of version control. The runner reads it and makes its values available to jobs.
// _env.json (lives only on the server; never uploaded)
{
"STRIPE_KEY": "sk_live_…",
"DIGEST_WEBHOOK": "https://hooks.example.com/…"
}
How exactly the values reach a job — as environment variables, or on ctx — is part of the
open runtime & secrets question. The stable part is the file: an
underscore-prefixed _env.json you keep on the server by hand.