diff --git a/README.md b/README.md index 963e432..0cca0ed 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,15 @@ The following types can be passed over RPC (in arguments or return values), and * `ReadableStream` and `WritableStream`, with automatic flow control. * `Headers`, `Request`, and `Response` from the Fetch API. +When a returned `Response` has a Cloudflare Workers-style `.webSocket` property, Cap'n Web treats +that socket as the continuation of an HTTP/WebSocket upgrade. The receiver gets a `Response` with +a `.webSocket` property whose value supports `send()`, `close()`, `accept()`, `readyState`, and +`message` / `close` / `error` event listeners, suitable for passing to +`newWebSocketRpcSession()`. Bare `WebSocket` objects are not otherwise serializable values; this +support is scoped to fetch upgrade responses. Close or dispose the upgraded socket when done so +the remote connection can be released. Upgrade tunneling carries data frames over the RPC session +and does not add `ReadableStream`-style flow control or expose ping/pong control frames. + The following types are not supported as of this writing, but may be added in the future: * `Map` and `Set` * `ArrayBuffer` and typed arrays other than `Uint8Array` diff --git a/__tests__/test-server.ts b/__tests__/test-server.ts index 8f083d8..8be238e 100644 --- a/__tests__/test-server.ts +++ b/__tests__/test-server.ts @@ -51,7 +51,10 @@ export async function setup(project: TestProject) { }) // Listen on an ephemeral port for testing purposes. - httpServer.listen(0); + await new Promise((resolve, reject) => { + httpServer!.once("error", reject); + httpServer!.listen(0, "127.0.0.1", resolve); + }); let addr = httpServer.address() as AddressInfo; // Provide the server address to tests. diff --git a/__tests__/websocket-serialization.test.ts b/__tests__/websocket-serialization.test.ts new file mode 100644 index 0000000..6f6bc4b --- /dev/null +++ b/__tests__/websocket-serialization.test.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2025 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit + +import { expect, it, describe } from "vitest"; +import type { AddressInfo } from "node:net"; +import { WebSocket as WsWebSocket, WebSocketServer } from "ws"; +import { newWebSocketRpcSession, RpcTarget } from "../src/index.js"; + +function listenWebSocket(server: WebSocketServer): Promise { + return new Promise((resolve, reject) => { + server.on("error", reject); + server.on("listening", () => { + resolve((server.address() as AddressInfo).port); + }); + }); +} + +function closeWebSocket(server: WebSocketServer): Promise { + return new Promise(resolve => { + for (let client of server.clients) client.terminate(); + server.close(() => resolve()); + }); +} + +function waitOpen(socket: WsWebSocket): Promise { + if (socket.readyState === WsWebSocket.OPEN) return Promise.resolve(); + + return new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("WebSocket failed to open")), { + once: true, + }); + }); +} + +function withTimeout(promise: Promise, ms = 5000): Promise { + let timer: ReturnType; + let timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Timed out waiting for WebSocket message")), ms); + }); + + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +function nextMessage(socket: { addEventListener(type: "message", listener: (event: any) => void, options?: any): void }) + : Promise { + return new Promise(resolve => { + socket.addEventListener("message", event => resolve(event.data), { once: true }); + }); +} + +function nextClose(socket: { addEventListener(type: "close", listener: (event: any) => void, options?: any): void }) + : Promise<{ code: number, reason: string }> { + return new Promise(resolve => { + socket.addEventListener("close", event => { + resolve({ code: event.code, reason: event.reason }); + }, { once: true }); + }); +} + +function responseWithWebSocket(socket: WebSocket): Response { + let response = new Response(null); + Object.defineProperty(response, "webSocket", { value: socket }); + return response; +} + +describe("WebSocket upgrade response serialization", () => { + it("rejects bare WebSockets but can pass Response.webSocket as an upgrade continuation", + async () => { + let echoServer = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + echoServer.on("connection", socket => { + socket.on("message", (data, isBinary) => socket.send(data, { binary: isBinary })); + }); + let echoPort = await listenWebSocket(echoServer); + + class Api extends RpcTarget { + openedSocket?: WsWebSocket; + + async open() { + let socket = new WsWebSocket(`ws://127.0.0.1:${echoPort}`); + await waitOpen(socket); + this.openedSocket = socket; + return socket; + } + + async openResponse() { + let socket = new WsWebSocket(`ws://127.0.0.1:${echoPort}`); + await waitOpen(socket); + return responseWithWebSocket(socket as any); + } + } + + let apiImpl = new Api(); + let rpcServer = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + rpcServer.on("connection", socket => { + newWebSocketRpcSession(socket as any, apiImpl); + }); + let rpcPort = await listenWebSocket(rpcServer); + + let api: any = newWebSocketRpcSession(new WsWebSocket(`ws://127.0.0.1:${rpcPort}`) as any); + try { + await expect(api.open()).rejects.toThrow(); + + let response: any = await api.openResponse(); + expect(response.status).toBe(200); + expect(response.webSocket).toBeTruthy(); + + let socket = response.webSocket; + let message = nextMessage(socket); + socket.send("hello through Response.webSocket"); + expect(await withTimeout(message)).toBe("hello through Response.webSocket"); + + let onceCount = 0; + socket.addEventListener("message", () => { ++onceCount; }, { once: true }); + let onceMessage = nextMessage(socket); + socket.send("listener once"); + expect(await withTimeout(onceMessage)).toBe("listener once"); + let afterOnceMessage = nextMessage(socket); + socket.send("listener after once"); + expect(await withTimeout(afterOnceMessage)).toBe("listener after once"); + expect(onceCount).toBe(1); + + let binaryMessage = nextMessage(socket); + socket.send(new Uint8Array([1, 2, 3]).buffer); + let bytes = await withTimeout(binaryMessage); + expect(typeof bytes).not.toBe("string"); + expect(Array.from(bytes as Uint8Array)).toEqual([1, 2, 3]); + + let close = nextClose(socket); + socket.close(1000, "done"); + expect(await withTimeout(close)).toEqual({ code: 1000, reason: "done" }); + socket.close(); + } finally { + apiImpl.openedSocket?.close(); + await closeWebSocket(rpcServer); + await closeWebSocket(echoServer); + } + }, 10_000); + + it("can run Cap'n Web over a WebSocket tunneled through Cap'n Web", async () => { + class Processor extends RpcTarget { + square(value: number) { return value * value; } + greet(name: string) { return `hello ${name}`; } + } + + let processorServer = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + processorServer.on("connection", socket => { + newWebSocketRpcSession(socket as any, new Processor()); + }); + let processorPort = await listenWebSocket(processorServer); + + class TunnelTarget extends RpcTarget { + async fetch(request: Request): Promise { + if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") { + return new Response("Expected a WebSocket upgrade.", { status: 426 }); + } + + let socket = new WsWebSocket(`ws://127.0.0.1:${processorPort}`); + await waitOpen(socket); + + let response = new Response(null); + Object.defineProperty(response, "webSocket", { value: socket }); + return response; + } + } + + let tunnelServer = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + tunnelServer.on("connection", socket => { + newWebSocketRpcSession(socket as any, new TunnelTarget()); + }); + let tunnelPort = await listenWebSocket(tunnelServer); + + let tunnelApi: any = + newWebSocketRpcSession(new WsWebSocket(`ws://127.0.0.1:${tunnelPort}`) as any); + let response: any = await tunnelApi.fetch(new Request("https://captun.test/processor", { + headers: { Upgrade: "websocket" }, + })); + + let processor: any = newWebSocketRpcSession(response.webSocket); + try { + expect(await processor.square(7)).toBe(49); + expect(await processor.greet("captun")).toBe("hello captun"); + } finally { + await closeWebSocket(tunnelServer); + await closeWebSocket(processorServer); + } + }, 10_000); +}); diff --git a/__tests__/workerd.test.ts b/__tests__/workerd.test.ts index 79f63e8..d90bcb0 100644 --- a/__tests__/workerd.test.ts +++ b/__tests__/workerd.test.ts @@ -240,6 +240,41 @@ interface WorkerdTestTarget extends TestTarget { } describe("workerd RPC server", () => { + it("can return a WebSocket-bearing Response over RPC", async () => { + class WebSocketResponseTarget extends RpcTarget { + openSocket() { + let pair = new WebSocketPair(); + let client = pair[0]; + let server = pair[1]; + server.accept(); + server.addEventListener("message", event => server.send(event.data)); + return new Response(null, { status: 101, webSocket: client }); + } + } + + let pair = new WebSocketPair(); + let client = pair[0]; + let server = pair[1]; + client.accept(); + server.accept(); + + let api: any = newWebSocketRpcSession(client); + newWebSocketRpcSession(server, new WebSocketResponseTarget()); + + let response: any = await api.openSocket(); + let socket = response.webSocket; + expect(socket).toBeTruthy(); + + socket.accept(); + let message = new Promise(resolve => { + socket.addEventListener("message", event => resolve(event.data), { once: true }); + }); + socket.send("hello over Response.webSocket"); + + expect(await message).toBe("hello over Response.webSocket"); + socket.close(); + }); + it("can accept WebSocket RPC connections", async () => { let resp = await (env).testServer.fetch("http://foo", {headers: {Upgrade: "websocket"}}); let ws = resp.webSocket; diff --git a/protocol.md b/protocol.md index b179795..aa05be9 100644 --- a/protocol.md +++ b/protocol.md @@ -203,10 +203,11 @@ A `Request` object from the Fetch API. `url` and `init` are the parameters to pa At this time, `init.signal` is not supported and must not be sent, though that will change when `AbortSignal` gains support for serialization. `["response", body, init]` +`["response", body, init, ["upgrade", sessionExpression]]` A `Response` object from the Fetch API. `body` and `init` are the parameters to pass to `Response`'s constructor to create the desired `Response` instance. `body` is an expression which must evaluate to `null`, a string, `UInt8Array`, or `ReadableStream`. `init.headers`, if present, must contain an array of pairs, suitable to pass to the constructor of `Headers`. Other properties of `init` must be plain values; they will not be evaluated as expressions before passing to the `Response` constructor. -At this time, `init.webSocket` (a Cloudflare Workers extension) is not supported and must not be sent, though that may change if `WebSocket` gains support for serialization. +The 4-element form represents an HTTP/WebSocket upgrade continuation, as in the Cloudflare Workers `Response.webSocket` extension. It is only produced when serializing a `Response` with an attached `.webSocket`; bare WebSocket objects are not otherwise serializable values. `body` must be `null` for an upgrade response. `sessionExpression` evaluates to an exported upgrade session that forwards `send()`, `close()`, and socket events. The receiver reconstructs a `Response` with a non-standard `.webSocket` property; the presence of that property is the upgrade signal. Since standard `Response` constructors cannot represent 1xx statuses, senders omit `status` and `statusText` from `init` when an upgrade response has a 1xx status. A runtime that requires a native upgrade socket may create a local WebSocket pair and bridge it to `sessionExpression`. `["import", importId, propertyPath, callArguments]` `["pipeline", importId, propertyPath, callArguments]` diff --git a/src/core.ts b/src/core.ts index fd443a6..18510a4 100644 --- a/src/core.ts +++ b/src/core.ts @@ -805,11 +805,11 @@ export class RpcPayload { // return, so that we can make sure they are not disposed before the pipeline ends. // // This is initialized on first use. - private rpcTargets?: Map; + private rpcTargets?: Map; // Get the StubHook representing the given RpcTarget found inside this payload. public getHookForRpcTarget(target: RpcTarget | Function, parent: object | undefined, - dupStubs: boolean = true): StubHook { + dupStubs: boolean = true, mapKey: object = target): StubHook { if (this.source === "params") { if (dupStubs) { // We aren't supposed to take ownership of stubs appearing in params -- we're supposed to @@ -846,12 +846,12 @@ export class RpcPayload { // * If the target is not in the map, we just create it, but don't populate the map. // * If the target *is* in the map, we *remove* the hook from the map, and return it. - let hook = this.rpcTargets?.get(target); + let hook = this.rpcTargets?.get(mapKey); if (hook) { if (dupStubs) { return hook.dup(); } else { - this.rpcTargets?.delete(target); + this.rpcTargets?.delete(mapKey); return hook; } } else { @@ -860,7 +860,7 @@ export class RpcPayload { if (!this.rpcTargets) { this.rpcTargets = new Map; } - this.rpcTargets.set(target, hook); + this.rpcTargets.set(mapKey, hook); return hook.dup(); } else { return hook; @@ -1078,7 +1078,15 @@ export class RpcPayload { // Make an actual copy of the object, e.g. so the headers are copied. // Note that it would be incorrect to use clone() here since that would tee() the body // stream. - return new Response(resp.body, resp); + let result: any = new Response(resp.body, resp); + let webSocket = (resp).webSocket; + if (webSocket) { + Object.defineProperty(result, "webSocket", { + value: webSocket, + configurable: true, + }); + } + return result; } default: @@ -1348,7 +1356,16 @@ export class RpcPayload { // The body may be a ReadableStream that has an associated hook in rpcTargets. let resp = value; if (resp.body) this.disposeImpl(resp.body, resp); - // TODO: When we support WebSocket, we may need to dispose response.webSocket here? + let cfResp = resp as any; + if (cfResp.webSocket) { + let hook = this.rpcTargets?.get(cfResp.webSocket); + if (hook) { + hook.dispose(); + this.rpcTargets!.delete(cfResp.webSocket); + } else { + try { cfResp.webSocket.close(); } catch {} + } + } return; } diff --git a/src/serialize.ts b/src/serialize.ts index 97074c0..477531c 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -3,6 +3,7 @@ // https://opensource.org/license/mit import { StubHook, RpcPayload, typeForRpc, RpcStub, RpcPromise, LocatedPromise, RpcTarget, unwrapStubAndPath, streamImpl, PromiseStubHook, PayloadStubHook } from "./core.js"; +import { UpgradeSessionSource, UpgradeWebSocket, bridgeWebSockets } from "./upgrade-session.js"; export type ImportId = number; export type ExportId = number; @@ -301,8 +302,22 @@ export class Devaluator { init.encodeBody = cfResp.encodeBody; } if (cfResp.webSocket) { - // As of this writing, we don't support WebSocket, but we might someday. - throw new TypeError("Can't serialize a Response containing a webSocket."); + if (!this.source) { + throw new Error("Can't serialize a WebSocket upgrade in this context."); + } + if (body !== null) { + throw new TypeError("WebSocket upgrade response body must be null."); + } + // Ferry the upgraded connection as an HTTP upgrade continuation. This is intentionally + // scoped to Response.webSocket rather than making WebSocket a generic serializable value. + if (typeof init.status === "number" && init.status >= 100 && init.status < 200) { + delete init.status; + delete init.statusText; + } + let hook = this.source.getHookForRpcTarget( + new UpgradeSessionSource(cfResp.webSocket), resp, true, + cfResp.webSocket); + return ["response", body, init, ["upgrade", this.devaluateHook("export", hook)]]; } return ["response", body, init]; @@ -710,7 +725,8 @@ export class Evaluator { } case "response": { - if (value.length !== 3) break; + // 3 elements normally; a 4th carries a fetch upgrade continuation. + if (value.length !== 3 && value.length !== 4) break; let body = this.evaluateImpl(value[1], parent, property); if (body === null || @@ -725,19 +741,41 @@ export class Evaluator { let init = value[2]; if (typeof init !== "object" || init === null) break; - // Evaluate specific properties which are expected to contain non-trivial types. - if (init.webSocket) { - // `response.webSocket` is a Cloudflare Workers extension. Not (yet?) supported for - // serialization. - throw new TypeError("Can't deserialize a Response containing a webSocket."); - } - // Type-check `headers` is an array because the constructor allows multiple // representations and we don't want to allow the others. if (init.headers && !(init.headers instanceof Array)) { throw new TypeError("Request headers must be serialized as an array of pairs."); } + if (value.length === 4) { + let upgrade = value[3]; + if (!Array.isArray(upgrade) || upgrade.length !== 2 || upgrade[0] !== "upgrade") { + break; + } + let webSocket = new UpgradeWebSocket(this.evaluateImpl(upgrade[1], parent, property)); + if (!("status" in init) && body === null && typeof WebSocketPair !== "undefined") { + // Workers can only upgrade with a native WebSocketPair end, not a bridged object. + let pair = new WebSocketPair(); + bridgeWebSockets(pair[1], webSocket as any); + try { + return new Response(null, { + ...(init as ResponseInit), + status: 101, + webSocket: pair[0], + } as ResponseInit); + } catch { + // Standard Response constructors reject 1xx statuses. In those runtimes, keep the + // WebSocket as an extension property and let the consumer decide how to upgrade. + } + } + let result: any = new Response(body as BodyInit | null, init as ResponseInit); + Object.defineProperty(result, "webSocket", { + value: webSocket, + configurable: true, + }); + return result; + } + return new Response(body as BodyInit | null, init as ResponseInit); } diff --git a/src/upgrade-session.ts b/src/upgrade-session.ts new file mode 100644 index 0000000..1c8a3d8 --- /dev/null +++ b/src/upgrade-session.ts @@ -0,0 +1,212 @@ +// Copyright (c) 2025 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit + +import { RpcTarget } from "./core.js"; + +/** Inbound frame events delivered from the source socket to the upgraded peer. */ +type WireEvent = + | { type: "message"; data: string | Uint8Array } + | { type: "close"; code: number; reason: string } + | { type: "error" }; + +/** The subset of the platform `WebSocket` API needed for fetch upgrade continuations. */ +interface WebSocketEndpoint { + send(data: string | ArrayBufferLike | Uint8Array): void; + close(code?: number, reason?: string): void; + accept?(): void; + addEventListener(type: string, listener: (event: any) => void, options?: any): void; + readyState?: number; + binaryType?: string; +} + +function normalizeData(data: unknown): string | Uint8Array { + if (typeof data === "string") return data; + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (ArrayBuffer.isView(data)) { + let view = data as ArrayBufferView; + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } + return String(data); +} + +export function bridgeWebSockets(left: WebSocketEndpoint, right: WebSocketEndpoint): Disposable { + let closed = false; + try { left.accept?.(); } catch {} + try { right.accept?.(); } catch {} + try { left.binaryType = "arraybuffer"; } catch {} + try { right.binaryType = "arraybuffer"; } catch {} + + const close = (code?: number, reason?: string) => { + if (closed) return; + closed = true; + try { left.close(code, reason); } catch {} + try { right.close(code, reason); } catch {} + }; + + left.addEventListener("message", (event: any) => { + if (!closed) right.send(normalizeData(event.data)); + }); + right.addEventListener("message", (event: any) => { + if (!closed) left.send(normalizeData(event.data)); + }); + left.addEventListener("close", (event: any) => close(event?.code, event?.reason)); + right.addEventListener("close", (event: any) => close(event?.code, event?.reason)); + left.addEventListener("error", () => close()); + right.addEventListener("error", () => close()); + + return { [Symbol.dispose]: close }; +} + +export class UpgradeSessionSource extends RpcTarget { + #socket: WebSocketEndpoint; + #receiver?: (event: WireEvent) => void; + #backlog: WireEvent[] = []; + #closed = false; + + constructor(socket: WebSocketEndpoint) { + super(); + this.#socket = socket; + try { socket.accept?.(); } catch { /* already accepted or not supported */ } + try { socket.binaryType = "arraybuffer"; } catch {} + socket.addEventListener("message", (e: any) => + this.#emit({ type: "message", data: normalizeData(e.data) })); + socket.addEventListener("close", (e: any) => + this.#emit({ type: "close", code: e?.code ?? 1005, reason: e?.reason ?? "" })); + socket.addEventListener("error", () => this.#emit({ type: "error" })); + } + + start(receiver: (event: WireEvent) => void): void { + if (this.#receiver) throw new Error("WebSocket upgrade session already started."); + // Call params are implicitly disposed when the call returns, so retain a duplicate. + let retained = (receiver as any)?.dup ? (receiver as any).dup() : receiver; + this.#receiver = retained; + for (const event of this.#backlog) this.#sendToReceiver(retained, event); + this.#backlog = []; + } + + send(data: string | Uint8Array): void { + this.#socket.send(data); + } + + close(code?: number, reason?: string): void { + this.#socket.close(code, reason); + } + + [Symbol.dispose](): void { + (this.#receiver as any)?.[Symbol.dispose]?.(); + this.#receiver = undefined; + if (!this.#closed && this.#socket.readyState !== 3) { + try { this.#socket.close(); } catch {} + } + } + + #emit(event: WireEvent): void { + if (event.type === "close") { + this.#closed = true; + } + + if (this.#receiver) this.#sendToReceiver(this.#receiver, event); + else this.#backlog.push(event); + } + + #sendToReceiver(receiver: (event: WireEvent) => void, event: WireEvent): void { + Promise.resolve(receiver(event)).catch(() => {}); + } +} + +type Listener = (event: any) => void; +type ListenerEntry = { listener: Listener, once: boolean }; + +export class UpgradeWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + #stub: any; + #readyState = UpgradeWebSocket.OPEN; + #listeners: Record = { message: [], close: [], error: [], open: [] }; + + onmessage: Listener | null = null; + onclose: Listener | null = null; + onerror: Listener | null = null; + onopen: Listener | null = null; + + constructor(stub: any) { + // Retain the capability: a stub arriving as a call result is implicitly disposed shortly + // after that call settles, which would sever the upgrade. dup() gives us an owned copy whose + // lifetime we control (released in close()). + this.#stub = stub?.dup ? stub.dup() : stub; + Promise.resolve(this.#stub.start((event: WireEvent) => this.#dispatch(event))) + .catch(err => this.#fire("error", { type: "error", error: err })); + } + + get readyState(): number { return this.#readyState; } + + /** No-op for Workers `WebSocket` API compatibility; the source side already accepted. */ + accept(): void {} + + send(data: string | ArrayBufferLike | ArrayBufferView): void { + this.#ignore(this.#stub.send(normalizeData(data))); + } + + close(code?: number, reason?: string): void { + if (this.#readyState >= UpgradeWebSocket.CLOSING) return; + this.#readyState = UpgradeWebSocket.CLOSING; + this.#ignore(this.#stub.close(code, reason)); + } + + [Symbol.dispose](): void { + this.close(); + this.#release(); + } + + addEventListener(type: string, listener: Listener, options?: { once?: boolean }): void { + (this.#listeners[type] ??= []).push({ listener, once: !!options?.once }); + } + + removeEventListener(type: string, listener: Listener): void { + const list = this.#listeners[type]; + if (!list) return; + const i = list.findIndex(entry => entry.listener === listener); + if (i >= 0) list.splice(i, 1); + } + + #fire(type: string, event: any): void { + for (const entry of [...(this.#listeners[type] ?? [])]) { + if (entry.once) this.removeEventListener(type, entry.listener); + entry.listener(event); + } + const handler = (this as any)["on" + type] as Listener | null; + if (handler) handler(event); + } + + #ignore(result: any): void { + if (result?.then) { + result.then(() => {}, (err: any) => this.#fire("error", { type: "error", error: err })); + } + } + + #release(): void { + this.#stub?.[Symbol.dispose]?.(); + this.#stub = undefined; + } + + #dispatch(event: WireEvent): void { + switch (event.type) { + case "message": + this.#fire("message", { type: "message", data: event.data }); + break; + case "close": + this.#readyState = UpgradeWebSocket.CLOSED; + this.#fire("close", { type: "close", code: event.code, reason: event.reason }); + this.#release(); + break; + case "error": + this.#fire("error", { type: "error" }); + break; + } + } +} diff --git a/vitest.config.ts b/vitest.config.ts index 1e43ca4..1408441 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,7 +17,11 @@ export default defineConfig({ name: 'node', // We throw flow-control test under Node only because it's testing straightforward // JavaScript -- no need to run it on every runtime. - include: ['__tests__/index.test.ts', '__tests__/flow-control.test.ts'], + include: [ + '__tests__/index.test.ts', + '__tests__/flow-control.test.ts', + '__tests__/websocket-serialization.test.ts', + ], environment: 'node', }, }, @@ -108,4 +112,4 @@ export default defineConfig({ }, ], }, -}) \ No newline at end of file +})