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:

FieldTypeMeaning
namestringThe job's base name — e.g. digest. Shared by every file with the same base name.
firedAtDateThe 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.
signalAbortSignalTripped 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:

OutcomeResult
Throws an uncaught errorFailed run — the error and stack are logged.
Returns a rejected promiseFailed run — the rejection reason is logged.
Exits with a non-zero codeFailed 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.