diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb995c29..4b694c2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -420,6 +420,13 @@ importers: version: 4.0.2 websocket: + dependencies: + '@effectionx/node': + specifier: workspace:* + version: link:../node + '@effectionx/timebox': + specifier: workspace:* + version: link:../timebox devDependencies: '@effectionx/vitest': specifier: workspace:* diff --git a/websocket/README.md b/websocket/README.md index e4b6863e..37768b27 100644 --- a/websocket/README.md +++ b/websocket/README.md @@ -26,7 +26,7 @@ await main(function* () { let socket = yield* useWebSocket("ws://websocket.example.org"); // Send messages to the server - socket.send("Hello World"); + yield* socket.send("Hello World"); // Receive messages using a simple iterator for (let message of yield* each(socket)) { @@ -46,6 +46,68 @@ await main(function* () { - **Clean Resource Management**: Connections are properly cleaned up when the operation completes +## WebSocket Server + +`useWebSocketServer()` is the server counterpart of `useWebSocket()`. It yields a +stream of incoming connections, where **each connection is the same full-duplex +`WebSocketResource`** produced by the client — you receive messages by iterating +it and reply with `yield* connection.send()`. + +The underlying server is supplied through a factory, so this package never +imports a concrete server implementation and stays platform-agnostic. On Node +this is typically the [`ws`](https://github.com/websockets/ws) `WebSocketServer`. + +```typescript +import { each, main, spawn } from "effection"; +import { WebSocketServer } from "ws"; +import { + useWebSocketServer, + type WebSocketServerLike, +} from "@effectionx/websocket"; + +await main(function* () { + let server = yield* useWebSocketServer( + () => new WebSocketServer({ port: 3000 }) as unknown as WebSocketServerLike, + ); + + // A stream is consumed sequentially, so spawn a handler per connection to + // serve many clients concurrently. + for (let connection of yield* each(server)) { + yield* spawn(function* () { + for (let message of yield* each(connection)) { + yield* connection.send(`echo: ${message.data}`); + yield* each.next(); + } + }); + yield* each.next(); + } +}); +``` + +A client — using `useWebSocket()` from the same package — pairs with it directly. +Because `send` is an `Operation`, invoke it with `yield*` on both sides: + +```typescript +import { each, main } from "effection"; +import { useWebSocket } from "@effectionx/websocket"; + +await main(function* () { + let socket = yield* useWebSocket("ws://localhost:3000"); + + yield* socket.send("hello"); // client -> server + + for (let message of yield* each(socket)) { + console.log(message.data); // "echo: hello" (server -> client) + yield* each.next(); + } +}); +``` + +Connections are buffered, so none are dropped between the moment the server +starts listening and the moment you begin iterating. The server — and every live +connection it produced — is automatically closed when the resource passes out of +scope. + ## Advanced Usage ### Custom WebSocket Implementations diff --git a/websocket/mod.ts b/websocket/mod.ts index 5adfd3a9..6b8974fe 100644 --- a/websocket/mod.ts +++ b/websocket/mod.ts @@ -1 +1,2 @@ export * from "./websocket.ts"; +export * from "./server.ts"; diff --git a/websocket/package.json b/websocket/package.json index 1617f317..7436b4a0 100644 --- a/websocket/package.json +++ b/websocket/package.json @@ -1,7 +1,7 @@ { "name": "@effectionx/websocket", - "description": "WebSocket client with stream-based message handling and automatic cleanup", - "version": "2.3.3", + "description": "WebSocket client and server with stream-based message handling and automatic cleanup", + "version": "3.0.0", "keywords": ["io", "streams"], "type": "module", "main": "./dist/mod.js", @@ -14,6 +14,10 @@ "default": "./dist/mod.js" } }, + "dependencies": { + "@effectionx/node": "workspace:*", + "@effectionx/timebox": "workspace:*" + }, "peerDependencies": { "effection": "^3 || ^4" }, diff --git a/websocket/server.test.ts b/websocket/server.test.ts new file mode 100644 index 00000000..254303b6 --- /dev/null +++ b/websocket/server.test.ts @@ -0,0 +1,254 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { describe, it } from "@effectionx/vitest"; +import { + type Operation, + type Subscription, + createQueue, + resource, + spawn, + suspend, + withResolvers, +} from "effection"; +import { expect } from "expect"; +import { WebSocketServer, type WebSocket as WsWebSocket } from "ws"; + +import { + type WebSocketServerLike, + type WebSocketServerResource, + useWebSocketServer, +} from "./server.ts"; +import { type WebSocketResource, useWebSocket } from "./websocket.ts"; + +describe("WebSocketServer", () => { + it("yields a connection and receives a message from the client", function* () { + let { server, port } = yield* useTestServer(); + let incoming = yield* server; + + let client = yield* connect(port); + let connection = (yield* incoming.next()).value; + + let messages = yield* connection; + yield* client.send("hello from client"); + + let { value } = yield* messages.next(); + expect(value).toMatchObject({ data: "hello from client" }); + }); + + it("sends a message from a server connection to the client", function* () { + let { server, port } = yield* useTestServer(); + let incoming = yield* server; + + let client = yield* connect(port); + let connection = (yield* incoming.next()).value; + + let clientMessages = yield* client; + yield* connection.send("hello from server"); + + let { value } = yield* clientMessages.next(); + expect(value).toMatchObject({ data: "hello from server" }); + }); + + it("completes a connection stream when its client disconnects", function* () { + let { server, port } = yield* useTestServer(); + let incoming = yield* server; + + let raw = new WebSocket(`ws://localhost:${port}`); + yield* useWebSocket(() => raw); + let connection = (yield* incoming.next()).value; + let messages = yield* connection; + + raw.close(4001, "goodbye"); + + let event = yield* drain(messages); + expect(event.type).toEqual("close"); + expect(event.wasClean).toEqual(true); + expect(event.code).toEqual(4001); + expect(event.reason).toEqual("goodbye"); + }); + + it("closes live client connections when the server is torn down", function* () { + let { httpServer, port } = yield* useHttp(); + let accepted = createQueue(); + + let serverTask = yield* spawn(function* () { + let server = yield* useWebSocketServer( + () => + new WebSocketServer({ + server: httpServer, + }) as unknown as WebSocketServerLike, + ); + let incoming = yield* server; + yield* incoming.next(); + accepted.add(); + yield* suspend(); + }); + + let client = yield* connect(port); + let messages = yield* client; + + // wait until the server has accepted the connection before tearing it down + yield* accepted.next(); + yield* serverTask.halt(); + + let event = yield* drain(messages); + expect(event.type).toEqual("close"); + expect(event.wasClean).toEqual(true); + expect(event.code).toEqual(1001); + expect(event.reason).toEqual("server shutting down"); + }); + + it("closes a connection with an explicit code and reason", function* () { + let { server, port } = yield* useTestServer(); + let incoming = yield* server; + + let client = yield* connect(port); + let connection = (yield* incoming.next()).value; + let clientMessages = yield* client; + + yield* connection.close(4002, "custom"); + + let event = yield* drain(clientMessages); + expect(event.code).toEqual(4002); + expect(event.reason).toEqual("custom"); + }); + + it("buffers a connection that arrives before it is consumed", function* () { + let { server, port } = yield* useTestServer(); + + // connect the client before subscribing to the server stream + let client = yield* connect(port); + let incoming = yield* server; + + // the connection was buffered while nobody was subscribed + let connection = (yield* incoming.next()).value; + + let messages = yield* connection; + yield* client.send("buffered hello"); + + let { value } = yield* messages.next(); + expect(value).toMatchObject({ data: "buffered hello" }); + }); + + it("isolates a connection error so the server keeps serving other clients", function* () { + let { httpServer, port } = yield* useHttp(); + + // capture the raw accepted sockets so we can force an error on one + let wss = new WebSocketServer({ server: httpServer }); + let rawSockets = createQueue(); + wss.on("connection", (ws) => rawSockets.add(ws)); + + let server = yield* useWebSocketServer( + () => wss as unknown as WebSocketServerLike, + ); + let incoming = yield* server; + let serverErrors = yield* server.errors; + + // accept one client, then make its underlying socket error + yield* connect(port); + yield* incoming.next(); + let raw = (yield* rawSockets.next()).value; + raw.emit("error", new Error("boom")); + + // the failure is surfaced on the errors stream, not thrown at the server. + // A socket failure surfaces as the DOM `error` event, whose message is the + // underlying error's message. + let { value: error } = yield* serverErrors.next(); + expect((error as ErrorEvent).message).toContain("boom"); + + // and the server survives, serving a fresh client + let client = yield* connect(port); + let connection = (yield* incoming.next()).value; + let messages = yield* connection; + yield* client.send("still alive"); + let { value } = yield* messages.next(); + expect(value).toMatchObject({ data: "still alive" }); + }); + + it("surfaces each simultaneous client as a distinct connection", function* () { + let { server, port } = yield* useTestServer(); + let incoming = yield* server; + + // connect two clients, then read two buffered connections back out + let clientA = yield* connect(port); + let clientB = yield* connect(port); + + let first = (yield* incoming.next()).value; + let second = (yield* incoming.next()).value; + + expect(first).not.toBe(second); + + // each connection receives only its own client's message, regardless of order + let firstMessages = yield* first; + let secondMessages = yield* second; + + yield* clientA.send("A"); + yield* clientB.send("B"); + + let received = [ + ((yield* firstMessages.next()).value as MessageEvent).data, + ((yield* secondMessages.next()).value as MessageEvent).data, + ].sort(); + + expect(received).toEqual(["A", "B"]); + }); +}); + +interface TestServer { + server: WebSocketServerResource; + port: number; +} + +function useTestServer(): Operation { + return resource(function* (provide) { + let { httpServer, port } = yield* useHttp(); + + let server = yield* useWebSocketServer( + () => + new WebSocketServer({ + server: httpServer, + }) as unknown as WebSocketServerLike, + ); + + yield* provide({ server, port }); + }); +} + +function useHttp(): Operation<{ + httpServer: ReturnType; + port: number; +}> { + return resource(function* (provide) { + let httpServer = createServer(); + + let listening = withResolvers(); + httpServer.listen(0, listening.resolve); + yield* listening.operation; + + let port = (httpServer.address() as AddressInfo).port; + + try { + yield* provide({ httpServer, port }); + } finally { + let closed = withResolvers(); + httpServer.close(() => closed.resolve()); + yield* closed.operation; + } + }); +} + +function* connect(port: number): Operation> { + return yield* useWebSocket( + () => new WebSocket(`ws://localhost:${port}`), + ); +} + +function* drain( + subscription: Subscription, +): Operation { + let next = yield* subscription.next(); + while (!next.done) { + next = yield* subscription.next(); + } + return next.value; +} diff --git a/websocket/server.ts b/websocket/server.ts new file mode 100644 index 00000000..63e22b8f --- /dev/null +++ b/websocket/server.ts @@ -0,0 +1,165 @@ +import { + createQueue, + createSignal, + each, + resource, + scoped, + spawn, +} from "effection"; +import type { Operation, Stream } from "effection"; +import { on, once } from "@effectionx/node"; +import type { EventEmitterLike } from "@effectionx/node"; + +import { type WebSocketResource, useWebSocket } from "./websocket.ts"; + +/** + * The minimal structural surface of a + * [`ws`](https://github.com/websockets/ws) `WebSocketServer` (or any + * compatible server) that {@link useWebSocketServer} needs: an + * {@link EventEmitterLike} that emits `connection` and `error` events, plus a + * `close` method. + * + * This is intentionally narrow so that the package never has to import a + * concrete server implementation and stays platform-agnostic. Because the + * `connection` event of the `ws` library yields its own `WebSocket` type + * rather than the DOM `WebSocket`, you may need to cast when passing a real + * server, e.g. `new WebSocketServer({ port }) as unknown as WebSocketServerLike` + * — mirroring the `ws as unknown as WebSocket` cast used with the client. + */ +export interface WebSocketServerLike extends EventEmitterLike { + close(callback?: () => void): void; +} + +/** + * Handle to a WebSocket server consumed as an Effection {@link Stream}. Each + * value in the stream is a {@link WebSocketResource} representing a single + * client connection. + * + * A `WebSocketServerResource` has no explicit close method. The underlying + * server — and every live connection it produced — is automatically closed + * when the resource passes out of scope. + */ +export interface WebSocketServerResource + extends Stream, never> { + /** + * A stream of errors raised by individual connections. A failing connection + * is isolated — it does not crash the server — and whatever it threw (for a + * socket failure, the DOM `error` event) is published here so you can observe + * per-connection failures by consuming this stream (rather than via a + * callback). It is lossy: errors emitted while nobody is subscribed are not + * buffered. + */ + errors: Stream; +} + +/** + * Create a WebSocket server resource that yields a {@link Stream} of incoming + * client connections. Each connection is a {@link WebSocketResource} — the very + * same full-duplex handle produced by {@link useWebSocket} on the client — so + * you receive messages by iterating it and reply with `yield* connection.send()`. + * + * The creation of the underlying server is delegated to a factory function, + * keeping this package free of any concrete server dependency. On Node this is + * typically the [`ws`](https://github.com/websockets/ws) `WebSocketServer`. + * + * Connections are buffered, so none are dropped between the moment the server + * starts listening and the moment you begin iterating. Since a stream is + * consumed sequentially, spawn a handler per connection to serve many clients + * concurrently: + * + * ```ts + * import { each, main, spawn } from "effection"; + * import { WebSocketServer } from "ws"; + * import { useWebSocketServer, type WebSocketServerLike } from "@effectionx/websocket"; + * + * await main(function* () { + * let server = yield* useWebSocketServer( + * () => new WebSocketServer({ port: 3000 }) as unknown as WebSocketServerLike, + * ); + * + * for (let connection of yield* each(server)) { + * yield* spawn(function* () { + * for (let message of yield* each(connection)) { + * yield* connection.send(`echo: ${message.data}`); + * yield* each.next(); + * } + * }); + * yield* each.next(); + * } + * }); + * ``` + * + * @param create - a function that constructs the underlying server object that + * this resource will manage + * @returns an operation yielding a {@link WebSocketServerResource} + */ +export function useWebSocketServer( + create: () => WebSocketServerLike, +): Operation> { + return resource(function* (provide) { + let server = create(); + + let connections = createQueue, never>(); + let errors = createSignal(); + let live = new Set>(); + + // crash the resource scope if the server itself errors, mirroring the + // client's `throw yield* once(socket, "error")` behavior + yield* spawn(function* () { + let [error] = yield* once<[Error]>(server, "error"); + throw error; + }); + + // accept connections. Each is handled in its own task wrapped in `scoped`, + // which is a real error boundary (its trap/delimiter contains a crash) — so + // a single socket erroring is isolated to that connection and published on + // `errors` instead of taking down the server. The connection is held open + // until its socket closes. + yield* spawn(function* () { + for (let [raw] of yield* each(on<[WebSocket]>(server, "connection"))) { + yield* spawn(function* () { + try { + yield* scoped(function* () { + let connection = yield* useWebSocket(() => raw); + live.add(connection); + connections.add(connection); + try { + // stay alive until the socket closes + let subscription = yield* connection; + let next = yield* subscription.next(); + while (!next.done) { + next = yield* subscription.next(); + } + } finally { + live.delete(connection); + } + }); + } catch (error) { + errors.send(error); + } + }); + yield* each.next(); + } + }); + + try { + // a queue is itself a subscription; expose it as a stream whose + // subscription is the shared connection queue + yield* provide({ + *[Symbol.iterator]() { + return connections; + }, + errors, + }); + } finally { + // Compose a going-away shutdown: close live connections with 1001 before + // releasing. The first close wins, so this takes precedence over each + // connection's scope-exit close (1000) as its task tears down. Snapshot + // the set because connections delete themselves from it as they close. + for (let connection of [...live]) { + yield* connection.close(1001, "server shutting down"); + } + server.close(); + } + }); +} diff --git a/websocket/tsconfig.json b/websocket/tsconfig.json index 49b10377..f74cfd60 100644 --- a/websocket/tsconfig.json +++ b/websocket/tsconfig.json @@ -7,6 +7,12 @@ "include": ["**/*.ts"], "exclude": ["**/*.test.ts", "dist"], "references": [ + { + "path": "../node" + }, + { + "path": "../timebox" + }, { "path": "../vitest" } diff --git a/websocket/websocket.test.ts b/websocket/websocket.test.ts index 6af4d245..d860ce86 100644 --- a/websocket/websocket.test.ts +++ b/websocket/websocket.test.ts @@ -5,6 +5,7 @@ import { type Subscription, createQueue, resource, + scoped, suspend, useScope, withResolvers, @@ -20,7 +21,7 @@ describe("WebSocket", () => { let subscription = yield* server.socket; - client.socket.send("hello from client"); + yield* client.socket.send("hello from client"); let { value } = yield* subscription.next(); @@ -32,7 +33,7 @@ describe("WebSocket", () => { let subscription = yield* client.socket; - server.socket.send("hello from server"); + yield* server.socket.send("hello from server"); let { value } = yield* subscription.next(); @@ -62,8 +63,48 @@ describe("WebSocket", () => { expect(event.type).toEqual("close"); expect(event.wasClean).toEqual(true); }); + + it("does not hang teardown when the peer never sends a close frame", function* () { + let socket = makeSilentSocket(); + + // `scoped` runs the body in a child scope and tears it down when the body + // returns — releasing the connection. Even though the socket never emits + // "close", the release must complete (bounded by the close timeout) rather + // than hang forever. + yield* scoped(function* () { + yield* useWebSocket(() => socket as unknown as WebSocket); + }); + + // reaching here means teardown completed; it also attempted the close + expect(socket.closeCalls).toEqual(1); + }); }); +/** + * A minimal WebSocket stand-in that is already OPEN and never emits an "open", + * "close", or "error" event — used to prove teardown does not hang on a silent + * peer. + */ +function makeSilentSocket() { + return { + readyState: WebSocket.OPEN, + binaryType: "blob" as BinaryType, + bufferedAmount: 0, + extensions: "", + protocol: "", + url: "ws://silent.test", + closeCalls: 0, + // record listeners but never fire any event + addEventListener() {}, + removeEventListener() {}, + send() {}, + close() { + // deliberately never fires a "close" event + this.closeCalls += 1; + }, + }; +} + interface TestSocket { close(): void; socket: WebSocketResource; diff --git a/websocket/websocket.ts b/websocket/websocket.ts index fbbfa372..faf29947 100644 --- a/websocket/websocket.ts +++ b/websocket/websocket.ts @@ -7,6 +7,13 @@ import { withResolvers, } from "effection"; import type { Operation, Stream } from "effection"; +import { timebox } from "@effectionx/timebox"; + +/** + * How long to wait for the peer's `close` handshake when a socket is released + * before giving up and moving on, so a silent peer can never hang teardown. + */ +const CLOSE_TIMEOUT_MS = 1000; /** * Handle to a @@ -16,8 +23,10 @@ import type { Operation, Stream } from "effection"; * itself is a subscribale stream. When the socket is closed, the stream will * complete with a [`CloseEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent) * - * A WebSocketResource does not have an explicit close method. Rather, the underlying - * socket will be automatically closed when the resource passes out of scope. + * The underlying socket is automatically closed when the resource passes out of + * scope (with code `1000` and reason `"released"`). For a different close code + * or reason — e.g. a `1001` "going away" on server shutdown — compose an + * explicit {@link WebSocketResource.close} before the resource is released. */ export interface WebSocketResource extends Stream, CloseEvent> { @@ -30,7 +39,18 @@ export interface WebSocketResource readonly protocol: string; readonly readyState: number; readonly url: string; - send(data: WebSocketData): void; + send(data: WebSocketData): Operation; + /** + * Close the socket with an explicit code and reason, resolving once the close + * handshake completes (bounded by an internal timeout so a silent peer cannot + * hang). Because the first close wins, calling this before the resource is + * released lets you choose the close code the peer observes; the automatic + * scope-exit close then becomes a no-op. + * + * @param code - a valid WebSocket close code (default `1000`) + * @param reason - a close reason string (default `"released"`) + */ + close(code?: number, reason?: string): Operation; } /** @@ -133,6 +153,13 @@ export function useWebSocket( close(next.value); }); + // Close the socket and wait (bounded) for the close handshake. The first + // close wins, so whoever calls this first picks the code the peer sees. + function* closeSocket(code: number, reason: string): Operation { + socket.close(code, reason); + yield* timebox(CLOSE_TIMEOUT_MS, () => closed); + } + try { socket.addEventListener("message", messages.send); socket.addEventListener("close", messages.close); @@ -158,13 +185,19 @@ export function useWebSocket( get url() { return socket.url; }, - send: (data) => socket.send(data), + *send(data: WebSocketData): Operation { + socket.send(data); + }, + *close(code = 1000, reason = "released"): Operation { + yield* closeSocket(code, reason); + }, [Symbol.iterator]: messages[Symbol.iterator], }), ]); } finally { - socket.close(1000, "released"); - yield* closed; + // Default scope-exit close; a no-op if the caller already closed with an + // explicit code via `close()`. Bounded so a silent peer can't hang. + yield* closeSocket(1000, "released"); socket.removeEventListener("message", messages.send); socket.removeEventListener("close", messages.close); }