diff --git a/docs/1.guide/10.tasks.md b/docs/1.guide/10.tasks.md index 31b4518689..5da3eaf7e2 100644 --- a/docs/1.guide/10.tasks.md +++ b/docs/1.guide/10.tasks.md @@ -89,7 +89,8 @@ export default defineNuxtConfig({ ### Platform support - `dev`, `node-server`, `bun` and `deno-server` presets are supported with [croner](https://croner.56k.guru/) engine. -- `cloudflare_module` preset have native integration with [Cron Triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/). Make sure to configure wrangler to use exactly same patterns you define in `scheduledTasks` to be matched. +- `cloudflare_module` preset has native integration with [Cron Triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/). Make sure to configure wrangler to use the same patterns you define in `scheduledTasks` to be matched. +- `vercel` preset has native integration with [Vercel Cron Jobs](https://vercel.com/docs/cron-jobs). Nitro automatically generates the cron job configuration at build time — no manual `vercel.json` setup required. - More presets (with native primitives support) are planned to be supported! ## Programmatically run tasks diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index 5c6da90108..682c880291 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -69,6 +69,32 @@ Alternatively, Nitro also detects Bun automatically if you specify a `bunVersion } ``` +## Scheduled tasks (Cron Jobs) + +:read-more{title="Vercel Cron Jobs" to="https://vercel.com/docs/cron-jobs"} + +Nitro automatically converts your [`scheduledTasks`](/guide/tasks#scheduled-tasks) configuration into [Vercel Cron Jobs](https://vercel.com/docs/cron-jobs) at build time. Define your schedules in your Nitro config and deploy - no manual `vercel.json` cron configuration required. + +```ts [nitro.config.ts] +export default defineNitroConfig({ + experimental: { + tasks: true + }, + scheduledTasks: { + // Run `cms:update` every hour + '0 * * * *': ['cms:update'], + // Run `db:cleanup` every day at midnight + '0 0 * * *': ['db:cleanup'] + } +}) +``` + +### Secure cron job endpoints + +:read-more{title="Securing cron jobs" to="https://vercel.com/docs/cron-jobs/manage-cron-jobs#securing-cron-jobs"} + +A `CRON_SECRET` environment variable is **required** to protect the cron handler. Set it in your Vercel project settings. + ## Custom build output configuration You can provide additional [build output configuration](https://vercel.com/docs/build-output-api/v3) using `vercel.config` key inside `nitro.config`. It will be merged with built-in auto-generated config. diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index 76e5e0825f..ce093a6c69 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -1,5 +1,8 @@ +import { fileURLToPath } from "node:url"; import { defineNitroPreset } from "nitropack/kit"; import type { Nitro } from "nitropack/types"; +import { normalize } from "pathe"; +import { withLeadingSlash } from "ufo"; import { deprecateSWR, generateEdgeFunctionFiles, @@ -18,6 +21,7 @@ const vercel = defineNitroPreset( entry: "./runtime/vercel", vercel: { skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED, + cronHandlerRoute: "/_vercel/cron", }, output: { dir: "{{ rootDir }}/.vercel/output", @@ -31,6 +35,22 @@ const vercel = defineNitroPreset( hooks: { "rollup:before": (nitro: Nitro) => { deprecateSWR(nitro); + + // Cron tasks handler + if ( + nitro.options.experimental.tasks && + Object.keys(nitro.options.scheduledTasks || {}).length > 0 + ) { + nitro.options.handlers.push({ + route: withLeadingSlash( + nitro.options.vercel?.cronHandlerRoute || "/_vercel/cron" + ), + lazy: true, + handler: normalize( + fileURLToPath(new URL("runtime/cron-handler", import.meta.url)) + ), + }); + } }, async compiled(nitro: Nitro) { await generateFunctionFiles(nitro); diff --git a/src/presets/vercel/runtime/cron-handler.ts b/src/presets/vercel/runtime/cron-handler.ts new file mode 100644 index 0000000000..ece5eb65fd --- /dev/null +++ b/src/presets/vercel/runtime/cron-handler.ts @@ -0,0 +1,38 @@ +import { timingSafeEqual } from "node:crypto"; +import { createError, eventHandler, getHeader } from "h3"; +import { runCronTasks } from "nitropack/runtime/internal"; + +export default eventHandler(async (event) => { + // Require and validate CRON_SECRET - https://vercel.com/docs/cron-jobs/manage-cron-jobs#securing-cron-jobs + const cronSecret = process.env.CRON_SECRET; + if (!cronSecret) { + throw createError({ + statusCode: 500, + statusMessage: "CRON_SECRET environment variable is not set", + }); + } + const authHeader = getHeader(event, "authorization") || ""; + const expected = `Bearer ${cronSecret}`; + const a = Buffer.from(authHeader); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); + } + + const cron = getHeader(event, "x-vercel-cron-schedule"); + if (!cron) { + throw createError({ + statusCode: 400, + statusMessage: "Missing x-vercel-cron-schedule header", + }); + } + + await runCronTasks(cron, { + context: {}, + payload: { + scheduledTime: Date.now(), + }, + }); + + return { success: true }; +}); diff --git a/src/presets/vercel/types.ts b/src/presets/vercel/types.ts index 817f0c52e0..f1f8619468 100644 --- a/src/presets/vercel/types.ts +++ b/src/presets/vercel/types.ts @@ -127,6 +127,18 @@ export interface VercelOptions { regions?: string[]; functions?: VercelServerlessFunctionConfig; + + /** + * The route path for the Vercel cron handler endpoint. + * + * When `experimental.tasks` and `scheduledTasks` are configured, + * Nitro registers a cron handler at this path that Vercel invokes + * on each scheduled cron trigger. + * + * @default "/_vercel/cron" + * @see https://vercel.com/docs/cron-jobs + */ + cronHandlerRoute?: string; } /** diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index e382f6fbb8..328a864fe1 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -223,6 +223,26 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { ], }); + // Cron jobs from scheduledTasks + // Only the node `vercel` preset registers a cron handler (see preset.ts). + // `vercel-edge` cannot run the Node-based handler and `vercel-static` has no + // server function, so we skip emitting cron config for them. + if ( + nitro.options.preset === "vercel" && + nitro.options.experimental.tasks && + Object.keys(nitro.options.scheduledTasks || {}).length > 0 + ) { + const cronPath = withLeadingSlash( + nitro.options.vercel?.cronHandlerRoute || "/_vercel/cron" + ); + const existing = config.crons || []; + const seen = new Set(existing.map((c) => `${c.path}|${c.schedule}`)); + const cronEntries = Object.keys(nitro.options.scheduledTasks) + .map((schedule) => ({ path: cronPath, schedule })) + .filter((c) => !seen.has(`${c.path}|${c.schedule}`)); + config.crons = [...cronEntries, ...existing]; + } + // Early return if we are building a static site if (nitro.options.static) { return config; diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index 0f1ba1d0c8..c82c3aaaa7 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -17,13 +17,19 @@ describe("nitro:preset:vercel", async () => { return res; }; }, - () => { + (_ctx, callHandler) => { it("should add route rules to config", async () => { const config = await fsp .readFile(resolve(ctx.outDir, "config.json"), "utf8") .then((r) => JSON.parse(r)); expect(config).toMatchInlineSnapshot(` { + "crons": [ + { + "path": "/_vercel/cron", + "schedule": "* * * * *", + }, + ], "overrides": { "_scalar/index.html": { "path": "_scalar", @@ -352,6 +358,10 @@ describe("nitro:preset:vercel", async () => { "dest": "/500", "src": "/500", }, + { + "dest": "/_vercel/cron", + "src": "/_vercel/cron", + }, { "dest": "/assets/[id]", "src": "/assets/(?[^/]+)", @@ -476,6 +486,7 @@ describe("nitro:preset:vercel", async () => { "functions/__fallback.func/node_modules", "functions/__fallback.func/package.json", "functions/__fallback.func/timing.js", + "functions/_vercel/cron.func (symlink)", "functions/api/cached.func (symlink)", "functions/api/db.func (symlink)", "functions/api/echo.func (symlink)", @@ -555,6 +566,74 @@ describe("nitro:preset:vercel", async () => { ] `); }); + + describe("cron handler", () => { + const cronUrl = "/_vercel/cron"; + const schedule = "* * * * *"; // matches fixture scheduledTasks + const originalSecret = process.env.CRON_SECRET; + + afterAll(() => { + if (originalSecret === undefined) { + delete process.env.CRON_SECRET; + } else { + process.env.CRON_SECRET = originalSecret; + } + }); + + it("returns 500 when CRON_SECRET is not set", async () => { + delete process.env.CRON_SECRET; + const res = await callHandler({ + url: cronUrl, + ignoreResponseError: true, + headers: { "x-vercel-cron-schedule": schedule }, + }); + expect(res.status).toBe(500); + }); + + it("returns 401 with missing or wrong Authorization", async () => { + process.env.CRON_SECRET = "test-secret"; + const noAuth = await callHandler({ + url: cronUrl, + ignoreResponseError: true, + headers: { "x-vercel-cron-schedule": schedule }, + }); + expect(noAuth.status).toBe(401); + + const wrongAuth = await callHandler({ + url: cronUrl, + ignoreResponseError: true, + headers: { + authorization: "Bearer wrong-secret", + "x-vercel-cron-schedule": schedule, + }, + }); + expect(wrongAuth.status).toBe(401); + }); + + it("returns 400 when the cron schedule header is missing", async () => { + process.env.CRON_SECRET = "test-secret"; + const res = await callHandler({ + url: cronUrl, + ignoreResponseError: true, + headers: { authorization: "Bearer test-secret" }, + }); + expect(res.status).toBe(400); + }); + + it("runs tasks with valid CRON_SECRET and schedule header", async () => { + process.env.CRON_SECRET = "test-secret"; + const res = await callHandler({ + url: cronUrl, + ignoreResponseError: true, + headers: { + authorization: "Bearer test-secret", + "x-vercel-cron-schedule": schedule, + }, + }); + expect(res.status).toBe(200); + expect(res.data).toMatchObject({ success: true }); + }); + }); } ); });