From eb362f65b3e52cc72c5aa683295e9c0b83a50f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20N=C3=A9meth?= Date: Thu, 11 Jun 2026 22:51:45 +0200 Subject: [PATCH 1/6] presets(cloudflare): support durable objects, workflows and bindings in local dev --- src/build/vite/env.ts | 13 +- src/presets/cloudflare/dev.ts | 181 +++++++++++++++++++++++ src/presets/cloudflare/entry-exports.ts | 2 +- src/presets/cloudflare/utils.ts | 2 +- src/runtime/internal/vite/dev-worker.mjs | 26 +++- 5 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 src/presets/cloudflare/dev.ts diff --git a/src/build/vite/env.ts b/src/build/vite/env.ts index feeb36aea4..a2e938e2f3 100644 --- a/src/build/vite/env.ts +++ b/src/build/vite/env.ts @@ -1,4 +1,5 @@ -import type { EnvironmentOptions, RollupCommonJSOptions, Plugin as VitePlugin } from "vite"; +import type { EnvironmentOptions, RollupCommonJSOptions } from "vite"; +import type { CloudflareDevWorker } from "../../presets/cloudflare/dev.ts"; import type { NitroPluginContext, ServiceConfig } from "./types.ts"; import type { RunnerName } from "env-runner"; @@ -199,6 +200,11 @@ async function _loadRunner(ctx: NitroPluginContext, manager: RunnerManager) { if (runnerName === "miniflare") { const { MiniflareEnvRunner } = await import("env-runner/runners/miniflare"); const { miniflare, wranglerModule } = await resolveMiniflareDeps(ctx.nitro!); + let devWorker: CloudflareDevWorker | undefined; + if (ctx.nitro!.options.preset === "cloudflare-dev") { + const { composeCloudflareDevWorker } = await import("../../presets/cloudflare/dev.ts"); + devWorker = await composeCloudflareDevWorker(ctx.nitro!, entry); + } runner = new MiniflareEnvRunner({ name: "nitro-vite", miniflare, @@ -207,7 +213,10 @@ async function _loadRunner(ctx: NitroPluginContext, manager: RunnerManager) { ...ctx.nitro!.options.cloudflare?.wrangler, }, wranglerEnv: ctx.nitro!.options.cloudflare?.wranglerEnv, - data: { entry }, + exports: devWorker?.exports, + // disable env-runner's auto wiring in dev + miniflareOptions: devWorker ? { durableObjects: {} } : undefined, + data: { entry: devWorker?.entry || entry }, }); } else { runner = await loadRunner(runnerName, { diff --git a/src/presets/cloudflare/dev.ts b/src/presets/cloudflare/dev.ts new file mode 100644 index 0000000000..851b539008 --- /dev/null +++ b/src/presets/cloudflare/dev.ts @@ -0,0 +1,181 @@ +import type { Nitro } from "nitro/types"; +import type { WranglerConfig } from "./types.ts"; +import { join } from "pathe"; +import { writeFile, prettyPath } from "../_utils/fs.ts"; +import { resolveExportsEntry } from "./entry-exports.ts"; +import { readWranglerConfig } from "./utils.ts"; + +export interface CloudflareDevWorker { + entry: string; + exports: Record; +} + +/** + * Composes a dev worker entry that statically re-exports the app's Cloudflare + * classes referenced by wrangler bindings (Durable Objects, Workflows) next to + * the dev worker handlers — the dev counterpart of `setupEntryExports`. + */ +export async function composeCloudflareDevWorker( + nitro: Nitro, + devWorkerEntry: string +): Promise { + const boundClasses = await getBoundClasses(nitro); + const boundClassNames = Object.keys(boundClasses); + if (boundClassNames.length === 0) { + return; + } + + const exportsEntry = resolveExportsEntry(nitro); + if (!exportsEntry) { + nitro.logger.warn( + `[cloudflare] Wrangler config references the \`${boundClassNames.join("`, `")}\` class(es) but no Cloudflare exports entry was found (\`cloudflare.exports\` or \`exports.cloudflare.ts\`). They will not be available in dev.` + ); + return; + } + + const outDir = join(nitro.options.buildDir, "cloudflare-dev"); + + let availableExports: string[]; + try { + availableExports = await bundleExportsEntry(exportsEntry, join(outDir, "exports.mjs")); + } catch (error) { + nitro.logger.error( + `[cloudflare] Failed to bundle \`${prettyPath(exportsEntry)}\`. Its exports will not be available in dev.`, + error + ); + return; + } + + const missing = boundClassNames.filter((name) => !availableExports.includes(name)); + if (missing.length > 0) { + nitro.logger.warn( + `[cloudflare] Wrangler config references the \`${missing.join("`, `")}\` class(es) but they are not exported from \`${prettyPath(exportsEntry)}\`.` + ); + } + + const names = boundClassNames.filter((name) => availableExports.includes(name)); + if (names.length === 0) { + return; + } + + const entry = join(outDir, "worker.mjs"); + await writeFile(entry, composeWorkerSource(devWorkerEntry)); + + return { + entry, + exports: Object.fromEntries(names.map((name) => [name, boundClasses[name]])), + }; +} + +async function getBoundClasses( + nitro: Nitro +): Promise> { + const { config: userConfig } = await readWranglerConfig(nitro); + const wranglerEnv = nitro.options.cloudflare?.wranglerEnv || process.env.CLOUDFLARE_ENV; + const doClasses = new Map(); + const workflowClasses = new Map(); + for (const config of [userConfig, nitro.options.cloudflare?.wrangler]) { + const resolved = applyWranglerEnv(config, wranglerEnv); + for (const binding of resolved?.durable_objects?.bindings || []) { + if (!binding?.name || !binding.class_name) { + continue; + } + if (binding.script_name) { + doClasses.delete(binding.name); + } else { + doClasses.set(binding.name, binding.class_name); + } + } + for (const workflow of resolved?.workflows || []) { + if (!workflow?.binding || !workflow.class_name) { + continue; + } + if (workflow.script_name) { + workflowClasses.delete(workflow.binding); + } else { + workflowClasses.set(workflow.binding, workflow.class_name); + } + } + } + return { + ...Object.fromEntries([...workflowClasses.values()].map((name) => [name, { type: "class" }])), + ...Object.fromEntries([...doClasses.values()].map((name) => [name, { type: "DurableObject" }])), + } as Record; +} + +function applyWranglerEnv( + config: WranglerConfig | undefined, + env?: string +): WranglerConfig | undefined { + return env && config?.env?.[env] ? { ...config, ...config.env[env] } : config; +} + +async function bundleExportsEntry(entry: string, outFile: string): Promise { + const { rolldown } = await import("rolldown"); + const bundle = await rolldown({ + input: entry, + platform: "neutral", + external: /^(cloudflare|workerd|node):/, + resolve: { + conditionNames: ["workerd", "worker", "import", "module", "default"], + }, + logLevel: "silent", + }); + try { + const result = await bundle.write({ + file: outFile, + format: "esm", + codeSplitting: false, + }); + const chunk = result.output.find((output) => output.type === "chunk" && output.isEntry); + return chunk?.type === "chunk" ? chunk.exports : []; + } finally { + await bundle.close(); + } +} + +function composeWorkerSource(devWorkerEntry: string) { + return /* js */ `// Generated by nitro +export * from "./exports.mjs"; + +const devWorkerId = ${JSON.stringify(devWorkerEntry)}; +let devWorker, devWorkerPromise; + +// The dev worker must be imported dynamically through the unsafe eval binding: +// a plain import() would be statically resolved by miniflare's module crawler, +// which cannot handle its bare imports (at runtime they are resolved by the +// env-runner module fallback service instead). +function loadDevWorker(env) { + if (env && !globalThis.__ENV_RUNNER_UNSAFE_EVAL__) { + globalThis.__ENV_RUNNER_UNSAFE_EVAL__ = env.__ENV_RUNNER_UNSAFE_EVAL__; + } + return (devWorkerPromise ??= globalThis.__ENV_RUNNER_UNSAFE_EVAL__ + .newAsyncFunction("return import(id)", "loadDevWorker", "id")(devWorkerId) + .then((mod) => (devWorker = mod))); +} + +export async function fetch(request, env, ctx) { + return (await loadDevWorker(env)).fetch(request, env, ctx); +} + +export async function upgrade(context) { + return (await loadDevWorker()).upgrade(context); +} + +export const ipc = { + async onOpen(ctx) { + return (await loadDevWorker()).ipc?.onOpen?.(ctx); + }, + onMessage(message) { + if (devWorker) { + devWorker.ipc?.onMessage?.(message); + } else { + loadDevWorker().then((mod) => mod.ipc?.onMessage?.(message)); + } + }, + async onClose() { + return devWorker?.ipc?.onClose?.(); + }, +}; +`; +} diff --git a/src/presets/cloudflare/entry-exports.ts b/src/presets/cloudflare/entry-exports.ts index e67e4b0d34..849176fe48 100644 --- a/src/presets/cloudflare/entry-exports.ts +++ b/src/presets/cloudflare/entry-exports.ts @@ -18,7 +18,7 @@ export async function setupEntryExports(nitro: Nitro) { `; } -function resolveExportsEntry(nitro: Nitro) { +export function resolveExportsEntry(nitro: Nitro) { const entry = resolveModulePath(nitro.options.cloudflare?.exports || "./exports.cloudflare.ts", { from: nitro.options.rootDir, extensions: RESOLVE_EXTENSIONS, diff --git a/src/presets/cloudflare/utils.ts b/src/presets/cloudflare/utils.ts index d680eaa053..1cc0049057 100644 --- a/src/presets/cloudflare/utils.ts +++ b/src/presets/cloudflare/utils.ts @@ -187,7 +187,7 @@ const extensionParsers = { ".toml": parseTOML, } as const; -async function readWranglerConfig( +export async function readWranglerConfig( nitro: Nitro ): Promise<{ configPath?: string; config?: WranglerConfig }> { const configPath = await findNearestFile(["wrangler.json", "wrangler.jsonc", "wrangler.toml"], { diff --git a/src/runtime/internal/vite/dev-worker.mjs b/src/runtime/internal/vite/dev-worker.mjs index 776b8a29a5..fcda56d7d0 100644 --- a/src/runtime/internal/vite/dev-worker.mjs +++ b/src/runtime/internal/vite/dev-worker.mjs @@ -263,14 +263,18 @@ globalThis.__transform_html__ = async function (html) { // ----- Exports (env-runner AppEntry) ----- -export async function fetch(req) { - const viteEnv = req?.headers.get("x-vite-env") || "nitro"; - const env = envs[viteEnv]; - if (!env) { - return renderError(req, httpError(500, `Unknown vite environment "${viteEnv}"`)); +export async function fetch(req, env, ctx) { + if (env) { + // only workerd-based runners (miniflare) invoke the entry as `fetch(request, env, ctx)` + augmentReq(req, env, ctx); + } + const viteEnvName = req?.headers.get("x-vite-env") || "nitro"; + const viteEnv = envs[viteEnvName]; + if (!viteEnv) { + return renderError(req, httpError(500, `Unknown vite environment "${viteEnvName}"`)); } try { - return await env.fetch(req); + return await viteEnv.fetch(req); } catch (error) { return renderError(req, error); } @@ -329,6 +333,16 @@ export const ipc = { }, }; +// ----- Worker bindings ----- + +function augmentReq(req, env, context) { + globalThis.__env__ = env; + req.ip = req.headers.get("cf-connecting-ip") || undefined; + req.runtime ??= { name: "cloudflare" }; + req.runtime.cloudflare = { ...req.runtime.cloudflare, env, context }; + req.waitUntil = context?.waitUntil?.bind(context); +} + // ----- Error handling ----- function httpError(status, message) { From c6e89213a97e33968a8c1e0a2692d57d18821388 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20N=C3=A9meth?= Date: Thu, 11 Jun 2026 22:52:41 +0200 Subject: [PATCH 2/6] docs(cloudflare): add durable example --- examples/cloudflare-durable/README.md | 50 +++++++++++++++++++ .../cloudflare-durable/exports.cloudflare.ts | 1 + examples/cloudflare-durable/index.html | 21 ++++++++ examples/cloudflare-durable/nitro.config.ts | 6 +++ examples/cloudflare-durable/package.json | 13 +++++ examples/cloudflare-durable/routes/counter.ts | 11 ++++ .../server/durable/counter.ts | 9 ++++ examples/cloudflare-durable/tsconfig.json | 6 +++ examples/cloudflare-durable/vite.config.ts | 4 ++ examples/cloudflare-durable/wrangler.jsonc | 9 ++++ pnpm-lock.yaml | 12 +++++ test/examples.test.ts | 7 ++- 12 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 examples/cloudflare-durable/README.md create mode 100644 examples/cloudflare-durable/exports.cloudflare.ts create mode 100644 examples/cloudflare-durable/index.html create mode 100644 examples/cloudflare-durable/nitro.config.ts create mode 100644 examples/cloudflare-durable/package.json create mode 100644 examples/cloudflare-durable/routes/counter.ts create mode 100644 examples/cloudflare-durable/server/durable/counter.ts create mode 100644 examples/cloudflare-durable/tsconfig.json create mode 100644 examples/cloudflare-durable/vite.config.ts create mode 100644 examples/cloudflare-durable/wrangler.jsonc diff --git a/examples/cloudflare-durable/README.md b/examples/cloudflare-durable/README.md new file mode 100644 index 0000000000..62842d03ad --- /dev/null +++ b/examples/cloudflare-durable/README.md @@ -0,0 +1,50 @@ +This example shows how to use [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/) with Nitro — in production and in local dev, where `vite dev` runs your app inside [workerd](https://github.com/cloudflare/workerd) via Miniflare. + +## Defining a Durable Object + +Durable Object classes are regular classes extending `DurableObject`: + +```ts [server/durable/counter.ts] +import { DurableObject } from "cloudflare:workers"; + +export class CounterDO extends DurableObject { + async increment(amount: number = 1): Promise { + const count = ((await this.ctx.storage.get("count")) ?? 0) + amount; + await this.ctx.storage.put("count", count); + return count; + } +} +``` + +They are exported to the worker entrypoint through `exports.cloudflare.ts`: + +```ts [exports.cloudflare.ts] +export { CounterDO } from "./server/durable/counter.ts"; +``` + +And bound in `wrangler.jsonc`: + +```jsonc [wrangler.jsonc] +{ + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "CounterDO" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["CounterDO"] }] +} +``` + +## Calling a Durable Object + +The namespace binding is available from the request event: + +```ts [routes/counter.ts] +import { defineHandler } from "nitro"; + +export default defineHandler(async (event) => { + const env = event.req.runtime?.cloudflare?.env; + const count = await env.COUNTER.getByName("global").increment(); + return { count }; +}); +``` + +Run `vite dev` and fetch `/counter` — the count increments in a real Durable Object running in workerd. Route handlers are hot-reloaded; after changing the Durable Object class itself, restart the dev server. diff --git a/examples/cloudflare-durable/exports.cloudflare.ts b/examples/cloudflare-durable/exports.cloudflare.ts new file mode 100644 index 0000000000..4823df63dc --- /dev/null +++ b/examples/cloudflare-durable/exports.cloudflare.ts @@ -0,0 +1 @@ +export { CounterDO } from "./server/durable/counter.ts"; diff --git a/examples/cloudflare-durable/index.html b/examples/cloudflare-durable/index.html new file mode 100644 index 0000000000..ca7d11e95b --- /dev/null +++ b/examples/cloudflare-durable/index.html @@ -0,0 +1,21 @@ + + + + + Nitro + Cloudflare Durable Objects + + +

Nitro + Cloudflare Durable Objects

+

Counter: …

+ + + + diff --git a/examples/cloudflare-durable/nitro.config.ts b/examples/cloudflare-durable/nitro.config.ts new file mode 100644 index 0000000000..87edb5e7b8 --- /dev/null +++ b/examples/cloudflare-durable/nitro.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "nitro"; + +export default defineConfig({ + preset: "cloudflare_module", + serverDir: "./", +}); diff --git a/examples/cloudflare-durable/package.json b/examples/cloudflare-durable/package.json new file mode 100644 index 0000000000..9a6008f081 --- /dev/null +++ b/examples/cloudflare-durable/package.json @@ -0,0 +1,13 @@ +{ + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "deploy": "vite build && wrangler deploy" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260601.0", + "nitro": "latest", + "wrangler": "^4.99.0" + } +} diff --git a/examples/cloudflare-durable/routes/counter.ts b/examples/cloudflare-durable/routes/counter.ts new file mode 100644 index 0000000000..e9983926fb --- /dev/null +++ b/examples/cloudflare-durable/routes/counter.ts @@ -0,0 +1,11 @@ +import type { CounterDO } from "../server/durable/counter.ts"; +import { defineHandler } from "nitro"; + +export default defineHandler(async (event) => { + const env = event.req.runtime?.cloudflare?.env as { + COUNTER: DurableObjectNamespace; + }; + const counter = env.COUNTER.getByName("global"); + const count = await counter.increment(); + return { count }; +}); diff --git a/examples/cloudflare-durable/server/durable/counter.ts b/examples/cloudflare-durable/server/durable/counter.ts new file mode 100644 index 0000000000..18cecf82fb --- /dev/null +++ b/examples/cloudflare-durable/server/durable/counter.ts @@ -0,0 +1,9 @@ +import { DurableObject } from "cloudflare:workers"; + +export class CounterDO extends DurableObject { + async increment(amount: number = 1): Promise { + const count = ((await this.ctx.storage.get("count")) ?? 0) + amount; + await this.ctx.storage.put("count", count); + return count; + } +} diff --git a/examples/cloudflare-durable/tsconfig.json b/examples/cloudflare-durable/tsconfig.json new file mode 100644 index 0000000000..08298a6abf --- /dev/null +++ b/examples/cloudflare-durable/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "nitro/tsconfig", + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + } +} diff --git a/examples/cloudflare-durable/vite.config.ts b/examples/cloudflare-durable/vite.config.ts new file mode 100644 index 0000000000..34d3353e1c --- /dev/null +++ b/examples/cloudflare-durable/vite.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from "vite"; +import { nitro } from "nitro/vite"; + +export default defineConfig({ plugins: [nitro()] }); diff --git a/examples/cloudflare-durable/wrangler.jsonc b/examples/cloudflare-durable/wrangler.jsonc new file mode 100644 index 0000000000..2ab3d26530 --- /dev/null +++ b/examples/cloudflare-durable/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "https://www.unpkg.com/wrangler/config-schema.json", + "name": "example-cloudflare-durable", + "compatibility_date": "2026-06-01", + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "CounterDO" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["CounterDO"] }], +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a2527a530..eeee8e55c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -316,6 +316,18 @@ importers: specifier: workspace:* version: link:../.. + examples/cloudflare-durable: + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260601.0 + version: 4.20260610.1 + nitro: + specifier: workspace:* + version: link:../.. + wrangler: + specifier: ^4.99.0 + version: 4.99.0(@cloudflare/workers-types@4.20260610.1) + examples/custom-error-handler: devDependencies: nitro: diff --git a/test/examples.test.ts b/test/examples.test.ts index d1e8cd2a3d..427482d5a6 100644 --- a/test/examples.test.ts +++ b/test/examples.test.ts @@ -33,7 +33,12 @@ const skip = new Set([ ]), ]); -const skipDev = new Set(["cached-handler"]); +const skipDev = new Set([ + "cached-handler", + // The index.html renderer template cannot be read from inside workerd + // (covered by test/vite/cloudflare-do.test.ts instead) + "cloudflare-durable", +]); const skipProd = new Set(isRolldown ? [] : []); From b14c6ac63813bbccd66e6d16afedb89293b476d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20N=C3=A9meth?= Date: Thu, 11 Jun 2026 22:53:00 +0200 Subject: [PATCH 3/6] test(cloudfllare): add do test --- .../cloudflare-do-fixture/durable/counter.ts | 9 +++ .../durable/entrypoint.ts | 15 +++++ .../exports.cloudflare.ts | 2 + .../cloudflare-do-fixture/routes/counter.ts | 13 ++++ .../vite/cloudflare-do-fixture/vite.config.ts | 21 +++++++ test/vite/cloudflare-do.test.ts | 62 +++++++++++++++++++ 6 files changed, 122 insertions(+) create mode 100644 test/vite/cloudflare-do-fixture/durable/counter.ts create mode 100644 test/vite/cloudflare-do-fixture/durable/entrypoint.ts create mode 100644 test/vite/cloudflare-do-fixture/exports.cloudflare.ts create mode 100644 test/vite/cloudflare-do-fixture/routes/counter.ts create mode 100644 test/vite/cloudflare-do-fixture/vite.config.ts create mode 100644 test/vite/cloudflare-do.test.ts diff --git a/test/vite/cloudflare-do-fixture/durable/counter.ts b/test/vite/cloudflare-do-fixture/durable/counter.ts new file mode 100644 index 0000000000..18cecf82fb --- /dev/null +++ b/test/vite/cloudflare-do-fixture/durable/counter.ts @@ -0,0 +1,9 @@ +import { DurableObject } from "cloudflare:workers"; + +export class CounterDO extends DurableObject { + async increment(amount: number = 1): Promise { + const count = ((await this.ctx.storage.get("count")) ?? 0) + amount; + await this.ctx.storage.put("count", count); + return count; + } +} diff --git a/test/vite/cloudflare-do-fixture/durable/entrypoint.ts b/test/vite/cloudflare-do-fixture/durable/entrypoint.ts new file mode 100644 index 0000000000..d11f18b11d --- /dev/null +++ b/test/vite/cloudflare-do-fixture/durable/entrypoint.ts @@ -0,0 +1,15 @@ +import { WorkerEntrypoint, WorkflowEntrypoint } from "cloudflare:workers"; + +export class EchoEntrypoint extends WorkerEntrypoint { + echo(value: string): string { + return value; + } +} + +export class EchoWorkflow extends WorkflowEntrypoint { + override async run(): Promise { + return "echo"; + } +} + +export const EXPORTS_VERSION = 1; diff --git a/test/vite/cloudflare-do-fixture/exports.cloudflare.ts b/test/vite/cloudflare-do-fixture/exports.cloudflare.ts new file mode 100644 index 0000000000..311b02842d --- /dev/null +++ b/test/vite/cloudflare-do-fixture/exports.cloudflare.ts @@ -0,0 +1,2 @@ +export { CounterDO } from "./durable/counter.ts"; +export * from "./durable/entrypoint.ts"; diff --git a/test/vite/cloudflare-do-fixture/routes/counter.ts b/test/vite/cloudflare-do-fixture/routes/counter.ts new file mode 100644 index 0000000000..40049cc765 --- /dev/null +++ b/test/vite/cloudflare-do-fixture/routes/counter.ts @@ -0,0 +1,13 @@ +import type { CounterDO } from "../durable/counter.ts"; + +export default async (req: Request & { runtime?: any }) => { + const env = req.runtime?.cloudflare?.env as { + COUNTER: DurableObjectNamespace; + }; + const count = await env.COUNTER.getByName("global").increment(); + return Response.json({ + count, + hasGlobalEnv: !!(globalThis as any).__env__, + hasWorkflowBinding: !!(env as any).ECHO_WORKFLOW, + }); +}; diff --git a/test/vite/cloudflare-do-fixture/vite.config.ts b/test/vite/cloudflare-do-fixture/vite.config.ts new file mode 100644 index 0000000000..9eeef5a4c4 --- /dev/null +++ b/test/vite/cloudflare-do-fixture/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import { nitro } from "nitro/vite"; + +export default defineConfig({ + plugins: [ + nitro({ + preset: "cloudflare-module", + serverDir: "./", + cloudflare: { + wrangler: { + durable_objects: { + bindings: [{ name: "COUNTER", class_name: "CounterDO" }], + }, + workflows: [ + { binding: "ECHO_WORKFLOW", name: "echo-workflow", class_name: "EchoWorkflow" }, + ], + }, + }, + }), + ], +}); diff --git a/test/vite/cloudflare-do.test.ts b/test/vite/cloudflare-do.test.ts new file mode 100644 index 0000000000..a135897a5c --- /dev/null +++ b/test/vite/cloudflare-do.test.ts @@ -0,0 +1,62 @@ +import { fileURLToPath } from "node:url"; +import type { ViteDevServer } from "vite"; +import { isWindows } from "std-env"; +import { beforeAll, afterAll, describe, expect, test } from "vitest"; + +const { createServer } = (await import( + process.env.NITRO_VITE_PKG || "vite" +)) as typeof import("vite"); + +// Durable Objects in `nitro dev` with the cloudflare preset (miniflare runner): +// DO classes from `exports.cloudflare.ts` are composed into the dev worker as +// static exports, and bindings (`env`) are forwarded to the hosted app. +describe.skipIf(isWindows)("vite:cloudflare-do", { sequential: true }, () => { + let server: ViteDevServer; + let serverURL: string; + + const rootDir = fileURLToPath(new URL("./cloudflare-do-fixture", import.meta.url)); + + const originalCwd = process.cwd(); + + beforeAll(async () => { + process.chdir(rootDir); + server = await createServer({ root: rootDir }); + await server.listen("0" as unknown as number); + const addr = server.httpServer?.address() as { + port: number; + address: string; + family: string; + }; + serverURL = `http://${addr.family === "IPv6" ? `[${addr.address}]` : addr.address}:${addr.port}`; + }, 30_000); + + afterAll(async () => { + await server?.close(); + process.chdir(originalCwd); + }); + + test("calls a Durable Object in dev (workerd)", async () => { + const res = await fetch(`${serverURL}/counter`); + expect(res.status).toBe(200); + const data = (await res.json()) as { count: number }; + expect(data.count).toBe(1); + }); + + test("durable object state persists across requests", async () => { + const res = await fetch(`${serverURL}/counter`); + const data = (await res.json()) as { count: number }; + expect(data.count).toBe(2); + }); + + test("bindings are exposed via globalThis.__env__", async () => { + const res = await fetch(`${serverURL}/counter`); + const data = (await res.json()) as { hasGlobalEnv: boolean }; + expect(data.hasGlobalEnv).toBe(true); + }); + + test("workflow class is exported and bound", async () => { + const res = await fetch(`${serverURL}/counter`); + const data = (await res.json()) as { hasWorkflowBinding: boolean }; + expect(data.hasWorkflowBinding).toBe(true); + }); +}); From fbf3b4f0de93039734707adb7b10a9962a4fd686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20N=C3=A9meth?= Date: Thu, 11 Jun 2026 22:53:38 +0200 Subject: [PATCH 4/6] docs(cloudflare): add do/workflows --- docs/2.deploy/20.providers/cloudflare.md | 49 +++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/2.deploy/20.providers/cloudflare.md b/docs/2.deploy/20.providers/cloudflare.md index 789512fc64..06383cc745 100644 --- a/docs/2.deploy/20.providers/cloudflare.md +++ b/docs/2.deploy/20.providers/cloudflare.md @@ -72,7 +72,7 @@ export class MyWorkflow extends WorkflowEntrypoint { } ``` -Nitro will automatically detect this file and include its exports in the final build. +Nitro will automatically detect this file and include its exports in the final build. In dev mode, classes referenced by wrangler bindings (Durable Objects, Workflows) are also exported from the local dev worker. ::warning The `exports.cloudflare.ts` file must not have a default export. @@ -90,6 +90,53 @@ export default defineConfig({ }) ``` +### Durable Objects + +Export your [Durable Object](https://developers.cloudflare.com/durable-objects/) classes from `exports.cloudflare.ts` and declare their bindings in your wrangler config: + +```ts [exports.cloudflare.ts] +export { CounterDO } from "./server/durable/counter.ts"; +``` + +```ts [server/durable/counter.ts] +import { DurableObject } from "cloudflare:workers"; + +export class CounterDO extends DurableObject { + async increment(amount: number = 1): Promise { + const count = ((await this.ctx.storage.get("count")) ?? 0) + amount; + await this.ctx.storage.put("count", count); + return count; + } +} +``` + +```jsonc [wrangler.jsonc] +{ + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "CounterDO" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["CounterDO"] }] +} +``` + +The namespace binding is then available from the request event, in production and in local dev (which runs your app in workerd via Miniflare): + +```ts [routes/counter.ts] +import { defineHandler } from "nitro"; + +export default defineHandler(async (event) => { + const env = event.req.runtime?.cloudflare?.env; + const count = await env.COUNTER.getByName("global").increment(); + return { count }; +}); +``` + +::note +In dev mode, Durable Object and Workflow classes are static exports of the worker while your handlers are hot-reloaded: after changing these classes, restart the dev server to apply them. +:: + +:read-more{title="Durable Objects example" to="https://github.com/nitrojs/nitro/tree/main/examples/cloudflare-durable"} + ### Scheduled Tasks (Cron Triggers) When using [Nitro tasks](/docs/tasks) with `scheduledTasks`, Nitro automatically generates [Cron Triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/) in the wrangler config at build time. From c61c9c152119c1ab5672801769df55b19a9350ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20N=C3=A9meth?= Date: Fri, 3 Jul 2026 19:40:22 +0200 Subject: [PATCH 5/6] docs(examples): handle missing COUNTER binding in Durable Object --- examples/cloudflare-durable/routes/counter.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/examples/cloudflare-durable/routes/counter.ts b/examples/cloudflare-durable/routes/counter.ts index e9983926fb..d58654e4a5 100644 --- a/examples/cloudflare-durable/routes/counter.ts +++ b/examples/cloudflare-durable/routes/counter.ts @@ -1,10 +1,13 @@ import type { CounterDO } from "../server/durable/counter.ts"; -import { defineHandler } from "nitro"; +import { defineHandler, HTTPError } from "nitro"; export default defineHandler(async (event) => { - const env = event.req.runtime?.cloudflare?.env as { - COUNTER: DurableObjectNamespace; - }; + const env = event.req.runtime?.cloudflare?.env as + | { COUNTER?: DurableObjectNamespace } + | undefined; + if (!env?.COUNTER) { + throw new HTTPError("Missing Cloudflare Durable Object binding: COUNTER", { status: 500 }); + } const counter = env.COUNTER.getByName("global"); const count = await counter.increment(); return { count }; From a546ce5a2b7f93ebfe864aa81ad915bb0a7230dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20N=C3=A9meth?= Date: Fri, 3 Jul 2026 20:28:12 +0200 Subject: [PATCH 6/6] chore: fix lockfile --- examples/cloudflare-durable/package.json | 2 +- pnpm-lock.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/cloudflare-durable/package.json b/examples/cloudflare-durable/package.json index 9a6008f081..76cfc7e15b 100644 --- a/examples/cloudflare-durable/package.json +++ b/examples/cloudflare-durable/package.json @@ -6,7 +6,7 @@ "deploy": "vite build && wrangler deploy" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260601.0", + "@cloudflare/workers-types": "^5.20260716.1", "nitro": "latest", "wrangler": "^4.99.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eeee8e55c8..84f6a4f52c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -319,14 +319,14 @@ importers: examples/cloudflare-durable: devDependencies: '@cloudflare/workers-types': - specifier: ^4.20260601.0 - version: 4.20260610.1 + specifier: ^5.20260716.1 + version: 5.20260906.1 nitro: specifier: workspace:* version: link:../.. wrangler: specifier: ^4.99.0 - version: 4.99.0(@cloudflare/workers-types@4.20260610.1) + version: 4.129.0(@cloudflare/workers-types@5.20260906.1) examples/custom-error-handler: devDependencies: