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
49 changes: 48 additions & 1 deletion docs/2.deploy/20.providers/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,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.
Expand All @@ -82,6 +82,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<number> {
const count = ((await this.ctx.storage.get<number>("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.
Expand Down
50 changes: 50 additions & 0 deletions examples/cloudflare-durable/README.md
Original file line number Diff line number Diff line change
@@ -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<number> {
const count = ((await this.ctx.storage.get<number>("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.
1 change: 1 addition & 0 deletions examples/cloudflare-durable/exports.cloudflare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CounterDO } from "./server/durable/counter.ts";
21 changes: 21 additions & 0 deletions examples/cloudflare-durable/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Nitro + Cloudflare Durable Objects</title>
</head>
<body>
<h1>Nitro + Cloudflare Durable Objects</h1>
<p>Counter: <span id="count">…</span></p>
<button id="increment">Increment</button>
<script type="module">
const el = document.querySelector("#count");
async function increment() {
const { count } = await fetch("/counter").then((r) => r.json());
el.textContent = count;
}
document.querySelector("#increment").addEventListener("click", increment);
increment();
</script>
</body>
</html>
6 changes: 6 additions & 0 deletions examples/cloudflare-durable/nitro.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from "nitro";

export default defineConfig({
preset: "cloudflare_module",
serverDir: "./",
});
13 changes: 13 additions & 0 deletions examples/cloudflare-durable/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
11 changes: 11 additions & 0 deletions examples/cloudflare-durable/routes/counter.ts
Original file line number Diff line number Diff line change
@@ -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<CounterDO>;
};
const counter = env.COUNTER.getByName("global");
Comment on lines +4 to +8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor | ⚑ Quick win

Add validation for missing Cloudflare bindings.

If event.req.runtime?.cloudflare?.env is undefined, the type assertion on line 5 combined with property access on line 8 will throw a TypeError rather than a clear, actionable error. As per coding guidelines, prefer explicit errors over silent failures.

πŸ›‘οΈ Proposed fix to add explicit validation
 export default defineHandler(async (event) => {
+  if (!event.req.runtime?.cloudflare?.env) {
+    throw new Error("Cloudflare runtime environment not available");
+  }
   const env = event.req.runtime?.cloudflare?.env as {
     COUNTER: DurableObjectNamespace<CounterDO>;
   };
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/cloudflare-durable/routes/counter.ts` around lines 4 - 8, The code
assumes event.req.runtime?.cloudflare?.env exists and directly casts it, which
will throw a TypeError if missing; update the handler (defineHandler) to
validate that event.req.runtime?.cloudflare?.env is defined and that env.COUNTER
exists before calling env.COUNTER.getByName("global"), and throw a clear,
descriptive error (e.g., "Missing Cloudflare Durable Object binding: COUNTER")
so callers get an actionable message instead of a runtime TypeError; reference
the env variable and the COUNTER DurableObjectNamespace/CounterDO binding when
adding the checks.

Source: Coding guidelines

const count = await counter.increment();
return { count };
});
9 changes: 9 additions & 0 deletions examples/cloudflare-durable/server/durable/counter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { DurableObject } from "cloudflare:workers";

export class CounterDO extends DurableObject {
async increment(amount: number = 1): Promise<number> {
const count = ((await this.ctx.storage.get<number>("count")) ?? 0) + amount;
await this.ctx.storage.put("count", count);
return count;
}
}
6 changes: 6 additions & 0 deletions examples/cloudflare-durable/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": "nitro/tsconfig",
"compilerOptions": {
"types": ["@cloudflare/workers-types"]
}
}
4 changes: 4 additions & 0 deletions examples/cloudflare-durable/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { defineConfig } from "vite";
import { nitro } from "nitro/vite";

export default defineConfig({ plugins: [nitro()] });
9 changes: 9 additions & 0 deletions examples/cloudflare-durable/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -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"] }],
}
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions src/build/vite/env.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -175,13 +176,21 @@ async function _loadRunner(ctx: NitroPluginContext, manager: RunnerManager) {
let runner;
if (runnerName === "miniflare") {
const { MiniflareEnvRunner } = await import("env-runner/runners/miniflare");
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",
wrangler: {
...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, {
Expand Down
Loading
Loading