From 8308dfc6314c34b73cd84bcf48b65efba92a5400 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Wed, 3 Jun 2026 15:57:30 +0100 Subject: [PATCH 1/7] preset(vercel): support vercel cron --- docs/1.guide/10.tasks.md | 3 +- docs/2.deploy/20.providers/vercel.md | 26 +++++++++++++++++ src/presets/vercel/preset.ts | 16 ++++++++++ src/presets/vercel/runtime/cron-handler.ts | 34 ++++++++++++++++++++++ src/presets/vercel/types.ts | 12 ++++++++ src/presets/vercel/utils.ts | 19 ++++++++++++ test/presets/vercel.test.ts | 11 +++++++ 7 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 src/presets/vercel/runtime/cron-handler.ts 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..1c5cb084d7 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"} + +To prevent unauthorized access to the cron handler, set a `CRON_SECRET` environment variable in your Vercel project settings. When `CRON_SECRET` is set, Nitro validates the `Authorization` header on every cron invocation. + ## 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..eed92dd0e4 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import { defineNitroPreset } from "nitropack/kit"; import type { Nitro } from "nitropack/types"; import { @@ -18,6 +19,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 +33,20 @@ 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: nitro.options.vercel?.cronHandlerRoute || "/_vercel/cron", + lazy: true, + handler: 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..34efd893b8 --- /dev/null +++ b/src/presets/vercel/runtime/cron-handler.ts @@ -0,0 +1,34 @@ +import { timingSafeEqual } from "node:crypto"; +import { createError, eventHandler, getHeader } from "h3"; +import { runCronTasks } from "nitropack/runtime/internal"; + +export default eventHandler(async (event) => { + // Validate CRON_SECRET if set - https://vercel.com/docs/cron-jobs/manage-cron-jobs#securing-cron-jobs + const cronSecret = process.env.CRON_SECRET; + if (cronSecret) { + 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..891f9b33ba 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -223,6 +223,25 @@ 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 = nitro.options.vercel?.cronHandlerRoute || "/_vercel/cron"; + const cronEntries = Object.keys(nitro.options.scheduledTasks).map( + (schedule) => ({ + path: cronPath, + schedule, + }) + ); + config.crons = [...cronEntries, ...(config.crons || [])]; + } + // 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 4d3a8c5416..97ec8b3781 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -24,6 +24,12 @@ describe("nitro:preset:vercel", async () => { .then((r) => JSON.parse(r)); expect(config).toMatchInlineSnapshot(` { + "crons": [ + { + "path": "/_vercel/cron", + "schedule": "* * * * *", + }, + ], "overrides": { "_scalar/index.html": { "path": "_scalar", @@ -345,6 +351,10 @@ describe("nitro:preset:vercel", async () => { "dest": "/500", "src": "/500", }, + { + "dest": "/_vercel/cron", + "src": "/_vercel/cron", + }, { "dest": "/assets/[id]", "src": "/assets/(?[^/]+)", @@ -469,6 +479,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)", From 3d369433af5c44eeb203a1b94046b4438f61143f Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Tue, 9 Jun 2026 14:28:16 +0100 Subject: [PATCH 2/7] feat: require CRON_SECRET --- src/presets/vercel/runtime/cron-handler.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/presets/vercel/runtime/cron-handler.ts b/src/presets/vercel/runtime/cron-handler.ts index 34efd893b8..ece5eb65fd 100644 --- a/src/presets/vercel/runtime/cron-handler.ts +++ b/src/presets/vercel/runtime/cron-handler.ts @@ -3,16 +3,20 @@ import { createError, eventHandler, getHeader } from "h3"; import { runCronTasks } from "nitropack/runtime/internal"; export default eventHandler(async (event) => { - // Validate CRON_SECRET if set - https://vercel.com/docs/cron-jobs/manage-cron-jobs#securing-cron-jobs + // 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) { - 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" }); - } + 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"); From e46058659cfdfa02545114b5e344ffc2f0f8ea14 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Tue, 9 Jun 2026 14:29:03 +0100 Subject: [PATCH 3/7] fix: prevent duplicate cron config and invalid cron handler path --- src/presets/vercel/utils.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts index 891f9b33ba..328a864fe1 100644 --- a/src/presets/vercel/utils.ts +++ b/src/presets/vercel/utils.ts @@ -232,14 +232,15 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) { nitro.options.experimental.tasks && Object.keys(nitro.options.scheduledTasks || {}).length > 0 ) { - const cronPath = nitro.options.vercel?.cronHandlerRoute || "/_vercel/cron"; - const cronEntries = Object.keys(nitro.options.scheduledTasks).map( - (schedule) => ({ - path: cronPath, - schedule, - }) + const cronPath = withLeadingSlash( + nitro.options.vercel?.cronHandlerRoute || "/_vercel/cron" ); - config.crons = [...cronEntries, ...(config.crons || [])]; + 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 From 3799330015aec1baa65cab60ddfa9603b5cdd270 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Thu, 11 Jun 2026 13:33:07 +0100 Subject: [PATCH 4/7] docs: require cron secret --- docs/2.deploy/20.providers/vercel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/2.deploy/20.providers/vercel.md b/docs/2.deploy/20.providers/vercel.md index 1c5cb084d7..682c880291 100644 --- a/docs/2.deploy/20.providers/vercel.md +++ b/docs/2.deploy/20.providers/vercel.md @@ -93,7 +93,7 @@ export default defineNitroConfig({ :read-more{title="Securing cron jobs" to="https://vercel.com/docs/cron-jobs/manage-cron-jobs#securing-cron-jobs"} -To prevent unauthorized access to the cron handler, set a `CRON_SECRET` environment variable in your Vercel project settings. When `CRON_SECRET` is set, Nitro validates the `Authorization` header on every cron invocation. +A `CRON_SECRET` environment variable is **required** to protect the cron handler. Set it in your Vercel project settings. ## Custom build output configuration From da4584dda18e2696118500807dcd681197d09aba Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Thu, 11 Jun 2026 13:33:58 +0100 Subject: [PATCH 5/7] fix: prevent invalid cron path handler --- src/presets/vercel/preset.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index eed92dd0e4..014bfff33e 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from "node:url"; import { defineNitroPreset } from "nitropack/kit"; import type { Nitro } from "nitropack/types"; +import { withLeadingSlash } from "ufo"; import { deprecateSWR, generateEdgeFunctionFiles, @@ -40,7 +41,9 @@ const vercel = defineNitroPreset( Object.keys(nitro.options.scheduledTasks || {}).length > 0 ) { nitro.options.handlers.push({ - route: nitro.options.vercel?.cronHandlerRoute || "/_vercel/cron", + route: withLeadingSlash( + nitro.options.vercel?.cronHandlerRoute || "/_vercel/cron" + ), lazy: true, handler: fileURLToPath( new URL("runtime/cron-handler", import.meta.url) From 7e97258c42e6aace6d947c686b71a26836c6e00e Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Thu, 11 Jun 2026 13:41:21 +0100 Subject: [PATCH 6/7] test: ensure cron secret is validated --- test/presets/vercel.test.ts | 70 ++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index 97ec8b3781..a0c9b4b6f0 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -17,7 +17,7 @@ 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") @@ -559,6 +559,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 }); + }); + }); } ); }); From 955444aa5f744f31537f8fc34a5f8ac9c55e0e08 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Thu, 11 Jun 2026 14:04:11 +0100 Subject: [PATCH 7/7] fix: normalize path for windows --- src/presets/vercel/preset.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/presets/vercel/preset.ts b/src/presets/vercel/preset.ts index 014bfff33e..ce093a6c69 100644 --- a/src/presets/vercel/preset.ts +++ b/src/presets/vercel/preset.ts @@ -1,6 +1,7 @@ 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, @@ -45,8 +46,8 @@ const vercel = defineNitroPreset( nitro.options.vercel?.cronHandlerRoute || "/_vercel/cron" ), lazy: true, - handler: fileURLToPath( - new URL("runtime/cron-handler", import.meta.url) + handler: normalize( + fileURLToPath(new URL("runtime/cron-handler", import.meta.url)) ), }); }