Test WebSockets and Durable Objects
In-process WebSocket arc build checks and white-box Durable Object tests with composeDurableInstance.
HTTP integration tests use app.test() and handle.fetch. The WebSocket arc and Durable Objects need different harnesses: WS routes validate at host.build() time, and Durable Object instances run on a per-instance container graph you can compose in-process without a real binding.
This page covers the portable patterns. For mounting DOs on a Worker and production bindings, see Durable Objects.
WebSocket arc: build-time validation
Section titled “WebSocket arc: build-time validation”WebSocket routes register on host.ws. Many WS mistakes surface as FlareValidationError during host.build(), not on the first connection. Drive build() synchronously in unit tests with FLARE_MODE=test set before constructing the host.
process.env["FLARE_MODE"] = "test";
import { FlareHost, FlareResponse, FlareValidationError } from "@flare-ts/core";import { node } from "@flare-ts/core/node";import { strict as assert } from "node:assert";
const host = new FlareHost(node);host.http.get("/chat/:id", () => new FlareResponse(200, {}));host.ws.route("/chat/:room");
let err: unknown;try { host.build();} catch (e) { err = e;}assert.ok(err instanceof FlareValidationError);assert.ok( (err as FlareValidationError).errors.some((e) => e.code === "WS_HTTP_ROUTE_CONFLICT"),);Common validator codes include WS_HTTP_ROUTE_CONFLICT, WS_DUPLICATE_ROUTE, and WS_ROUTE_MISSING_PARAM_NAME. When HTTP and WS paths are distinct, build() succeeds and the WS arc is compiled alongside HTTP.
Full-duplex WebSocket message tests on Node use the Node transport integration suite (end-to-end upgrade and echo scenarios). On Cloudflare, WebSocketPair is available in the cloudflare:test pool for binding-level suites. TestAppHandle.fetch targets the HTTP pipeline; drive WS upgrades with Request objects that include Upgrade: websocket against the runtime export or a composed Durable Object handler (below).
Durable Objects: composeDurableInstance
Section titled “Durable Objects: composeDurableInstance”Import white-box helpers from @flare-ts/core/cloudflare:
| Helper | Use |
|---|---|
composeDurableInstance | Build the per-instance handler for a registered FlareDurableObject class |
makeFakeDurableState | In-memory DurableObjectState with distinct ids per name |
makeFakeStorage | KV subset (get, put, delete, list); no SQL |
makeEnv | Wrap plain bindings as Cloudflare.Env |
makeFakeStorage does not implement sql. DOs that rely on state.storage.sql need a real cloudflare:test binding.
HTTP on a composed instance
Section titled “HTTP on a composed instance”import { buildCf, composeDurableInstance, FlareDurableObject, makeEnv, makeFakeDurableState,} from "@flare-ts/core/cloudflare";import { FlareHost, FlareResponse } from "@flare-ts/core";
class Counter extends FlareDurableObject { static override deps = [] as const;}
const host = new FlareHost(buildCf({ host: { env: "test" } }));host.durableObject(Counter).http.get("/count", () => new FlareResponse(200, { n: 1 }));host.http.get("/_", () => new FlareResponse(200)); // worker still needs an HTTP routehost.build();
const inst = composeDurableInstance( host, makeFakeDurableState({ name: "counter-1" }), makeEnv(), Counter,);
const res = await inst.fetch(new Request("https://do/count"));// res.status === 200The Worker host still needs at least one HTTP route to compile cleanly even when you only test the Durable Object instance handler.
WebSocket on a composed instance
Section titled “WebSocket on a composed instance”Register WS routes on the instance’s WS arc (host.durableObject(Cls).ws.route(...)), then upgrade through the composed instance’s fetch:
import { buildCf, composeDurableInstance, DurableState, FlareDurableObject, makeEnv, makeFakeDurableState,} from "@flare-ts/core/cloudflare";import { FlareHost, FlareResponse } from "@flare-ts/core";
class Room extends FlareDurableObject { static override deps = [DurableState] as const;}
const host = new FlareHost(buildCf({ host: { env: "test" } }));const room = host.durableObject(Room);room.ws.route("/sock", { inject: { ds: DurableState } }).message((ws, scope) => { const m = scope.input.message; ws.send(`echo:${scope.ds.id.toString()}:${m.isBinary ? "binary" : m.text()}`);});host.http.get("/_", () => new FlareResponse(200));host.build();
const inst = composeDurableInstance( host, makeFakeDurableState({ name: "room-1" }), makeEnv(), Room,);
const res = await inst.fetch( new Request("https://do/sock", { headers: { Upgrade: "websocket" } }),);// res.status === 101; res.webSocket is the client half of the pair, returned to the callerRun this under the cloudflare:test pool: driving a WS upgrade allocates a WebSocketPair, a Workers-runtime global. The HTTP example above is binding-free; this one is not.
Parsed upgrade params and inbound messages arrive on scope.input, the same pattern as HTTP’s scope.input on inline handlers. Inject services with a named map (inject: { ds: DurableState }).
Per-instance isolation
Section titled “Per-instance isolation”Pass different name values to makeFakeDurableState to assert separate container graphs:
const instA = composeDurableInstance(host, makeFakeDurableState({ name: "alpha" }), makeEnv(), Room);const instB = composeDurableInstance(host, makeFakeDurableState({ name: "beta" }), makeEnv(), Room);Choosing a tier
Section titled “Choosing a tier”| Goal | Tool |
|---|---|
| HTTP route through full pipeline | app.test() + handle.fetch |
| WS route registration mistakes | host.build() + FlareValidationError codes |
| Durable Object HTTP handler logic | composeDurableInstance + fake state |
| Durable Object WS handler logic | composeDurableInstance under cloudflare:test |
| Durable Object storage SQL | cloudflare:test real binding |
| Node WS wire protocol | Node transport integration tests |
See also
Section titled “See also”- The arc model: the HTTP and WebSocket arcs on one host
- WebSockets: routes, contracts, and channels
- Mount Durable Objects: Worker registration
- Testing:
FLARE_MODE=testandTestAppHandle