Hono, Workers, Bun, Deno and Next.js
createFetchHandler is the Rayfold endpoint as a Request → Response function. Rayfold runs on any server that takes that shape, with the same batches, live queries, policies and caching as on Node. The Node transport is this handler with an adapter in front. The one exception is @http REST bindings: createBindingHandler is written against Node's req/res and has no fetch form yet, so a schema's REST routes are served on Node only.
import { createFetchHandler } from "@rayfold/server";
const handler = createFetchHandler(server, {
viewer: (request) => viewerFrom(request.headers.get("authorization")),
});Nothing in it touches Node, so it also imports from @rayfold/server/core in a runtime that has no Node built-ins. A test bundles that entry for the browser and fails if any file in its import graph imports from node: or reaches for Buffer or process.
Where to put it
import { createRayfoldServer, createFetchHandler } from "@rayfold/server/core";
const server = createRayfoldServer({ schema, resolvers });
const handler = createFetchHandler(server, { viewer: (r) => viewerFrom(r) });
export default { fetch: handler };import { Hono } from "hono";
const app = new Hono();
// every method and every sub-path: /rayfold/manifest, /openapi.json, /health, /ready, /uploads
app.all("/rayfold/*", (c) => handler(c.req.raw));
app.all("/rayfold", (c) => handler(c.req.raw));Bun.serve({ port: 4000, fetch: handler });Deno.serve({ port: 4000 }, handler);// app/rayfold/[[...path]]/route.ts
const GET = (request: Request) => handler(request);
export { GET, GET as POST, GET as OPTIONS };path moves the endpoint (createFetchHandler(server, { path: "/api/rayfold" })); mount your framework's route on the same prefix.
What differs from the Node transport
Say whether you are loopback-bound. A server reached on 127.0.0.1 answers loopback host names only, which defeats DNS rebinding (spec 12 §2). The Node transport reads that from the socket; a fetch runtime has none, so pass loopback: true when you serve a development server on localhost. allowedHosts works everywhere; use it in production.
Live queries need the platform to let a response stay open. Workers, Deno and Bun stream, so a live query or a stream works there as it does on Node. Serverless platforms limit duration instead: when a platform cuts a response at its time limit, the op ends with it. The client library reopens a client.live() subscription after a retryable end, so a screen recovers by itself. On a platform with a short limit, expect reconnections instead of one long-lived response.
The relay and graceful shutdown assume a process. PgRelay holds a LISTEN connection and server.drain() waits for in-flight batches; both suit a server that stays up (Deployment). On a per-request runtime, a command's patches still reach the caller that ran it. Other instances hear them only if you give every one of them a relay. A durable object or a container can hold one; a short-lived isolate cannot.
Uploads and other bodies
The handler reads at most maxBody bytes (1 MiB by default) and refuses more with 413, before anything runs.
The upload route comes with it: createFetchHandler(server, { uploads: { store } }) serves POST {path}/uploads on any of these runtimes (Uploads). It has its own bound (maxBytes, 25 MiB by default), counted as the bytes arrive, so maxBody does not apply to it.
Checking it
GET {path}/health and GET {path}/ready answer on every runtime, so the same probes work behind any of them (Deployment). GET {path}/manifest serves the schema a client needs to speak to you.