diff --git a/docs/2.deploy/20.providers/cloudflare.md b/docs/2.deploy/20.providers/cloudflare.md index 789512fc64..cd5e2da69b 100644 --- a/docs/2.deploy/20.providers/cloudflare.md +++ b/docs/2.deploy/20.providers/cloudflare.md @@ -117,7 +117,7 @@ No manual Wrangler configuration is needed. Nitro handles it for you. :read-more{title="Durable Objects" to="https://developers.cloudflare.com/durable-objects/"} -This preset extends `cloudflare_module` and routes requests through a [Durable Object](https://developers.cloudflare.com/durable-objects/) instance, enabling stateful features such as WebSocket support (via [CrossWS](https://crossws.h3.dev/adapters/cloudflare#durable-objects)) and in-memory state that persists across requests. +This preset extends `cloudflare_module` and routes WebSocket upgrades through a [Durable Object](https://developers.cloudflare.com/durable-objects/) instance using [CrossWS](https://crossws.h3.dev/adapters/cloudflare#durable-objects). ```ts [nitro.config.ts] import { defineConfig } from "nitro"; @@ -127,7 +127,9 @@ export default defineConfig({ }) ``` -The preset entry exports a `$DurableObject` class. You need to declare the Durable Object binding and migration in your wrangler config: +The preset entry exports a `$DurableObject` class. With `cloudflare.deployConfig` enabled, Nitro generates its binding in the default and named Wrangler environments. When no migration history or Durable Object `exports` declaration exists, Nitro generates an initial SQLite migration. Existing lifecycle declarations are preserved and validated by Wrangler. If you manage the lifecycle yourself, include `$DurableObject` in your declarations before deploying. + +If you disable `cloudflare.deployConfig`, declare the binding and migration in your Wrangler config: ```json [wrangler.json] { @@ -142,12 +144,29 @@ The preset entry exports a `$DurableObject` class. You need to declare the Durab "migrations": [ { "tag": "v1", - "new_classes": ["$DurableObject"] + "new_sqlite_classes": ["$DurableObject"] } ] } ``` +### Binding name + +Set `cloudflare.durable.bindingName` to customize the binding name. It defaults to `$DurableObject`; the class name remains `$DurableObject`. + +```ts [nitro.config.ts] +import { defineConfig } from "nitro"; + +export default defineConfig({ + preset: "cloudflare_durable", + cloudflare: { + durable: { bindingName: "MyCustomDO" } + } +}); +``` + +Nitro generates the matching binding when `cloudflare.deployConfig` is enabled. For manually managed Wrangler configuration, use the same binding name in `durable_objects.bindings`. + You can use the `cloudflare:durable:init` runtime hook to run code when the Durable Object is initialized, and the `cloudflare:durable:alarm` hook to handle [alarms](https://developers.cloudflare.com/durable-objects/api/alarms/). ### Tracing @@ -223,7 +242,6 @@ Then you can deploy the application with: :pm-x{command="wrangler pages deploy"} - ## Deploy within CI/CD using GitHub Actions Regardless of whether you're using Cloudflare Pages or Cloudflare Workers, you can use the [Wrangler GitHub actions](https://github.com/marketplace/actions/deploy-to-cloudflare-workers-with-wrangler) to deploy your application. diff --git a/src/presets/cloudflare/durable.ts b/src/presets/cloudflare/durable.ts new file mode 100644 index 0000000000..ea17bc4f78 --- /dev/null +++ b/src/presets/cloudflare/durable.ts @@ -0,0 +1,43 @@ +import type { WranglerConfig } from "./types.ts"; +import type { Nitro } from "nitro/types"; + +const DEFAULT_DURABLE_BINDING_NAME = "$DurableObject"; + +export function setupDurable(nitro: Nitro) { + nitro.options.virtual["#nitro/virtual/cloudflare-durable"] = () => + `export const bindingName = ${JSON.stringify(nitro.options.cloudflare?.durable?.bindingName || DEFAULT_DURABLE_BINDING_NAME)};`; +} + +export function configureDurable(nitro: Nitro, config: WranglerConfig) { + const className = "$DurableObject"; + const bindingName = + nitro.options.cloudflare?.durable?.bindingName || DEFAULT_DURABLE_BINDING_NAME; + const environments = Object.values(config.env || {}); + for (const env of environments) { + env.migrations ??= config.migrations; + env.exports ??= config.exports; + } + + for (const scope of [config, ...environments]) { + scope.durable_objects ??= { bindings: [] }; + scope.durable_objects.bindings ??= []; + const binding = scope.durable_objects.bindings.find((binding) => binding.name === bindingName); + if (binding && (binding.class_name !== className || binding.script_name)) { + throw new Error( + `Durable Object binding "${bindingName}" must reference the local "${className}" class.` + ); + } + if (!binding) { + scope.durable_objects.bindings.push({ name: bindingName, class_name: className }); + } + + const hasDurableExports = Object.values(scope.exports || {}).some( + (entry) => entry.type === "durable-object" + ); + if (!scope.migrations?.length && !hasDurableExports) { + scope.migrations = [{ tag: "v1", new_sqlite_classes: [className] }]; + } else if (scope !== config && hasDurableExports && !scope.migrations) { + scope.migrations = []; + } + } +} diff --git a/src/presets/cloudflare/preset.ts b/src/presets/cloudflare/preset.ts index ce0a72fb7e..77ecbf789e 100644 --- a/src/presets/cloudflare/preset.ts +++ b/src/presets/cloudflare/preset.ts @@ -3,6 +3,7 @@ import { writeFile } from "../_utils/fs.ts"; import type { Nitro } from "nitro/types"; import { join, resolve } from "pathe"; import { presetsDir } from "nitro/meta"; +import { setupDurable } from "./durable.ts"; import { unenvCfExternals } from "./unenv/preset.ts"; import { enableNodeCompat, @@ -174,6 +175,12 @@ const cloudflareDurable = defineNitroPreset( { extends: "cloudflare-module", entry: "./cloudflare/runtime/cloudflare-durable", + hooks: { + "build:before": async (nitro) => { + await cloudflareModule.hooks["build:before"](nitro); + setupDurable(nitro); + }, + }, }, { name: "cloudflare-durable" as const, diff --git a/src/presets/cloudflare/runtime/cloudflare-durable.ts b/src/presets/cloudflare/runtime/cloudflare-durable.ts index a2d8b50f09..aa0723e29d 100644 --- a/src/presets/cloudflare/runtime/cloudflare-durable.ts +++ b/src/presets/cloudflare/runtime/cloudflare-durable.ts @@ -8,7 +8,7 @@ import { useNitroApp, useNitroHooks } from "nitro/app"; import { isPublicAssetURL } from "#nitro/virtual/public-assets"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; -const DURABLE_BINDING = "$DurableObject"; +import { bindingName as DURABLE_BINDING } from "#nitro/virtual/cloudflare-durable"; const DURABLE_INSTANCE = "server"; interface Env { diff --git a/src/presets/cloudflare/types.ts b/src/presets/cloudflare/types.ts index 45436a89e3..05340f17c2 100644 --- a/src/presets/cloudflare/types.ts +++ b/src/presets/cloudflare/types.ts @@ -56,6 +56,11 @@ export interface CloudflareOptions { */ nodeCompat?: boolean; + durable?: { + /** @default "$DurableObject" */ + bindingName?: string; + }; + pages?: { /** * Nitro will automatically generate a `_routes.json` that controls which files get served statically and diff --git a/src/presets/cloudflare/utils.ts b/src/presets/cloudflare/utils.ts index d680eaa053..7d4dd25866 100644 --- a/src/presets/cloudflare/utils.ts +++ b/src/presets/cloudflare/utils.ts @@ -18,6 +18,8 @@ import { } from "ufo"; import { unenvCfNodeCompat } from "./unenv/preset.ts"; +import { configureDurable } from "./durable.ts"; + export async function writeCFRoutes(nitro: Nitro) { const _cfPagesConfig = nitro.options.cloudflare?.pages || {}; const routes: CloudflarePagesRoutes = { @@ -302,6 +304,10 @@ export async function writeWranglerConfig(nitro: Nitro, cfTarget: "pages" | "mod globs: ["**/*.mjs", "**/*.js"], }); } + + if (nitro.options.virtual?.["#nitro/virtual/cloudflare-durable"]) { + configureDurable(nitro, wranglerConfig); + } } // Nitro Tasks cron triggers diff --git a/src/runtime/virtual/cloudflare-durable.ts b/src/runtime/virtual/cloudflare-durable.ts new file mode 100644 index 0000000000..b805fed802 --- /dev/null +++ b/src/runtime/virtual/cloudflare-durable.ts @@ -0,0 +1,3 @@ +import "./_runtime_warn.ts"; + +export const bindingName = "$DurableObject"; diff --git a/test/fixture/server/handlers/durable-websocket.ts b/test/fixture/server/handlers/durable-websocket.ts new file mode 100644 index 0000000000..5af842cced --- /dev/null +++ b/test/fixture/server/handlers/durable-websocket.ts @@ -0,0 +1,7 @@ +import { defineWebSocketHandler } from "nitro"; + +export default defineWebSocketHandler({ + open(peer) { + peer.send(String(peer.peers.size)); + }, +}); diff --git a/test/presets/cloudflare-durable.test.ts b/test/presets/cloudflare-durable.test.ts new file mode 100644 index 0000000000..1054b06e50 --- /dev/null +++ b/test/presets/cloudflare-durable.test.ts @@ -0,0 +1,72 @@ +import { promises as fsp } from "node:fs"; +import { Miniflare } from "miniflare"; +import { resolve } from "pathe"; +import { afterAll, describe, expect, it } from "vitest"; + +import { setupTest } from "../tests.ts"; + +describe("nitro:preset:cloudflare-durable", async () => { + for (const bindingName of ["$DurableObject", "MyCustomDO"]) { + const ctx = await setupTest("cloudflare-durable", { + outDirSuffix: `-${bindingName}`, + config: { + features: { websocket: true }, + handlers: [ + { route: "/durable-websocket", handler: "./server/handlers/durable-websocket.ts" }, + ], + cloudflare: bindingName === "$DurableObject" ? {} : { durable: { bindingName } }, + }, + }); + const config = JSON.parse( + await fsp.readFile(resolve(ctx.outDir, "server/wrangler.json"), "utf8") + ); + it(`generates the binding and migration for ${bindingName}`, () => { + expect(config.durable_objects.bindings).toEqual([ + { name: bindingName, class_name: "$DurableObject" }, + ]); + expect(config.migrations).toEqual([{ tag: "v1", new_sqlite_classes: ["$DurableObject"] }]); + }); + const mf = new Miniflare({ + modules: true, + scriptPath: resolve(ctx.outDir, "server/index.mjs"), + modulesRules: [{ type: "CompiledWasm", include: ["**/*.wasm"] }], + compatibilityDate: "2026-08-01", + compatibilityFlags: ["nodejs_compat"], + durableObjects: Object.fromEntries( + config.durable_objects.bindings.map((binding: { name: string; class_name: string }) => [ + binding.name, + { className: binding.class_name, useSQLite: true }, + ]) + ), + bindings: ctx.env, + }); + afterAll(() => mf.dispose()); + + it(`routes WebSockets through ${bindingName}`, async () => { + const sockets: NonNullable>["webSocket"]>[] = []; + async function connect(query: string) { + const response = await mf.dispatchFetch(`http://localhost/durable-websocket${query}`, { + headers: { Upgrade: "websocket" }, + }); + expect(response.status).toBe(101); + const socket = response.webSocket!; + sockets.push(socket); + const count = new Promise((resolve) => { + socket.addEventListener("message", (event) => resolve(String(event.data)), { + once: true, + }); + }); + socket.accept(); + return count; + } + try { + expect(await connect("?room=alpha")).toBe("1"); + expect(await connect("?room=alpha")).toBe("2"); + expect(await connect("?room=beta")).toBe("3"); + expect(await connect("")).toBe("4"); + } finally { + for (const socket of sockets) socket.close(); + } + }); + } +}); diff --git a/test/unit/cloudflare-durable-config.test.ts b/test/unit/cloudflare-durable-config.test.ts new file mode 100644 index 0000000000..97212a1975 --- /dev/null +++ b/test/unit/cloudflare-durable-config.test.ts @@ -0,0 +1,179 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "pathe"; +import { createNitro } from "nitro/builder"; +import { unstable_readConfig } from "wrangler"; +import type { CloudflareOptions } from "../../src/presets/cloudflare/types.ts"; +import { describe, expect, it } from "vitest"; +import { writeWranglerConfig } from "../../src/presets/cloudflare/utils.ts"; + +async function generateConfig( + cloudflare: CloudflareOptions = {}, + options: { preset?: string; derived?: boolean; env?: string } = {} +) { + const rootDir = await mkdtemp(join(tmpdir(), "nitro-durable-config-")); + const nitro = await createNitro({ + rootDir, + preset: options.derived ? undefined : options.preset || "cloudflare-durable", + defaultPreset: options.derived ? { extends: "cloudflare-durable" } : undefined, + cloudflare: { deployConfig: true, ...cloudflare }, + compatibilityDate: "2026-09-01", + }); + try { + await nitro.hooks.callHook("build:before", nitro); + await writeWranglerConfig(nitro, "module"); + if (options.env) { + return unstable_readConfig({ + config: join(nitro.options.output.serverDir, "wrangler.json"), + env: options.env, + }); + } + return JSON.parse( + await readFile(join(nitro.options.output.serverDir, "wrangler.json"), "utf8") + ); + } finally { + await nitro.close(); + await rm(rootDir, { recursive: true, force: true }); + } +} + +describe("cloudflare durable deployment config", () => { + it("generates a default binding and initial SQLite migration", async () => { + const config = await generateConfig(); + expect(config.durable_objects.bindings).toEqual([ + { name: "$DurableObject", class_name: "$DurableObject" }, + ]); + expect(config.migrations).toEqual([{ tag: "v1", new_sqlite_classes: ["$DurableObject"] }]); + }); + + it("preserves an existing binding and migration history", async () => { + const wrangler = { + durable_objects: { bindings: [{ name: "CustomDO", class_name: "$DurableObject" }] }, + migrations: [{ tag: "original", new_classes: ["$DurableObject"] }], + }; + const config = await generateConfig({ durable: { bindingName: "CustomDO" }, wrangler }); + expect(config.durable_objects).toEqual(wrangler.durable_objects); + expect(config.migrations).toEqual(wrangler.migrations); + }); + + it("rejects a conflicting binding name", async () => { + await expect( + generateConfig({ + wrangler: { + durable_objects: { bindings: [{ name: "$DurableObject", class_name: "OtherObject" }] }, + }, + }) + ).rejects.toThrow(/binding.*\$DurableObject/i); + }); + + it("rejects a remote binding with the configured name", async () => { + await expect( + generateConfig({ + wrangler: { + durable_objects: { + bindings: [ + { name: "$DurableObject", class_name: "$DurableObject", script_name: "other-worker" }, + ], + }, + }, + }) + ).rejects.toThrow(/binding.*\$DurableObject/i); + }); + + it("leaves validation of an existing migration history to Wrangler", async () => { + const migrations = [{ tag: "v1", new_sqlite_classes: ["OtherObject"] }]; + const config = await generateConfig({ wrangler: { migrations } }); + expect(config.migrations).toEqual(migrations); + }); + + it("does not configure durable objects on the module preset", async () => { + const config = await generateConfig({}, { preset: "cloudflare-module" }); + expect(config.durable_objects).toBeUndefined(); + expect(config.migrations).toBeUndefined(); + }); + it("preserves deletion entries in existing migration histories", async () => { + const migrations = [ + { tag: "v1", new_sqlite_classes: ["$DurableObject"] }, + { tag: "v2", deleted_classes: ["$DurableObject"] }, + ]; + const config = await generateConfig({ wrangler: { migrations } }); + expect(config.migrations).toEqual(migrations); + }); + + it("preserves a class introduced by a rename", async () => { + const migrations = [ + { tag: "v1", new_sqlite_classes: ["OriginalObject"] }, + { tag: "v2", renamed_classes: [{ from: "OriginalObject", to: "$DurableObject" }] }, + ]; + const config = await generateConfig({ wrangler: { migrations } }); + expect(config.migrations).toEqual(migrations); + }); + it("preserves declarative Durable Object exports without adding migrations", async () => { + const exports = { $DurableObject: { type: "durable-object", storage: "sqlite" } } as const; + const config = await generateConfig({ wrangler: { exports } }); + expect(config.exports).toEqual(exports); + expect(config.migrations).toBeUndefined(); + }); + + it("preserves a migration that transfers the class from another Worker", async () => { + const migrations = [ + { + tag: "v1", + transferred_classes: [ + { from: "OldObject", from_script: "old-worker", to: "$DurableObject" }, + ], + }, + ]; + const config = await generateConfig({ wrangler: { migrations } }); + expect(config.migrations).toEqual(migrations); + }); + + it("generates bindings in named environments without dropping existing ones", async () => { + const config = await generateConfig( + { + durable: { bindingName: "CustomDO" }, + wrangler: { + env: { + production: { + name: "nitro-production", + durable_objects: { + bindings: [ + { name: "OtherDO", class_name: "OtherObject", script_name: "other-worker" }, + ], + }, + }, + }, + }, + }, + { env: "production" } + ); + expect(config.durable_objects.bindings).toEqual([ + { name: "OtherDO", class_name: "OtherObject", script_name: "other-worker" }, + { name: "CustomDO", class_name: "$DurableObject" }, + ]); + }); + + it("preserves environment exports without inheriting generated migrations", async () => { + const exports = { $DurableObject: { type: "durable-object", storage: "sqlite" } } as const; + const config = await generateConfig( + { wrangler: { env: { production: { name: "nitro-production", exports } } } }, + { env: "production" } + ); + expect(config.exports).toEqual(exports); + expect(config.migrations).toEqual([]); + expect(config.durable_objects.bindings).toEqual([ + { name: "$DurableObject", class_name: "$DurableObject" }, + ]); + }); + + it("generates bindings and migrations for an inherited durable preset", async () => { + const config = await generateConfig( + { durable: { bindingName: "CustomDO" } }, + { derived: true } + ); + expect(config.durable_objects?.bindings).toEqual([ + { name: "CustomDO", class_name: "$DurableObject" }, + ]); + expect(config.migrations).toEqual([{ tag: "v1", new_sqlite_classes: ["$DurableObject"] }]); + }); +});