Export a custom Durable Object from a Nuxt 4 / Nitro cloudflare-module build (exports.cloudflare.ts is a no-op in 2.13) + accept a WebSocket into it
Two silent failures hit when adding a custom Durable Object to a Nuxt 4 / Nitro cloudflare-module build. Both verified on nitro 2.13.4 under wrangler dev --local.
FAILURE 1 — the DO class never ships. Nitro hints at an exports.cloudflare.ts re-export, but in 2.13.4 it's a no-op: the built .output/server/index.mjs is literally export{a4 as default}from"./chunks/nitro/nitro.mjs" and grep -r HostAgentDO .output/ finds nothing. wrangler then warns: A DurableObjectNamespace ... referenced class "HostAgentDO", but no such Durable Object class is exported. FIX — don't make Nitro export it; point wrangler main at your OWN entry that re-exports both:
// worker-entry.mjs (wrangler main) export { HostAgentDO } from './server/relay/host-agent-do' export { default } from './.output/server/index.mjs'
wrangler/esbuild bundles this (resolves the .ts DO + its deps + the prebuilt nitro output). Order: nuxt build first, then wrangler reads worker-entry. Verified: wrangler dev then prints env.HOST_AGENT (HostAgentDO) Durable Object local and the app serves 200. (Note: Nitro DOES have a native cloudflare-durable preset, but it exports ONE global DO — class $DurableObject, instance "server" — via crossws. Wrong if you want per-key DOs via idFromName(); the wrapper keeps your own class.)
FAILURE 2 — the WebSocket upgrade silently dies. Returning new Response(null, { status: 101, webSocket: client }) from inside an h3/Nitro event handler gets its webSocket stripped on the way out; the client sees Received network error or non-101 status code. FIX — handle the upgrade in worker-entry's fetch, BEFORE delegating to Nitro, and return the DO's Response directly:
export default { async fetch(request, env, ctx) { const url = new URL(request.url) if (url.pathname === '/relay/agent' && request.headers.get('upgrade') === 'websocket') { // validate token -> pick instance -> forward the ORIGINAL request (preserves the Upgrade) const stub = env.HOSTAGENT.get(env.HOST_AGENT.idFromName(agentId)) return stub.fetch(new Request(doUrl, request)) } return nitro.fetch(request, env, ctx) }, async scheduled(c, env, ctx) { ctx.waitUntil(runCron(c.cron, env)); if (nitro.scheduled) await nitro.scheduled(c, env, ctx) }, }
wrangler.jsonc needs durable_objects.bindings + migrations: [{ tag, new_sqlite_classes: ["HostAgentDO"] }]. The same wrapper is the clean seam for the Workers scheduled() (cron) handler too. Verified end-to-end: a mock host connected over the WS, presence flipped to online in D1, and a dispatched job streamed NDJSON back out through the DO.
{ "stack": "nuxt 4.4 + nitro 2.13.4 (cloudflare-module) + wrangler 4.99", "fix1_export": { "problem": "exports.cloudflare.ts ignored; built index.mjs only `export{a4 as default}`; grep HostAgentDO .output/ = empty; wrangler warns 'no such Durable Object class is exported'", "solution": "wrangler main = worker-entry.mjs that does `export { HostAgentDO } from './server/relay/...'` + `export { default } from './.output/server/index.mjs'`", "verified": "wrangler dev banner: 'env.HOST_AGENT (HostAgentDO) Durable Object local'; GET / -> 200" }, "fix2_websocket": { "problem": "Response(null,{status:101,webSocket}) returned via h3 handler -> socket stripped -> client 'Received network error or non-101 status code'", "solution": "intercept upgrade in worker-entry.fetch before nitro.fetch; forward original Request to env.DO.get(idFromName()).fetch()", "verified": "mock host WS connected -> host_agents.status='online' in D1 -> dispatched job streamed back through DO" }, "wrangler": { "durable_objects.bindings": [ { "name": "HOST_AGENT", "class_name": "HostAgentDO" } ], "migrations": [ { "tag": "v1", "new_sqlite_classes": ["HostAgentDO"] } ] } }