Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions docs/2.deploy/20.providers/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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]
{
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions src/presets/cloudflare/durable.ts
Original file line number Diff line number Diff line change
@@ -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 = [];
}
}
}
7 changes: 7 additions & 0 deletions src/presets/cloudflare/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/presets/cloudflare/runtime/cloudflare-durable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions src/presets/cloudflare/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/presets/cloudflare/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/runtime/virtual/cloudflare-durable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import "./_runtime_warn.ts";

export const bindingName = "$DurableObject";
7 changes: 7 additions & 0 deletions test/fixture/server/handlers/durable-websocket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineWebSocketHandler } from "nitro";

export default defineWebSocketHandler({
open(peer) {
peer.send(String(peer.peers.size));
},
});
72 changes: 72 additions & 0 deletions test/presets/cloudflare-durable.test.ts
Original file line number Diff line number Diff line change
@@ -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<Awaited<ReturnType<typeof mf.dispatchFetch>>["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<string>((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();
}
});
}
});
Loading
Loading