I wanted one tiny router that runs unchanged on Cloudflare Workers and a plain Node HTTP server. The secret sauce is itty-router + the Fetch standard (Request
/Response
). Here’s how I wired it up, plus a quick note on the “do Node and Cloudflare use the same Response?” question.
Do Node and Cloudflare use the same Response object?
Short answer: not literally the same object, but the same Fetch standardResponse
API, compatible classes with different under the hood implementations.
– Cloudflare Workers exposes aResponse
that follows the WHATWG Fetch spec.
– Modern Node (v18+) also exposes a Fetch-compatibleResponse
(implemented by undici).So they’re API-compatible (same props/methods:
status
,headers
,text()
,json()
,arrayBuffer()
,body
as a Web ReadableStream,new Response()
, etc.), but they’re different implementations under the hood—don’t rely on brand checks like mixing realms orinstanceof
across runtimes.A few practical gotchas:
– Streaming: both use Web Streams; in Node you sometimes convert to/from Node streams (Readable.fromWeb
,Readable.toWeb
) when bridging tohttp.ServerResponse
.
– Node-only quirks (Requests): when you construct aRequest
with a Node stream body, Node needsduplex: 'half'
. This is not needed/used in Workers. (Doesn’t affectResponse
itself.)
– Helpers:Response.json()
exists in Workers and in recent Node. If you need to support older Node, fallback tonew Response(JSON.stringify(data), { headers: { 'content-type': 'application/json; charset=utf-8' } })
.If you stick to the spec surface (like in the router below), you can reuse the same code in both environments. For a more detailed explanation, check What’s the
Request
object in the browser, Cloudflare, and Node?.
The shared router (one codepath)
itty-router’s AutoRouter
can produce a single fetch
handler. Keep the routes and tiny HTTP helpers here.
// src/router.ts
import { AutoRouter } from 'itty-router'
// tiny helpers
const withCORS = (res: Response, status = res.status) =>
new Response(res.body, {
status,
headers: {
'content-type': res.headers.get('content-type') ?? 'application/json; charset=utf-8',
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET,POST,OPTIONS',
'access-control-allow-headers': 'content-type',
},
})
const ok = (data: unknown) => withCORS(Response.json(data))
const badRequest = (msg: string) => withCORS(Response.json({ error: msg }), 400)
const noContent = () => withCORS(new Response(null, { status: 204 }), 204)
const router = AutoRouter({ base: '/' })
// CORS preflight
router.options('/*', () => noContent())
// sample routes (keep it simple)
router.get('/hello', () => ok({ hello: 'world' }))
router.post('/echo', async (req: Request) => {
try {
const body = await req.json()
return ok({ youSent: body })
} catch {
return badRequest('Expected JSON body')
}
})
// 404
router.all('*', () => withCORS(Response.json({ error: 'Not found' }), 404))
// export a single fetch (works on Workers; reusable in Node)
export default { fetch: router.fetch }
That’s it. This fetch
function is our “universal” entry point.
Cloudflare Worker entry (zero glue)
Cloudflare Workers already speak fetch
:
// src/worker.ts
import router from './router'
export default {
fetch: router.fetch,
}
Deploy with your usual Wrangler config.
Node adapter (a tiny bridge)
For Node, we just translate IncomingMessage
⟶ Request
, call the shared fetch
, then write the Response
back.
// src/node-server.ts
/// <reference types="node" />
import http from 'node:http'
import { Readable } from 'node:stream'
import router from './router'
const PORT = Number(process.env.PORT ?? 8787)
http.createServer(async (req, res) => {
try {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`)
const method = req.method ?? 'GET'
// build Headers
const headers = new Headers()
for (const [k, v] of Object.entries(req.headers)) {
if (Array.isArray(v)) v.forEach(vv => headers.append(k, vv))
else if (v != null) headers.set(k, String(v))
}
// only attach a body for non-GET/HEAD
const hasBody = !['GET', 'HEAD'].includes(method.toUpperCase())
const body = hasBody ? (req as unknown as Readable) : undefined
const request = new Request(url.toString(), {
method,
headers,
body,
// Node (undici) quirk when passing a stream body:
// @ts-expect-error undici extension
duplex: hasBody ? 'half' : undefined,
})
const response = await router.fetch(request)
res.statusCode = response.status
response.headers.forEach((v, k) => res.setHeader(k, v))
if (!response.body) return res.end()
// stream to Node
// @ts-ignore types for toWeb are a bit behind
await (response.body as any).pipeTo(Readable.toWeb(res) as any)
} catch (err: any) {
res.statusCode = 500
res.setHeader('content-type', 'application/json; charset=utf-8')
res.end(JSON.stringify({ error: 'Internal error', detail: String(err?.message ?? err) }))
}
}).listen(PORT, () => {
console.log(`Node server: http://localhost:${PORT}`)
})
TypeScript tip
Make sure your tsconfig.json
includes DOM types so Request
/Response
are available in Node:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"strict": true
}
}
Why this works so well
- Single mental model: you write to the Fetch standard once. itty-router just plugs in.
- No “environment if”s: the router and helpers are runtime-agnostic.
- Portability: same handler can move to other Fetch runtimes (Bun, Deno Deploy, etc.) with little/no glue.
Quick curl test
# Node server
curl http://localhost:8787/hello
# => {"hello":"world"}
curl -X POST http://localhost:8787/echo -H 'content-type: application/json' -d '{"x":1}'
# => {"youSent":{"x":1}}
For Workers, hit your deployed URL with the same requests.
Gotchas & guardrails
- Don’t do
instanceof Response
checks across runtimes—treat them as structurally compatible, not identical classes. - When constructing a
Request
from a Node stream, passduplex: 'half'
. - If you need cookies or advanced streaming, test both runtimes—semantics are compatible, but edge cases differ.
Top comments (0)