Skip to content

Test WebSockets and Durable Objects

In-process WebSocket arc build checks and white-box Durable Object tests with composeDurableInstance.

AI generated, pending review Updated 10 days ago · Flare 0.3

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 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).

Import white-box helpers from @flare-ts/core/cloudflare:

HelperUse
composeDurableInstanceBuild the per-instance handler for a registered FlareDurableObject class
makeFakeDurableStateIn-memory DurableObjectState with distinct ids per name
makeFakeStorageKV subset (get, put, delete, list); no SQL
makeEnvWrap plain bindings as Cloudflare.Env

makeFakeStorage does not implement sql. DOs that rely on state.storage.sql need a real cloudflare:test binding.

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 route
host.build();
const inst = composeDurableInstance(
host,
makeFakeDurableState({ name: "counter-1" }),
makeEnv(),
Counter,
);
const res = await inst.fetch(new Request("https://do/count"));
// res.status === 200

The Worker host still needs at least one HTTP route to compile cleanly even when you only test the Durable Object instance handler.

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 caller

Run 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 }).

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);
GoalTool
HTTP route through full pipelineapp.test() + handle.fetch
WS route registration mistakeshost.build() + FlareValidationError codes
Durable Object HTTP handler logiccomposeDurableInstance + fake state
Durable Object WS handler logiccomposeDurableInstance under cloudflare:test
Durable Object storage SQLcloudflare:test real binding
Node WS wire protocolNode transport integration tests