Problem
pilots (our Firecracker sandbox + PaaS, vivek7405/pilots) is adding scheduled jobs: on a cron schedule the platform makes an HTTP GET <path> to the app, tagged with the header X-Pilot-Cron: <cron expr>, which its public edge strips from any request arriving from outside — so a handler can trust the header without a secret. Vercel's model (vercel.json → crons: [{path, schedule}], a GET with x-vercel-cron-schedule).
webjs must deploy on pilots with zero per-platform config (pilots bar 4). Today a webjs app has nowhere to declare a cron, and a route handler has no idiomatic way to ask "was I invoked by the scheduler?". pilots' deploy planner already reads package.json (apps/hostd/internal/detect/detect.go readPackageJSON), so the framework's own config block is the zero-config door — but the block's schema is additionalProperties: false, and an unknown crons key warns on every boot until the framework knows it.
Design / approach
Vercel-exact shape inside the block webjs already owns, so Next users' muscle memory carries over and pilots maps schedule→its own cron field in one line:
"webjs": { "crons": [{ "path": "/jobs/digest", "schedule": "0 5 * * *" }] }
Two additions and one optional helper. No scheduler in the framework: the platform fires the request; webjs only declares and recognises. This keeps the framework buildless and timer-free (the only interval in packages/server/src today is the SSE keepalive, listener-core.js:352; keep it that way).
- Config key
webjs.crons — schema, types, reader with warn-only validation.
- Route-handler accessor
cron() — beside headers()/cookies(): returns the schedule string when the request carries x-pilot-cron, else null; throws outside a request scope like its siblings; calls markDynamicAccess() so a cron response is never HTML-cached. A handler is then one line: if (!cron()) return json({ error: 'not a cron' }, { status: 403 }).
- Optional
webjs cron <path>: GETs the running dev server at path with X-Pilot-Cron: manual, so a job can be exercised locally before deploy.
Alternative considered and deferred: export const schedule = '…' inside route.ts with the list exposed at /__webjs/crons for runtime discovery — nicer DX, but needs a route scan in the framework and a runtime read on the platform side. Can layer on later without changing the config key.
Implementation notes (for the implementing agent)
Where to edit (paths relative to the webjs repo root, against commit 4f685d1):
- Schema:
packages/server/webjs-config.schema.json — additionalProperties: false at L8; add crons (array of objects {path: string, schedule: string}, path must start with /, both required, additionalProperties: false on the item). Keys currently listed L10-225 (elide, seed, clientRouter, headers, redirects, trailingSlash, basePath, allowedOrigins, csp, maxBodyBytes, maxMultipartBytes, requestTimeoutMs, headersTimeoutMs, keepAliveTimeoutMs, dev, start, db, doctor). The schema is exported from packages/server/package.json:20 and referenced by the scaffold's packages/cli/templates/.vscode/settings.json:9, so editors pick it up with no further change.
- Validator:
packages/server/src/webjs-config-validate.js — validateWebjsBlock L60, unknown-key path L65-68. House rule in the docblock L13-18: it WARNS, never throws. Leaf-only type checks; nested objects are deliberately not descended (L28-32) — so cron syntax is validated in the reader, not here.
- Reader:
packages/server/src/dev/config.js — add readCronsFromApp(appDir) beside readBasePathFromApp (L184) / readAllowedOriginsFromApp (L231). Validate each entry: path starts with /; schedule is 5 space-separated fields of numbers, *, ,, -, / (no names, UTC), or @hourly|@daily|@weekly|@monthly. Bad entries warn with the index (webjs.crons[2]: …) and are dropped; never throw. Boot call site for the warn pass: warnOnInvalidWebjsConfig (config.js:204), called from dev/handler.js:158.
- Types:
packages/core/src/webjs-config.d.ts interface WebjsConfig (L196) — add crons?: Array<{ path: string; schedule: string }> with a JSDoc line naming pilots and the X-Pilot-Cron header.
- Accessor:
packages/server/src/context.js — canonical shape is headers() at L190-198 (getRequest(), throw 'headers(): called outside a request scope', markDynamicAccess(), return). Add cron() next to it returning req.headers.get('x-pilot-cron') ?? null. Export from packages/server/index.js:48 (the context.js export line) and add the signature to packages/server/index.d.ts.
- Optional CLI:
packages/cli/bin/webjs.js — command cases at dev L450 / start L512; add cron <path> that resolves the port the way dev does (packages/cli/lib/port.js) and issues the GET with the header, printing status + body.
Landmines / gotchas:
markDynamicAccess() matters: the HTML cache (packages/server/src/html-cache.js) would otherwise serve a cached body to the next cron hit; the cache already refuses non-200 and Set-Cookie, but a 200 JSON body from a cron route is cacheable without the mark.
- The header name is pilots' contract (
X-Pilot-Cron), not a webjs invention — document it as "the pilots header" so a future second platform can be added as a second header check without breaking callers.
- Ordering with pilots: pilots reads
webjs.crons at deploy time regardless of the framework version, but until this schema change ships every boot logs unknown key "crons". Land this before (or with) the pilots side.
- Docs page
website/app/docs/configuration/page.ts hard-codes "9 of the 18 known keys" at L167 — the count changes.
Invariants (AGENTS.md): no build step, no new runtime dependency, warn-never-throw for config, accessors are no-arg and request-scoped, route.ts stays server-only.
Tests + docs surfaces (run webjs-doc-sync before editing docs; the list below is what it will find):
- Tests:
packages/server/test/config/webjs-config-schema.test.js (imports validateWebjsBlock L29) and webjs-config-validate.test.js — a crons entry validates, a bad path/schedule warns with the index; packages/server/test/api/api.test.js — cron() returns the header value under the request handler from packages/server/src/testing.js (createRequestHandler({ appDir, dev: true, testMode: true }) L489) and null without it, and throws outside a scope. Node tests: npm test.
- Docs site:
website/app/docs/configuration/page.ts (new crons section + the L167 count), website/app/docs/api-routes/page.ts (toolkit list L122-127, summary L358), website/app/docs/deployment/page.ts (pilots section). llms.txt is derived (website/lib/docs-llms.server.ts), nothing to hand-edit.
- Skill:
.agents/skills/webjs/SKILL.md:171 (toolkit one-liner), references/routing-and-pages.md:106-112 (toolkit paragraph + sample), references/built-ins.md (config keys). AGENTS.md:594 (the bullet enumerating every webjs block key). MCP corpus is a copy of the skill (packages/mcp/src/mcp-docs.js), no separate edit.
Acceptance criteria
Problem
pilots (our Firecracker sandbox + PaaS,
vivek7405/pilots) is adding scheduled jobs: on a cron schedule the platform makes an HTTPGET <path>to the app, tagged with the headerX-Pilot-Cron: <cron expr>, which its public edge strips from any request arriving from outside — so a handler can trust the header without a secret. Vercel's model (vercel.json→crons: [{path, schedule}], a GET withx-vercel-cron-schedule).webjs must deploy on pilots with zero per-platform config (pilots bar 4). Today a webjs app has nowhere to declare a cron, and a route handler has no idiomatic way to ask "was I invoked by the scheduler?". pilots' deploy planner already reads
package.json(apps/hostd/internal/detect/detect.goreadPackageJSON), so the framework's own config block is the zero-config door — but the block's schema isadditionalProperties: false, and an unknowncronskey warns on every boot until the framework knows it.Design / approach
Vercel-exact shape inside the block webjs already owns, so Next users' muscle memory carries over and pilots maps
schedule→its owncronfield in one line:Two additions and one optional helper. No scheduler in the framework: the platform fires the request; webjs only declares and recognises. This keeps the framework buildless and timer-free (the only interval in
packages/server/srctoday is the SSE keepalive,listener-core.js:352; keep it that way).webjs.crons— schema, types, reader with warn-only validation.cron()— besideheaders()/cookies(): returns the schedule string when the request carriesx-pilot-cron, elsenull; throws outside a request scope like its siblings; callsmarkDynamicAccess()so a cron response is never HTML-cached. A handler is then one line:if (!cron()) return json({ error: 'not a cron' }, { status: 403 }).webjs cron <path>: GETs the running dev server atpathwithX-Pilot-Cron: manual, so a job can be exercised locally before deploy.Alternative considered and deferred:
export const schedule = '…'insideroute.tswith the list exposed at/__webjs/cronsfor runtime discovery — nicer DX, but needs a route scan in the framework and a runtime read on the platform side. Can layer on later without changing the config key.Implementation notes (for the implementing agent)
Where to edit (paths relative to the webjs repo root, against commit 4f685d1):
packages/server/webjs-config.schema.json—additionalProperties: falseat L8; addcrons(array of objects{path: string, schedule: string},pathmust start with/, both required,additionalProperties: falseon the item). Keys currently listed L10-225 (elide,seed,clientRouter,headers,redirects,trailingSlash,basePath,allowedOrigins,csp,maxBodyBytes,maxMultipartBytes,requestTimeoutMs,headersTimeoutMs,keepAliveTimeoutMs,dev,start,db,doctor). The schema is exported frompackages/server/package.json:20and referenced by the scaffold'spackages/cli/templates/.vscode/settings.json:9, so editors pick it up with no further change.packages/server/src/webjs-config-validate.js—validateWebjsBlockL60, unknown-key path L65-68. House rule in the docblock L13-18: it WARNS, never throws. Leaf-only type checks; nested objects are deliberately not descended (L28-32) — so cron syntax is validated in the reader, not here.packages/server/src/dev/config.js— addreadCronsFromApp(appDir)besidereadBasePathFromApp(L184) /readAllowedOriginsFromApp(L231). Validate each entry:pathstarts with/;scheduleis 5 space-separated fields of numbers,*,,,-,/(no names, UTC), or@hourly|@daily|@weekly|@monthly. Bad entries warn with the index (webjs.crons[2]: …) and are dropped; never throw. Boot call site for the warn pass:warnOnInvalidWebjsConfig(config.js:204), called fromdev/handler.js:158.packages/core/src/webjs-config.d.tsinterface WebjsConfig(L196) — addcrons?: Array<{ path: string; schedule: string }>with a JSDoc line naming pilots and theX-Pilot-Cronheader.packages/server/src/context.js— canonical shape isheaders()at L190-198 (getRequest(), throw'headers(): called outside a request scope',markDynamicAccess(), return). Addcron()next to it returningreq.headers.get('x-pilot-cron') ?? null. Export frompackages/server/index.js:48(thecontext.jsexport line) and add the signature topackages/server/index.d.ts.packages/cli/bin/webjs.js— command cases atdevL450 /startL512; addcron <path>that resolves the port the waydevdoes (packages/cli/lib/port.js) and issues the GET with the header, printing status + body.Landmines / gotchas:
markDynamicAccess()matters: the HTML cache (packages/server/src/html-cache.js) would otherwise serve a cached body to the next cron hit; the cache already refuses non-200 andSet-Cookie, but a 200 JSON body from a cron route is cacheable without the mark.X-Pilot-Cron), not a webjs invention — document it as "the pilots header" so a future second platform can be added as a second header check without breaking callers.webjs.cronsat deploy time regardless of the framework version, but until this schema change ships every boot logsunknown key "crons". Land this before (or with) the pilots side.website/app/docs/configuration/page.tshard-codes "9 of the 18 known keys" at L167 — the count changes.Invariants (AGENTS.md): no build step, no new runtime dependency, warn-never-throw for config, accessors are no-arg and request-scoped,
route.tsstays server-only.Tests + docs surfaces (run
webjs-doc-syncbefore editing docs; the list below is what it will find):packages/server/test/config/webjs-config-schema.test.js(importsvalidateWebjsBlockL29) andwebjs-config-validate.test.js— acronsentry validates, a badpath/schedulewarns with the index;packages/server/test/api/api.test.js—cron()returns the header value under the request handler frompackages/server/src/testing.js(createRequestHandler({ appDir, dev: true, testMode: true })L489) andnullwithout it, and throws outside a scope. Node tests:npm test.website/app/docs/configuration/page.ts(newcronssection + the L167 count),website/app/docs/api-routes/page.ts(toolkit list L122-127, summary L358),website/app/docs/deployment/page.ts(pilots section).llms.txtis derived (website/lib/docs-llms.server.ts), nothing to hand-edit..agents/skills/webjs/SKILL.md:171(toolkit one-liner),references/routing-and-pages.md:106-112(toolkit paragraph + sample),references/built-ins.md(config keys).AGENTS.md:594(the bullet enumerating everywebjsblock key). MCP corpus is a copy of the skill (packages/mcp/src/mcp-docs.js), no separate edit.Acceptance criteria
"webjs": {"crons": [{"path": "/jobs/x", "schedule": "0 5 * * *"}]}boots with no warning; a bad entry warns with its index and is dropped; the app still boots.import { cron } from '@webjsdev/server': returns the schedule string whenx-pilot-cronis present,nullotherwise, throws outside a request scope; the response is not HTML-cached.crons; editors autocomplete it.AGENTS.mdkey list, and the configuration page's key count are updated.