diff --git a/docs/2.deploy/20.providers/cloudflare.md b/docs/2.deploy/20.providers/cloudflare.md index 1eb87cbae6..be97ea0319 100644 --- a/docs/2.deploy/20.providers/cloudflare.md +++ b/docs/2.deploy/20.providers/cloudflare.md @@ -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. @@ -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 { + 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. 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 644ebd0573..d50e0d0de6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -361,6 +361,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/src/build/vite/env.ts b/src/build/vite/env.ts index afe99e6dba..4c488a5657 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"; @@ -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, { 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 64c656f419..a2ae361816 100644 --- a/src/presets/cloudflare/utils.ts +++ b/src/presets/cloudflare/utils.ts @@ -183,7 +183,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 bfc182bce1..e9116d6ec1 100644 --- a/src/runtime/internal/vite/dev-worker.mjs +++ b/src/runtime/internal/vite/dev-worker.mjs @@ -144,14 +144,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); } @@ -202,6 +206,16 @@ export const ipc = { onClose() {}, }; +// ----- 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) { diff --git a/test/examples.test.ts b/test/examples.test.ts index 58b98ef55c..08590f02d4 100644 --- a/test/examples.test.ts +++ b/test/examples.test.ts @@ -33,7 +33,13 @@ const skip = new Set([ ]), ]); -const skipDev = new Set(["auto-imports", "cached-handler"]); +const skipDev = new Set([ + "auto-imports", + "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 ? [] : []); 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); + }); +});