From 085b40b872f933c34f92aa552f7d35df0997cdab Mon Sep 17 00:00:00 2001 From: Oscar Jiang Date: Sat, 11 Jul 2026 18:27:55 +0800 Subject: [PATCH 1/2] feat(eve): external cron mode for self-hosted builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosted (node preset) builds fire schedules from Nitro's in-process runner with no off switch: replicas duplicate every tick, downtime loses ticks silently, and production exposes no dispatch endpoint, so a hosting platform can neither pause a schedule nor trigger one. Building with EVE_EXTERNAL_CRON=1 now registers the schedule task handlers without any scheduledTasks entries — the in-process runner never starts — and instead mounts the same unguessable token cron route the Vercel preset uses. The route mirrors Nitro's Vercel cron handler contract (x-vercel-cron-schedule header, optional CRON_SECRET bearer check) and dispatches through dispatchScheduleTask. The build writes .output/eve/cron-manifest.json (route path plus each schedule's name and cron) — the self-hosted equivalent of the config.crons[] Vercel reads from build output. No effect on Vercel builds or eve dev. Closes #707 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0164WUaSv9mN58wgnnoN2vKG Signed-off-by: Oscar Jiang --- .changeset/external-cron-mode.md | 7 + docs/guides/deployment.md | 24 +++- .../nitro/host/create-application-nitro.ts | 14 ++ .../host/external-cron.integration.test.ts | 86 ++++++++++++ .../internal/nitro/host/external-cron.test.ts | 117 +++++++++++++++++ .../src/internal/nitro/host/external-cron.ts | 123 ++++++++++++++++++ .../nitro/host/schedule-task-routes.test.ts | 23 ++++ .../nitro/host/schedule-task-routes.ts | 12 +- .../nitro/routes/external-cron.test.ts | 107 +++++++++++++++ .../internal/nitro/routes/external-cron.ts | 73 +++++++++++ 10 files changed, 584 insertions(+), 2 deletions(-) create mode 100644 .changeset/external-cron-mode.md create mode 100644 packages/eve/src/internal/nitro/host/external-cron.integration.test.ts create mode 100644 packages/eve/src/internal/nitro/host/external-cron.test.ts create mode 100644 packages/eve/src/internal/nitro/host/external-cron.ts create mode 100644 packages/eve/src/internal/nitro/routes/external-cron.test.ts create mode 100644 packages/eve/src/internal/nitro/routes/external-cron.ts diff --git a/.changeset/external-cron-mode.md b/.changeset/external-cron-mode.md new file mode 100644 index 000000000..4db9dc7c8 --- /dev/null +++ b/.changeset/external-cron-mode.md @@ -0,0 +1,7 @@ +--- +"eve": minor +--- + +feat(eve): external cron mode for self-hosted builds (`EVE_EXTERNAL_CRON=1`) + +Building with `EVE_EXTERNAL_CRON=1` on the node preset registers no in-process cron: the deployment instead mounts the same unguessable token cron route the Vercel preset uses (`POST /eve/v1/cron/`, `x-vercel-cron-schedule` header, optional `CRON_SECRET` bearer check) and writes `.output/eve/cron-manifest.json` with the route path and each schedule's name and cron expression. Hosting platforms can own the clock — replica deduplication, catch-up policies, manual triggering — by driving that route from their own scheduler, exactly like Vercel Cron does. diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index ccd0066b2..bd998f84b 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -146,11 +146,33 @@ Self-deployed agents should make the Vercel-specific choices explicit: - Use `AI_GATEWAY_API_KEY` if you still want Gateway routing from a non-Vercel host. - Replace `vercelOidc()` with auth that your host can verify. - Use `defaultBackend()`, a pinned non-Vercel sandbox backend such as Docker or microsandbox, or your own `SandboxBackend` adapter. -- If the agent defines schedules, the default `eve build && eve start` path starts Nitro's schedule runner, and Vercel wires schedules to Vercel Cron automatically. If you adapt the output to a custom HTTP-only host or preset, make sure it also runs Nitro scheduled tasks, or trigger the same work from your own scheduler. +- If the agent defines schedules, the default `eve build && eve start` path starts Nitro's schedule runner, and Vercel wires schedules to Vercel Cron automatically. If you adapt the output to a custom HTTP-only host or preset, make sure it also runs Nitro scheduled tasks, trigger the same work from your own scheduler, or build with `EVE_EXTERNAL_CRON=1` (below) to hand the clock to an external scheduler. - Treat Vercel Cron, Vercel Sandbox prewarm, Vercel Deployment Protection bypass, and the Agent Runs dashboard as Vercel-only conveniences. The HTTP contract is unchanged: health, session creation, streaming, channels, tools, and subagents use the same routes under `/eve/`, and the workflow dispatch route lives under `/.well-known/workflow/`. A reverse proxy must preserve both prefixes. Any client that can reach and authenticate to those routes can talk to the agent. +### External schedule dispatch + +By default a self-hosted build fires schedules from an in-process runner: every replica keeps its own clock, ticks that land while the process is down are lost, and production exposes no endpoint to pause or trigger a schedule. A hosting platform that wants to own the clock — deduplication across replicas, catch-up policies, manual "run now" — can build in external cron mode: + +```bash +EVE_EXTERNAL_CRON=1 eve build +``` + +The build then registers no in-process cron. Instead it: + +- mounts the same unguessable token cron route the Vercel preset uses (`POST /eve/v1/cron/`); the path is the credential, and a configured `CRON_SECRET` is additionally enforced as a bearer token, exactly as on Vercel; +- writes `.output/eve/cron-manifest.json` with the route path and each schedule's name and cron expression — the self-hosted equivalent of the `config.crons[]` Vercel reads from build output. The manifest contains the secret path, so it lives only in build output and is never served. + +Your scheduler drives the deployment the same way Vercel Cron does: on each due tick, POST to the route with the cron expression in the `x-vercel-cron-schedule` header: + +```bash +curl -X POST "https://$(jq -r .cronHandlerRoute .output/eve/cron-manifest.json)" \ + -H "x-vercel-cron-schedule: 0 8 * * *" +``` + +Every schedule registered for that expression dispatches, and the response lists the sessions each dispatch started. The flag has no effect on Vercel builds, where the platform already owns the clock, or in `eve dev`, where schedules are dispatched through the dev route. + ## 9. Verify the deployment Smoke-test the live routes. Health first: diff --git a/packages/eve/src/internal/nitro/host/create-application-nitro.ts b/packages/eve/src/internal/nitro/host/create-application-nitro.ts index 1fbd73707..5f7ab46d2 100644 --- a/packages/eve/src/internal/nitro/host/create-application-nitro.ts +++ b/packages/eve/src/internal/nitro/host/create-application-nitro.ts @@ -22,6 +22,10 @@ import { import { createCompiledSandboxBackendPrunePlugin } from "#internal/nitro/host/compiled-sandbox-backend-prune-plugin.js"; import { configureNitroRoutes } from "#internal/nitro/host/configure-nitro-routes.js"; import { applyEveCronHandlerRoute } from "#internal/nitro/host/cron-handler-route.js"; +import { + applyExternalCronHandlerRoute, + isExternalCronEnabled, +} from "#internal/nitro/host/external-cron.js"; import { createNitroBundlerConfig } from "#internal/nitro/host/nitro-bundler-config.js"; import { captureDevLiveVirtualModules } from "#internal/nitro/host/dev-live-virtual-modules.js"; import { @@ -801,6 +805,9 @@ export async function createApplicationNitro( // use (e.g. dev mode), where the cron route is never registered. applyEveCronHandlerRoute(nitro); + // External cron mode only applies to self-hosted (node preset) builds: + // on Vercel the platform already owns the clock through the route above. + const externalCron = preset === undefined && isExternalCronEnabled(); const artifactsConfig: NitroArtifactsConfigInput = createNitroArtifactsConfig({ appRoot: preparedHost.appRoot, dev: nitro.options.dev, @@ -810,8 +817,15 @@ export async function createApplicationNitro( dispatchModulePath: resolvePackageSourceFilePath( "src/internal/nitro/routes/schedule-task.ts", ), + registerCronSchedules: !externalCron, registrations: preparedHost.scheduleRegistrations, }); + if (externalCron) { + applyExternalCronHandlerRoute(nitro, { + artifactsConfig, + registrations: preparedHost.scheduleRegistrations, + }); + } } await configureNitroRoutes(nitro, preparedHost, { surface, diff --git a/packages/eve/src/internal/nitro/host/external-cron.integration.test.ts b/packages/eve/src/internal/nitro/host/external-cron.integration.test.ts new file mode 100644 index 000000000..f6ba7250a --- /dev/null +++ b/packages/eve/src/internal/nitro/host/external-cron.integration.test.ts @@ -0,0 +1,86 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { ScheduleRegistration } from "#runtime/schedules/register.js"; +import { + applyExternalCronHandlerRoute, + EVE_CRON_MANIFEST_OUTPUT_PATH, + type ExternalCronNitro, +} from "#internal/nitro/host/external-cron.js"; + +const ARTIFACTS_CONFIG = { + appRoot: "/tmp/test-agent", + dev: false, +} as const; + +const REGISTRATIONS: ScheduleRegistration[] = [ + { + cron: "0 8 * * *", + description: 'Run eve schedule "daily".', + logicalPath: "schedules/daily.md", + scheduleId: "daily", + sourceId: "schedules/daily.md", + taskName: "eve.schedule.daily", + }, + { + cron: "*/5 * * * *", + description: 'Run eve schedule "heartbeat".', + logicalPath: "schedules/heartbeat.md", + scheduleId: "heartbeat", + sourceId: "schedules/heartbeat.md", + taskName: "eve.schedule.heartbeat", + }, +]; + +describe("applyExternalCronHandlerRoute manifest emission", () => { + let outputDir: string; + + beforeEach(async () => { + outputDir = await mkdtemp(join(tmpdir(), "eve-external-cron-")); + }); + + afterEach(async () => { + await rm(outputDir, { force: true, recursive: true }); + }); + + it("writes the cron manifest into the output directory on compiled", async () => { + const compiledHooks: Array<() => Promise | void> = []; + const nitro: ExternalCronNitro = { + hooks: { + hook(name, fn) { + if (name === "compiled") { + compiledHooks.push(fn); + } + }, + }, + options: { + handlers: [], + output: { dir: outputDir }, + virtual: {}, + }, + }; + + const route = applyExternalCronHandlerRoute(nitro, { + artifactsConfig: ARTIFACTS_CONFIG, + registrations: REGISTRATIONS, + }); + + expect(compiledHooks).toHaveLength(1); + await compiledHooks[0]!(); + + const manifest = JSON.parse( + await readFile(join(outputDir, EVE_CRON_MANIFEST_OUTPUT_PATH), "utf8"), + ) as unknown; + expect(manifest).toEqual({ + version: 1, + cronHandlerRoute: route, + crons: [ + { name: "daily", cron: "0 8 * * *" }, + { name: "heartbeat", cron: "*/5 * * * *" }, + ], + }); + }); +}); diff --git a/packages/eve/src/internal/nitro/host/external-cron.test.ts b/packages/eve/src/internal/nitro/host/external-cron.test.ts new file mode 100644 index 000000000..fae39788d --- /dev/null +++ b/packages/eve/src/internal/nitro/host/external-cron.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import { EVE_ROUTE_PREFIX } from "#protocol/routes.js"; +import type { ScheduleRegistration } from "#runtime/schedules/register.js"; +import { + applyExternalCronHandlerRoute, + isExternalCronEnabled, + type ExternalCronNitro, +} from "#internal/nitro/host/external-cron.js"; + +const ARTIFACTS_CONFIG = { + appRoot: "/tmp/test-agent", + dev: false, +} as const; + +const REGISTRATIONS: ScheduleRegistration[] = [ + { + cron: "0 8 * * *", + description: 'Run eve schedule "daily".', + logicalPath: "schedules/daily.md", + scheduleId: "daily", + sourceId: "schedules/daily.md", + taskName: "eve.schedule.daily", + }, + { + cron: "*/5 * * * *", + description: 'Run eve schedule "heartbeat".', + logicalPath: "schedules/heartbeat.md", + scheduleId: "heartbeat", + sourceId: "schedules/heartbeat.md", + taskName: "eve.schedule.heartbeat", + }, +]; + +describe("isExternalCronEnabled", () => { + it("accepts 1 and true, case- and whitespace-insensitively", () => { + expect(isExternalCronEnabled({ EVE_EXTERNAL_CRON: "1" })).toBe(true); + expect(isExternalCronEnabled({ EVE_EXTERNAL_CRON: "true" })).toBe(true); + expect(isExternalCronEnabled({ EVE_EXTERNAL_CRON: " TRUE " })).toBe(true); + }); + + it("rejects everything else", () => { + expect(isExternalCronEnabled({})).toBe(false); + expect(isExternalCronEnabled({ EVE_EXTERNAL_CRON: "" })).toBe(false); + expect(isExternalCronEnabled({ EVE_EXTERNAL_CRON: "0" })).toBe(false); + expect(isExternalCronEnabled({ EVE_EXTERNAL_CRON: "false" })).toBe(false); + expect(isExternalCronEnabled({ EVE_EXTERNAL_CRON: "yes" })).toBe(false); + }); +}); + +describe("applyExternalCronHandlerRoute", () => { + it("registers a POST handler at an unguessable route with the schedules baked in", () => { + const nitro = createNitroStub(); + + const route = applyExternalCronHandlerRoute(nitro, { + artifactsConfig: ARTIFACTS_CONFIG, + registrations: REGISTRATIONS, + }); + + expect(route.startsWith(`${EVE_ROUTE_PREFIX}/cron/`)).toBe(true); + expect(nitro.options.handlers).toEqual([ + { + handler: "#eve-route-external-cron", + method: "POST", + route, + }, + ]); + + const virtualSource = nitro.options.virtual["#eve-route-external-cron"]; + expect(virtualSource).toContain("handleExternalCronRequest"); + expect(virtualSource).toContain( + JSON.stringify({ + artifactsConfig: ARTIFACTS_CONFIG, + schedules: [ + { cron: "0 8 * * *", name: "daily", taskName: "eve.schedule.daily" }, + { cron: "*/5 * * * *", name: "heartbeat", taskName: "eve.schedule.heartbeat" }, + ], + }), + ); + }); + + it("registers exactly one manifest write on the compiled hook", () => { + const nitro = createNitroStub(); + + applyExternalCronHandlerRoute(nitro, { + artifactsConfig: ARTIFACTS_CONFIG, + registrations: REGISTRATIONS, + }); + + // The write-out itself (and its content) is covered by the + // integration test — unit tests stay hermetic. + expect(nitro.compiledHooks).toHaveLength(1); + }); +}); + +type NitroStub = ExternalCronNitro & { + compiledHooks: Array<() => Promise | void>; +}; + +function createNitroStub(): NitroStub { + const compiledHooks: Array<() => Promise | void> = []; + return { + compiledHooks, + hooks: { + hook(name, fn) { + if (name === "compiled") { + compiledHooks.push(fn); + } + }, + }, + options: { + handlers: [], + output: { dir: "/tmp/fake-output" }, + virtual: {}, + }, + }; +} diff --git a/packages/eve/src/internal/nitro/host/external-cron.ts b/packages/eve/src/internal/nitro/host/external-cron.ts new file mode 100644 index 000000000..f42dbbdfd --- /dev/null +++ b/packages/eve/src/internal/nitro/host/external-cron.ts @@ -0,0 +1,123 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import type { Nitro } from "nitro/types"; + +import { stringifyEsmImportSpecifier } from "#internal/application/import-specifier.js"; +import { resolvePackageSourceFilePath } from "#internal/application/package.js"; +import type { NitroArtifactsConfigInput } from "#internal/nitro/host/artifacts-config.js"; +import { createEveCronHandlerRoute } from "#internal/nitro/host/cron-handler-route.js"; +import type { ExternalCronRouteConfig } from "#internal/nitro/routes/external-cron.js"; +import type { ScheduleRegistration } from "#runtime/schedules/register.js"; + +/** + * Build-time switch for external cron mode on self-hosted (node preset) + * builds. When truthy, the build registers no Nitro `scheduledTasks` — + * so the in-process schedule runner never starts — and instead exposes + * the same unguessable token cron route the Vercel preset uses, plus a + * manifest the hosting platform reads to drive the clock itself. + */ +export const EVE_EXTERNAL_CRON_ENV_VAR = "EVE_EXTERNAL_CRON"; + +/** + * Where the cron manifest lands, relative to the Nitro output directory + * (`.output/eve/cron-manifest.json` for a default build). The manifest + * contains the secret route path, so it must stay in build output — + * readable by whoever ran the build, never served over HTTP. + */ +export const EVE_CRON_MANIFEST_OUTPUT_PATH = join("eve", "cron-manifest.json"); + +/** + * Contract of the emitted cron manifest — the self-hosted equivalent of + * the `config.crons[]` Vercel reads from its build output. + */ +export interface EveCronManifest { + readonly version: 1; + readonly cronHandlerRoute: string; + readonly crons: ReadonlyArray<{ + readonly name: string; + readonly cron: string; + }>; +} + +export function isExternalCronEnabled( + env: Record = process.env, +): boolean { + const value = env[EVE_EXTERNAL_CRON_ENV_VAR]?.trim().toLowerCase(); + return value === "1" || value === "true"; +} + +/** + * The narrow Nitro surface the external cron wiring needs — structural, + * so tests can drive it without a full Nitro instance. + */ +export interface ExternalCronNitro { + hooks: { + hook(name: "compiled", handler: () => void | Promise): unknown; + }; + options: Pick & { + output: Pick; + }; +} + +/** + * Registers the external cron dispatch route and schedules the manifest + * write-out for one external-cron production build. + * + * The route handler bakes in the compiled schedule registrations so it + * can dispatch by cron expression without consulting Nitro's + * `scheduledTasks` (deliberately left empty in this mode). The manifest + * is written on `compiled`, once the output directory exists. + */ +export function applyExternalCronHandlerRoute( + nitro: ExternalCronNitro, + input: { + readonly artifactsConfig: NitroArtifactsConfigInput; + readonly registrations: readonly ScheduleRegistration[]; + }, +): string { + const route = createEveCronHandlerRoute(); + const virtualId = `#eve-route-external-cron`; + const modulePath = stringifyEsmImportSpecifier( + resolvePackageSourceFilePath("src/internal/nitro/routes/external-cron.ts"), + ); + const routeConfig: ExternalCronRouteConfig = { + artifactsConfig: input.artifactsConfig, + schedules: input.registrations.map((registration) => ({ + cron: registration.cron, + name: registration.scheduleId, + taskName: registration.taskName, + })), + }; + + nitro.options.handlers.push({ + handler: virtualId, + method: "POST", + route, + }); + nitro.options.virtual[virtualId] = [ + `import { handleExternalCronRequest } from ${modulePath};`, + `const config = ${JSON.stringify(routeConfig)};`, + `export default async (event) => handleExternalCronRequest(config, event.req);`, + ].join("\n"); + + const manifest: EveCronManifest = { + version: 1, + cronHandlerRoute: route, + crons: input.registrations.map((registration) => ({ + name: registration.scheduleId, + cron: registration.cron, + })), + }; + nitro.hooks.hook("compiled", async () => { + await writeEveCronManifest(nitro.options.output.dir, manifest); + }); + + return route; +} + +async function writeEveCronManifest(outputDir: string, manifest: EveCronManifest): Promise { + const manifestPath = join(outputDir, EVE_CRON_MANIFEST_OUTPUT_PATH); + await mkdir(dirname(manifestPath), { recursive: true }); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +} diff --git a/packages/eve/src/internal/nitro/host/schedule-task-routes.test.ts b/packages/eve/src/internal/nitro/host/schedule-task-routes.test.ts index 4ec6b337b..fa51b84d9 100644 --- a/packages/eve/src/internal/nitro/host/schedule-task-routes.test.ts +++ b/packages/eve/src/internal/nitro/host/schedule-task-routes.test.ts @@ -84,6 +84,29 @@ describe("schedule task routes", () => { expect(virtualSource).toContain("dispatchScheduleTask(event.name, config)"); }); + it("registers task handlers without cron entries when registerCronSchedules is false", () => { + const nitro = createNitroStub(); + const registration = makeRegistration({ + cron: "0 8 * * *", + logicalPath: "schedules/daily.md", + scheduleId: "daily", + sourceId: "schedules/daily.md", + }); + + registerScheduleTaskHandlers(nitro, { + artifactsConfig: ARTIFACTS_CONFIG, + dispatchModulePath: DISPATCH_MODULE_PATH, + registerCronSchedules: false, + registrations: [registration], + }); + + expect(nitro.options.experimental.tasks).toBe(true); + expect(nitro.options.tasks[registration.taskName]).toBeDefined(); + expect(nitro.options.virtual[`#eve-schedule-task/${registration.taskName}`]).toBeDefined(); + // No cron entries means Nitro's in-process schedule runner never starts. + expect(nitro.options.scheduledTasks).toEqual({}); + }); + it("does nothing when there are no registrations", () => { const nitro = createNitroStub(); diff --git a/packages/eve/src/internal/nitro/host/schedule-task-routes.ts b/packages/eve/src/internal/nitro/host/schedule-task-routes.ts index 4af9fe93a..978e1470e 100644 --- a/packages/eve/src/internal/nitro/host/schedule-task-routes.ts +++ b/packages/eve/src/internal/nitro/host/schedule-task-routes.ts @@ -26,10 +26,16 @@ interface ScheduleTaskNitro { * `dispatchModulePath` is the absolute path of `dispatchScheduleTask`'s * module — the synthetic task module imports it and forwards `event.name` * along with the baked-in artifacts config. + * + * `registerCronSchedules: false` registers the task handlers without any + * `scheduledTasks` entries, so the node preset's in-process schedule + * runner never starts. External-cron builds use this: dispatch happens + * through the token cron route instead. */ export interface RegisterScheduleTaskHandlersInput { readonly artifactsConfig: NitroArtifactsConfigInput; readonly dispatchModulePath: string; + readonly registerCronSchedules?: boolean; readonly registrations: readonly ScheduleRegistration[]; } @@ -73,6 +79,7 @@ export function registerScheduleTaskHandlers( addScheduleTaskVirtualHandler(nitro, { artifactsConfig: input.artifactsConfig, dispatchModulePath: input.dispatchModulePath, + registerCronSchedule: input.registerCronSchedules ?? true, registration, }); } @@ -148,6 +155,7 @@ function addScheduleTaskVirtualHandler( input: { artifactsConfig: NitroArtifactsConfigInput; dispatchModulePath: string; + registerCronSchedule: boolean; registration: ScheduleRegistration; }, ): void { @@ -175,7 +183,9 @@ function addScheduleTaskVirtualHandler( `};`, ].join("\n"); - appendScheduledTask(nitro, input.registration.cron, input.registration.taskName); + if (input.registerCronSchedule) { + appendScheduledTask(nitro, input.registration.cron, input.registration.taskName); + } } function appendScheduledTask(nitro: ScheduleTaskNitro, cron: string, taskName: string): void { diff --git a/packages/eve/src/internal/nitro/routes/external-cron.test.ts b/packages/eve/src/internal/nitro/routes/external-cron.test.ts new file mode 100644 index 000000000..1385ca6c4 --- /dev/null +++ b/packages/eve/src/internal/nitro/routes/external-cron.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("#internal/nitro/routes/schedule-task.js", () => ({ + dispatchScheduleTask: vi.fn(async (taskName: string) => ({ + scheduleId: taskName.replace("eve.schedule.", ""), + sessionIds: [`session-for-${taskName}`], + })), +})); + +import { dispatchScheduleTask } from "#internal/nitro/routes/schedule-task.js"; +import { + EXTERNAL_CRON_SCHEDULE_HEADER, + handleExternalCronRequest, + type ExternalCronRouteConfig, +} from "#internal/nitro/routes/external-cron.js"; + +const CONFIG: ExternalCronRouteConfig = { + artifactsConfig: { appRoot: "/tmp/test-agent", dev: false }, + schedules: [ + { cron: "0 8 * * *", name: "daily", taskName: "eve.schedule.daily" }, + { cron: "0 8 * * *", name: "digest", taskName: "eve.schedule.digest" }, + { cron: "*/5 * * * *", name: "heartbeat", taskName: "eve.schedule.heartbeat" }, + ], +}; + +function createRequest(headers: Record = {}): Request { + return new Request("http://localhost/eve/v1/cron/test-token", { + headers, + method: "POST", + }); +} + +describe("handleExternalCronRequest", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it("dispatches every schedule registered for the due cron expression", async () => { + const response = await handleExternalCronRequest( + CONFIG, + createRequest({ [EXTERNAL_CRON_SCHEDULE_HEADER]: "0 8 * * *" }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + success: true, + dispatched: [ + { scheduleId: "daily", sessionIds: ["session-for-eve.schedule.daily"] }, + { scheduleId: "digest", sessionIds: ["session-for-eve.schedule.digest"] }, + ], + }); + expect(dispatchScheduleTask).toHaveBeenCalledTimes(2); + expect(dispatchScheduleTask).toHaveBeenCalledWith("eve.schedule.daily", CONFIG.artifactsConfig); + }); + + it("succeeds with an empty dispatch list for an unknown cron expression", async () => { + const response = await handleExternalCronRequest( + CONFIG, + createRequest({ [EXTERNAL_CRON_SCHEDULE_HEADER]: "59 23 * * *" }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true, dispatched: [] }); + expect(dispatchScheduleTask).not.toHaveBeenCalled(); + }); + + it("rejects requests without the cron schedule header", async () => { + const response = await handleExternalCronRequest(CONFIG, createRequest()); + + expect(response.status).toBe(400); + expect(dispatchScheduleTask).not.toHaveBeenCalled(); + }); + + it("enforces CRON_SECRET as a bearer token when configured", async () => { + vi.stubEnv("CRON_SECRET", "s3cret"); + + const missingAuth = await handleExternalCronRequest( + CONFIG, + createRequest({ [EXTERNAL_CRON_SCHEDULE_HEADER]: "*/5 * * * *" }), + ); + expect(missingAuth.status).toBe(401); + + const wrongAuth = await handleExternalCronRequest( + CONFIG, + createRequest({ + [EXTERNAL_CRON_SCHEDULE_HEADER]: "*/5 * * * *", + authorization: "Bearer wrong", + }), + ); + expect(wrongAuth.status).toBe(401); + expect(dispatchScheduleTask).not.toHaveBeenCalled(); + + const authorized = await handleExternalCronRequest( + CONFIG, + createRequest({ + [EXTERNAL_CRON_SCHEDULE_HEADER]: "*/5 * * * *", + authorization: "Bearer s3cret", + }), + ); + expect(authorized.status).toBe(200); + expect(dispatchScheduleTask).toHaveBeenCalledWith( + "eve.schedule.heartbeat", + CONFIG.artifactsConfig, + ); + }); +}); diff --git a/packages/eve/src/internal/nitro/routes/external-cron.ts b/packages/eve/src/internal/nitro/routes/external-cron.ts new file mode 100644 index 000000000..8a357a19f --- /dev/null +++ b/packages/eve/src/internal/nitro/routes/external-cron.ts @@ -0,0 +1,73 @@ +import { timingSafeEqual } from "node:crypto"; + +import type { NitroArtifactsConfig } from "#internal/nitro/routes/runtime-artifacts.js"; +import { dispatchScheduleTask } from "#internal/nitro/routes/schedule-task.js"; + +/** + * Header carrying the due cron expression, kept identical to Nitro's + * Vercel cron handler contract so one external-scheduler client can + * drive Vercel and self-hosted deployments alike. + */ +export const EXTERNAL_CRON_SCHEDULE_HEADER = "x-vercel-cron-schedule"; + +/** One dispatchable schedule baked into the external cron route. */ +export interface ExternalCronScheduleEntry { + readonly cron: string; + readonly name: string; + readonly taskName: string; +} + +/** + * Build-time config baked into the external cron route's virtual handler. + */ +export interface ExternalCronRouteConfig { + readonly artifactsConfig: NitroArtifactsConfig; + readonly schedules: readonly ExternalCronScheduleEntry[]; +} + +/** + * Dispatches the schedules registered for one cron expression. + * + * Mounted only on external-cron builds, at the same unguessable + * per-build path the Vercel preset uses — the path is the credential. + * The request contract also mirrors Nitro's Vercel cron handler: the + * due cron expression arrives in {@link EXTERNAL_CRON_SCHEDULE_HEADER}, + * and when `CRON_SECRET` is set the `Authorization` header must carry + * it as a bearer token (defense in depth on top of the path). + */ +export async function handleExternalCronRequest( + config: ExternalCronRouteConfig, + request: Request, +): Promise { + const cronSecret = process.env.CRON_SECRET; + if (cronSecret !== undefined && cronSecret.length > 0) { + const authorization = request.headers.get("authorization") ?? ""; + if (!timingSafeEqualStrings(authorization, `Bearer ${cronSecret}`)) { + return Response.json({ error: "Unauthorized." }, { status: 401 }); + } + } + + const cron = request.headers.get(EXTERNAL_CRON_SCHEDULE_HEADER); + if (cron === null || cron.length === 0) { + return Response.json( + { error: `Missing ${EXTERNAL_CRON_SCHEDULE_HEADER} header.` }, + { status: 400 }, + ); + } + + const dueSchedules = config.schedules.filter((schedule) => schedule.cron === cron); + const dispatched = await Promise.all( + dueSchedules.map(async (schedule) => { + const result = await dispatchScheduleTask(schedule.taskName, config.artifactsConfig); + return { scheduleId: result.scheduleId, sessionIds: result.sessionIds }; + }), + ); + + return Response.json({ success: true, dispatched }); +} + +function timingSafeEqualStrings(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} From 4e3e5798e7ad7b03c2049233ebe7ed618d00373a Mon Sep 17 00:00:00 2001 From: Oscar Jiang Date: Sat, 11 Jul 2026 19:53:35 +0800 Subject: [PATCH 2/2] fix(eve): key the cron manifest on distinct expressions, not schedules The dispatch route fires every schedule registered for the posted expression, so a manifest entry per schedule invites schedulers to create one job per schedule and double-dispatch shared expressions. Mirror the Vercel preset, which keys config.crons on scheduledTasks: one entry per distinct expression, listing the schedules it dispatches. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0164WUaSv9mN58wgnnoN2vKG Signed-off-by: Oscar Jiang --- .changeset/external-cron-mode.md | 2 +- docs/guides/deployment.md | 2 +- .../host/external-cron.integration.test.ts | 14 +++++++++++--- .../src/internal/nitro/host/external-cron.ts | 19 +++++++++++++------ 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.changeset/external-cron-mode.md b/.changeset/external-cron-mode.md index 4db9dc7c8..57f50c88e 100644 --- a/.changeset/external-cron-mode.md +++ b/.changeset/external-cron-mode.md @@ -4,4 +4,4 @@ feat(eve): external cron mode for self-hosted builds (`EVE_EXTERNAL_CRON=1`) -Building with `EVE_EXTERNAL_CRON=1` on the node preset registers no in-process cron: the deployment instead mounts the same unguessable token cron route the Vercel preset uses (`POST /eve/v1/cron/`, `x-vercel-cron-schedule` header, optional `CRON_SECRET` bearer check) and writes `.output/eve/cron-manifest.json` with the route path and each schedule's name and cron expression. Hosting platforms can own the clock — replica deduplication, catch-up policies, manual triggering — by driving that route from their own scheduler, exactly like Vercel Cron does. +Building with `EVE_EXTERNAL_CRON=1` on the node preset registers no in-process cron: the deployment instead mounts the same unguessable token cron route the Vercel preset uses (`POST /eve/v1/cron/`, `x-vercel-cron-schedule` header, optional `CRON_SECRET` bearer check) and writes `.output/eve/cron-manifest.json` with the route path and one entry per distinct cron expression listing the schedules it dispatches. Hosting platforms can own the clock — replica deduplication, catch-up policies, manual triggering — by driving that route from their own scheduler, exactly like Vercel Cron does. diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index bd998f84b..f19329cca 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -162,7 +162,7 @@ EVE_EXTERNAL_CRON=1 eve build The build then registers no in-process cron. Instead it: - mounts the same unguessable token cron route the Vercel preset uses (`POST /eve/v1/cron/`); the path is the credential, and a configured `CRON_SECRET` is additionally enforced as a bearer token, exactly as on Vercel; -- writes `.output/eve/cron-manifest.json` with the route path and each schedule's name and cron expression — the self-hosted equivalent of the `config.crons[]` Vercel reads from build output. The manifest contains the secret path, so it lives only in build output and is never served. +- writes `.output/eve/cron-manifest.json` with the route path and one entry per distinct cron expression, each listing the schedules it dispatches — the self-hosted equivalent of the `config.crons[]` Vercel reads from build output. Create one scheduler job per entry: a POST fires every schedule registered for that expression, so one job per schedule would double-dispatch shared expressions. The manifest contains the secret path, so it lives only in build output and is never served. Your scheduler drives the deployment the same way Vercel Cron does: on each due tick, POST to the route with the cron expression in the `x-vercel-cron-schedule` header: diff --git a/packages/eve/src/internal/nitro/host/external-cron.integration.test.ts b/packages/eve/src/internal/nitro/host/external-cron.integration.test.ts index f6ba7250a..44df994de 100644 --- a/packages/eve/src/internal/nitro/host/external-cron.integration.test.ts +++ b/packages/eve/src/internal/nitro/host/external-cron.integration.test.ts @@ -33,6 +33,14 @@ const REGISTRATIONS: ScheduleRegistration[] = [ sourceId: "schedules/heartbeat.md", taskName: "eve.schedule.heartbeat", }, + { + cron: "*/5 * * * *", + description: 'Run eve schedule "sync".', + logicalPath: "schedules/sync.md", + scheduleId: "sync", + sourceId: "schedules/sync.md", + taskName: "eve.schedule.sync", + }, ]; describe("applyExternalCronHandlerRoute manifest emission", () => { @@ -46,7 +54,7 @@ describe("applyExternalCronHandlerRoute manifest emission", () => { await rm(outputDir, { force: true, recursive: true }); }); - it("writes the cron manifest into the output directory on compiled", async () => { + it("writes the cron manifest grouped by expression on compiled", async () => { const compiledHooks: Array<() => Promise | void> = []; const nitro: ExternalCronNitro = { hooks: { @@ -78,8 +86,8 @@ describe("applyExternalCronHandlerRoute manifest emission", () => { version: 1, cronHandlerRoute: route, crons: [ - { name: "daily", cron: "0 8 * * *" }, - { name: "heartbeat", cron: "*/5 * * * *" }, + { cron: "0 8 * * *", schedules: ["daily"] }, + { cron: "*/5 * * * *", schedules: ["heartbeat", "sync"] }, ], }); }); diff --git a/packages/eve/src/internal/nitro/host/external-cron.ts b/packages/eve/src/internal/nitro/host/external-cron.ts index f42dbbdfd..67e253f86 100644 --- a/packages/eve/src/internal/nitro/host/external-cron.ts +++ b/packages/eve/src/internal/nitro/host/external-cron.ts @@ -29,14 +29,18 @@ export const EVE_CRON_MANIFEST_OUTPUT_PATH = join("eve", "cron-manifest.json"); /** * Contract of the emitted cron manifest — the self-hosted equivalent of - * the `config.crons[]` Vercel reads from its build output. + * the `config.crons[]` Vercel reads from its build output. `crons` holds + * one entry per distinct cron expression, mirroring how the Vercel preset + * keys its config on `scheduledTasks`: the dispatch route fires every + * schedule registered for the posted expression, so a scheduler that + * created one job per schedule would double-dispatch shared expressions. */ export interface EveCronManifest { readonly version: 1; readonly cronHandlerRoute: string; readonly crons: ReadonlyArray<{ - readonly name: string; readonly cron: string; + readonly schedules: readonly string[]; }>; } @@ -101,13 +105,16 @@ export function applyExternalCronHandlerRoute( `export default async (event) => handleExternalCronRequest(config, event.req);`, ].join("\n"); + const schedulesByExpression = new Map(); + for (const registration of input.registrations) { + const schedules = schedulesByExpression.get(registration.cron) ?? []; + schedules.push(registration.scheduleId); + schedulesByExpression.set(registration.cron, schedules); + } const manifest: EveCronManifest = { version: 1, cronHandlerRoute: route, - crons: input.registrations.map((registration) => ({ - name: registration.scheduleId, - cron: registration.cron, - })), + crons: [...schedulesByExpression].map(([cron, schedules]) => ({ cron, schedules })), }; nitro.hooks.hook("compiled", async () => { await writeEveCronManifest(nitro.options.output.dir, manifest);