feat: add Cron Triggers support to module framework

- modules may declare crons: [{ schedule, name, handler }]
- handler signature (event, { db, sql, env }) matches init context
- scheduled() export in src/index.js dispatches to matching handlers with fan-out and per-handler error isolation
- registry validates cron entries and collects into registry.crons
- wrangler.toml [triggers] crons must still be populated manually by module author
This commit is contained in:
2026-04-15 13:22:17 +07:00
parent 83c6892d6e
commit 8235c9602e
5 changed files with 362 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
/**
* @file cron-dispatcher — dispatches a Cloudflare scheduled event to all
* matching module cron handlers.
*
* Design:
* - Iterates registry.crons, filters by event.cron === entry.schedule.
* - Wraps each handler invocation in try/catch so one failure cannot block
* others (equivalent to Promise.allSettled fan-out via ctx.waitUntil).
* - ctx.waitUntil is fire-and-forget from Workers' perspective; we wrap in
* an async IIFE so errors are caught and logged rather than silently lost.
*/
import { createSqlStore } from "../db/create-sql-store.js";
import { createStore } from "../db/create-store.js";
/**
* @param {any} event — Cloudflare ScheduledEvent (has .cron string).
* @param {any} env
* @param {{ waitUntil: (p: Promise<any>) => void }} ctx
* @param {import("./registry.js").Registry} registry
*/
export function dispatchScheduled(event, env, ctx, registry) {
const matching = registry.crons.filter((entry) => entry.schedule === event.cron);
for (const entry of matching) {
const handlerCtx = {
db: createStore(entry.module.name, env),
sql: createSqlStore(entry.module.name, env),
env,
};
ctx.waitUntil(
(async () => {
try {
await entry.handler(event, handlerCtx);
} catch (err) {
console.error(
`[cron] handler "${entry.name}" (module "${entry.module.name}", schedule "${entry.schedule}") failed:`,
err,
);
}
})(),
);
}
}

View File

@@ -0,0 +1,66 @@
/**
* @file validate-cron — validates module-registered cron entries.
*
* Cron entry contract:
* - name: ^[a-z0-9_-]{1,32}$ — unique within the module (checked by registry)
* - schedule: non-empty string matching a cron-ish pattern
* (5 or 6 fields separated by spaces, e.g. "0 1 * * *")
* - handler: function
*
* All errors include module name + cron name for debuggability.
*/
export const CRON_NAME_RE = /^[a-z0-9_-]{1,32}$/;
/**
* Very loose cron expression check: 5 or 6 space-separated tokens.
* Cloudflare Workers validates the real expression at deploy time;
* we just catch obvious mistakes (empty string, random garbage).
*/
export const CRON_SCHEDULE_RE = /^\S+(\s+\S+){4,5}$/;
/**
* @typedef {object} ModuleCron
* @property {string} name — unique identifier within the module.
* @property {string} schedule — cron expression, e.g. "0 1 * * *".
* @property {(event: any, ctx: { db: any, sql: any, env: any }) => Promise<void>|void} handler
*/
/**
* Throws on any contract violation. Called once per cron entry at registry build.
*
* @param {any} cron
* @param {string} moduleName — for error messages.
*/
export function validateCron(cron, moduleName) {
const prefix = `module "${moduleName}" cron`;
if (!cron || typeof cron !== "object") {
throw new Error(`${prefix}: cron entry is not an object`);
}
// name
if (typeof cron.name !== "string") {
throw new Error(`${prefix}: name must be a string`);
}
if (!CRON_NAME_RE.test(cron.name)) {
throw new Error(
`${prefix} "${cron.name}": name must match ${CRON_NAME_RE} (lowercase letters, digits, underscore, hyphen; 132 chars)`,
);
}
// schedule
if (typeof cron.schedule !== "string" || cron.schedule.trim().length === 0) {
throw new Error(`${prefix} "${cron.name}": schedule must be a non-empty string`);
}
if (!CRON_SCHEDULE_RE.test(cron.schedule.trim())) {
throw new Error(
`${prefix} "${cron.name}": schedule must be a valid cron expression (5 or 6 space-separated fields), got "${cron.schedule}"`,
);
}
// handler
if (typeof cron.handler !== "function") {
throw new Error(`${prefix} "${cron.name}": handler must be a function`);
}
}