diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf415dd1..27ed5fbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -419,6 +419,13 @@ importers: version: 4.1.0 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..68170550 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)) { @@ -36,6 +36,16 @@ await main(function* () { }); ``` +By default, teardown waits up to one second for the peer's close handshake. +Configure that deadline when creating the resource if your environment needs a +different shutdown policy: + +```typescript +let socket = yield* useWebSocket("ws://websocket.example.org", { + closeTimeout: 5_000, +}); +``` + ## Features - **Ready-to-use Connections**: `useWebSocket()` returns only after the @@ -46,6 +56,92 @@ 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 hands +back a subscription 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 } from "@effectionx/websocket"; + +await main(function* () { + let connections = yield* useWebSocketServer( + () => new WebSocketServer({ port: 3000 }), + { closeTimeout: 5_000 }, + ); + + // Connections are read one at a time, so spawn a handler per connection to + // serve many clients concurrently. + while (true) { + let { value: connection } = yield* connections.next(); + yield* spawn(function* () { + for (let message of yield* each(connection)) { + yield* connection.send(`echo: ${message.data}`); + 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 from the moment the resource is created, so none are +dropped before you start reading. That is why the server is a subscription rather +than a stream: reading a connection consumes it, and every consumer draws from +the same buffer instead of getting an independent replay. + +The server — and every live connection it produced — is automatically closed when +the resource passes out of scope, with close code `1001` ("going away"). The +server's second argument configures the close-handshake timeout for every +accepted connection. + +### Observing connection failures + +The two kinds of failure are reported differently. An error on the server itself +crashes the resource's scope, reaching your error boundary like any other +failure. An error on a single connection is isolated so it cannot take the server +down, and is published on `server.errors` instead — spawn a task to watch that +stream if you want to see them: + +```typescript +yield* spawn(function* () { + for (let error of yield* each(connections.errors)) { + // a socket failure throws the DOM `error` event, which is not an `Error`; + // effection 4.1+ boxes it and keeps the original on `cause` + console.error("connection failed:", (error as Error)?.cause ?? error); + yield* each.next(); + } +}); +``` + +`errors` is lossy: failures raised while nobody is subscribed are not buffered. + ## 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 77ed750d..4e2f8cb3 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.4", + "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", @@ -15,6 +15,10 @@ } }, "files": ["dist"], + "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..6ec44fb9 --- /dev/null +++ b/websocket/server.test.ts @@ -0,0 +1,405 @@ +import { EventEmitter } from "node:events"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { timebox } from "@effectionx/timebox"; +import { describe, it } from "@effectionx/vitest"; +import { + type Operation, + type Subscription, + createQueue, + ensure, + resource, + scoped, + sleep, + 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 client = yield* connect(port); + let connection = (yield* server.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 client = yield* connect(port); + let connection = (yield* server.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 raw = new WebSocket(`ws://localhost:${port}`); + yield* useWebSocket(() => raw); + let connection = (yield* server.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, + }), + ); + yield* server.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 every live connection 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 }), + ); + yield* server.next(); + yield* server.next(); + accepted.add(); + yield* suspend(); + }); + + let clients = [yield* connect(port), yield* connect(port)]; + let inboxes = [yield* clients[0], yield* clients[1]]; + + // both connections are established, so both belong to the live roster + yield* accepted.next(); + yield* serverTask.halt(); + + for (let inbox of inboxes) { + let event = yield* drain(inbox); + expect(event.code).toEqual(1001); + expect(event.reason).toEqual("server shutting down"); + expect(event.wasClean).toEqual(true); + } + }); + + it("does not complete teardown until the server has finished closing", function* () { + // this server never invokes its close callback until released, standing in + // for one with connections still winding down + let server = makeFakeServer({ deferClose: true }); + let finished = createQueue(); + + yield* spawn(function* () { + yield* scoped(function* () { + yield* useWebSocketServer(() => server); + }); + finished.add(); + }); + + // the scope body is done, so teardown has asked the server to close + yield* sleep(0); + expect(server.closeCalls).toEqual(1); + + // ...but teardown is still pending, because the callback has not fired + let early = yield* timebox(100, () => finished.next()); + expect(early.timeout).toEqual(true); + + server.releaseClose(); + + let late = yield* timebox(1_000, () => finished.next()); + expect(late.timeout).toEqual(false); + }); + + it("bounds teardown when peers never answer the close handshake", function* () { + let server = makeFakeServer(); + let sockets = [makeSilentSocket(), makeSilentSocket(), makeSilentSocket()]; + + let outcome = yield* timebox(2_000, () => + scoped(function* () { + let connections = yield* useWebSocketServer(() => server, { + closeTimeout: 10, + }); + // emit once the accept loop is subscribed, which a real server's I/O + // guarantees but a hand-driven emitter does not + yield* spawn(function* () { + yield* sleep(0); + for (let socket of sockets) { + server.emit("connection", socket); + } + }); + for (let _ of sockets) { + yield* connections.next(); + } + }), + ); + + // leaving the scope closed all three without waiting on a reply that never + // comes, rather than hanging on the first silent peer + expect(outcome.timeout).toEqual(false); + expect(sockets.map((socket) => socket.closeCalls)).toEqual([1, 1, 1]); + // the going-away close won, so the scope-exit 1000 was a no-op + expect(sockets.map((socket) => socket.codes)).toEqual([ + [1001], + [1001], + [1001], + ]); + expect(server.closeCalls).toEqual(1); + }); + + it("closes a connection with an explicit code and reason", function* () { + let { server, port } = yield* useTestServer(); + + let client = yield* connect(port); + let connection = (yield* server.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 reading any connection + let client = yield* connect(port); + + // the connection was buffered before anybody read it + let connection = (yield* server.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); + let serverErrors = yield* server.errors; + + // accept one client, then make its underlying socket error + yield* connect(port); + yield* server.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 + let { value: error } = yield* serverErrors.next(); + expect(socketErrorEvent(error).message).toContain("boom"); + + // and the server survives, serving a fresh client + let client = yield* connect(port); + let connection = (yield* server.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(); + + // connect two clients, then read two buffered connections back out + let clientA = yield* connect(port); + let clientB = yield* connect(port); + + let first = (yield* server.next()).value; + let second = (yield* server.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, + }), + ); + + 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; + + yield* ensure(function* () { + let closed = withResolvers(); + httpServer.close(() => closed.resolve()); + yield* closed.operation; + }); + + yield* provide({ httpServer, port }); + }); +} + +/** + * A server we drive by hand, so a test can hand {@link useWebSocketServer} + * sockets that a real peer would never produce. + */ +function makeFakeServer({ deferClose = false }: { deferClose?: boolean } = {}) { + let pending: (() => void) | undefined; + return Object.assign(new EventEmitter(), { + closeCalls: 0, + close(callback?: () => void) { + this.closeCalls += 1; + if (deferClose) { + pending = callback; + } else { + callback?.(); + } + }, + /** Fire a close callback that `deferClose` withheld. */ + releaseClose() { + pending?.(); + }, + }) as unknown as EventEmitter & + WebSocketServerLike & { closeCalls: number; releaseClose(): void }; +} + +/** + * An accepted socket that is already open and never emits `open`, `close`, or + * `error` — a peer that takes a close frame and never answers it. `close()` + * only takes effect while the socket is open, like a real one, so a second + * close is the no-op the "first close wins" rule depends on. + */ +function makeSilentSocket() { + return { + readyState: WebSocket.OPEN as number, + binaryType: "blob" as BinaryType, + bufferedAmount: 0, + extensions: "", + protocol: "", + url: "ws://silent.test", + closeCalls: 0, + codes: [] as number[], + addEventListener() {}, + removeEventListener() {}, + send() {}, + close(code?: number) { + if (this.readyState !== WebSocket.OPEN) { + return; + } + this.readyState = WebSocket.CLOSING; + this.closeCalls += 1; + this.codes.push(code ?? 1000); + }, + }; +} + +/** + * A socket failure throws the DOM `error` event, which is not an `Error`. + * Effection 4.1 and later box such a value in a `ThrownValueError` that keeps + * the original on `cause`, while earlier versions publish the event itself, and + * this package supports both. + */ +function socketErrorEvent(error: unknown): ErrorEvent { + return ( + error instanceof Error && error.cause ? error.cause : error + ) as ErrorEvent; +} + +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..8f0ca6f0 --- /dev/null +++ b/websocket/server.ts @@ -0,0 +1,197 @@ +import { on, once } from "@effectionx/node/events"; +import type { EventEmitterLike } from "@effectionx/node/events"; +import { + all, + createQueue, + createSignal, + each, + ensure, + resource, + scoped, + spawn, + withResolvers, +} from "effection"; +import type { Operation, Stream, Subscription } from "effection"; + +import { + type UseWebSocketOptions, + 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. A `ws` + * `WebSocketServer` satisfies it structurally, so it can be passed directly + * with no cast. + * + * Shutdown closes accepted sockets with `1001` ("going away"), which the full + * RFC 6455 range allows but the WHATWG API does not, so an implementation whose + * accepted sockets enforce the browser restriction needs a different code. + */ +export interface WebSocketServerLike extends EventEmitterLike { + close(callback?: () => void): void; +} + +/** + * Handle to a WebSocket server, consumed as an Effection + * {@link Subscription} of incoming client connections. Each value is a + * {@link WebSocketResource} representing a single client. + * + * This is deliberately a subscription rather than a {@link Stream}. A stream is + * stateless — subscribing to it is what allocates state — whereas a server + * starts listening and buffering connections the moment the resource is + * created, and every consumer draws from that one shared buffer. Handing back a + * subscription says so in the type: reading a connection consumes it, and there + * is no second independent replay of the connections that already arrived. + * + * 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 Subscription, never> { + /** + * A stream of errors raised by individual connections. A failing connection + * is isolated — it does not crash the server — and whatever it threw is + * published here, so per-connection failures are observed by consuming this + * stream rather than through a callback. It is lossy: errors emitted while + * nobody is subscribed are not buffered. + * + * A socket failure throws the DOM `error` event, which is not an `Error`. + * Effection 4.1 and later box a thrown non-`Error` in a `ThrownValueError` + * whose `message` is the useless `String(event)` but whose `cause` is the + * event itself; earlier versions publish the event directly. Read `cause` + * first and fall back to the value. + */ + errors: Stream; +} + +/** + * Create a WebSocket server resource that hands back a {@link Subscription} 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 from the moment the resource is created, so none are + * dropped before you start reading. Because connections are read one at a time, + * spawn a handler per connection to serve many clients concurrently: + * + * ```ts + * import { each, main, spawn } from "effection"; + * import { WebSocketServer } from "ws"; + * import { useWebSocketServer } from "@effectionx/websocket"; + * + * await main(function* () { + * let connections = yield* useWebSocketServer( + * () => new WebSocketServer({ port: 3000 }), + * ); + * + * while (true) { + * let { value: connection } = yield* connections.next(); + * yield* spawn(function* () { + * for (let message of yield* each(connection)) { + * yield* connection.send(`echo: ${message.data}`); + * yield* each.next(); + * } + * }); + * } + * }); + * ``` + * + * @param create - a function that constructs the underlying server object that + * this resource will manage + * @param options - options applied to every accepted WebSocket connection + * @returns an operation yielding a {@link WebSocketServerResource} + */ +export function useWebSocketServer( + create: () => WebSocketServerLike, + options: UseWebSocketOptions = {}, +): Operation> { + return resource(function* (provide) { + let server = create(); + + // Two collections with different jobs: `accepted` is the delivery buffer, + // drained as the consumer reads, while `live` is the roster of still-open + // connections used to close them on shutdown. A connection sits in both + // until it is read, and stays in `live` long after it has left the buffer. + let accepted = createQueue, never>(); + let live = new Set>(); + let errors = createSignal(); + + // 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; + }); + + // `scoped` contains a crash rather than letting it escalate, so one socket + // erroring is isolated to its own connection and reported on `errors` + // instead of taking down the server. Each 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, options); + live.add(connection); + accepted.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(); + } + }); + + // Registered after the spawns so that it runs before they are halted: the + // going-away close has to land while each connection task is still alive. + yield* ensure(function* () { + // The first close wins, so 1001 takes precedence over the 1000 each + // connection sends as its own scope exits. Snapshot the set, because + // connections remove themselves from it as they close. Closing + // concurrently keeps a silent peer from serializing the whole shutdown + // into one close timeout apiece. + yield* all( + [...live].map((connection) => + connection.close(1001, "server shutting down"), + ), + ); + // Wait for the listening socket to be released, so a resource that binds + // the same port after this one cannot lose the race with EADDRINUSE. + let closed = withResolvers(); + server.close(() => closed.resolve()); + yield* closed.operation; + }); + + // A queue is already a subscription, so it is the handle itself. + yield* provide({ + next: () => accepted.next(), + errors, + }); + }); +} 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 77debcf2..2df7df5a 100644 --- a/websocket/websocket.test.ts +++ b/websocket/websocket.test.ts @@ -1,4 +1,5 @@ import { createServer } from "node:http"; +import { timebox } from "@effectionx/timebox"; import { describe, it } from "@effectionx/vitest"; import { type Operation, @@ -6,6 +7,7 @@ import { createQueue, ensure, resource, + scoped, suspend, useScope, withResolvers, @@ -21,7 +23,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(); @@ -33,7 +35,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(); @@ -63,8 +65,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(); + + let outcome = yield* timebox(100, () => + scoped(function* () { + yield* useWebSocket(() => socket as unknown as WebSocket, { + closeTimeout: 0, + }); + }), + ); + + expect(outcome.timeout).toEqual(false); + 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 76220085..4a4260c0 100644 --- a/websocket/websocket.ts +++ b/websocket/websocket.ts @@ -1,3 +1,4 @@ +import { timebox } from "@effectionx/timebox"; import { createSignal, ensure, @@ -9,6 +10,14 @@ import { } from "effection"; import type { Operation, Stream } from "effection"; +export interface UseWebSocketOptions { + /** + * How many milliseconds to wait for the peer's close handshake before + * allowing teardown to continue. Defaults to `1000`. + */ + closeTimeout?: number; +} + /** * Handle to a * [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object @@ -17,8 +26,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> { @@ -31,7 +42,24 @@ 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. + * + * The code is handed to the underlying socket unchanged, so which codes are + * legal depends on the implementation. The WHATWG API accepts only `1000` and + * `3000`–`4999`, throwing `InvalidAccessError` for anything else, while a + * `ws` socket accepts the full RFC 6455 range — `1001` ("going away") + * included. + * + * @param code - a close code the underlying socket accepts (default `1000`) + * @param reason - a close reason string (default `"released"`) + */ + close(code?: number, reason?: string): Operation; } /** @@ -59,13 +87,16 @@ export interface WebSocketResource * * @param url - The URL of the target WebSocket server to connect to. The URL must use one of the following schemes: ws, wss, http, or https, and cannot include a URL fragment. If a relative URL is provided, it is relative to the base URL of the calling script. For more detail, see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#url * - * @param prototol - A single string or an array of strings representing the sub-protocol(s) that the client would like to use, in order of preference. If it is omitted, an empty array is used by default, i.e. []. For more details, see + * @param protocolsOrOptions - A sub-protocol string, or resource options when + * no sub-protocol is needed + * @param options - Resource options when a sub-protocol is provided * * @returns an operation yielding a {@link WebSocketResource} */ export function useWebSocket( url: string, - protocols?: string, + protocolsOrOptions?: string | UseWebSocketOptions, + options?: UseWebSocketOptions, ): Operation>; /** @@ -96,10 +127,12 @@ export function useWebSocket( * * ``` * @param create - a function that will construct the underlying [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object that this resource wil use + * @param options - Resource options * @returns an operation yielding a {@link WebSocketResource} */ export function useWebSocket( create: () => WebSocket, + options?: UseWebSocketOptions, ): Operation>; /** @@ -107,9 +140,17 @@ export function useWebSocket( */ export function useWebSocket( url: string | (() => WebSocket), - protocols?: string, + protocolsOrOptions?: string | UseWebSocketOptions, + additionalOptions: UseWebSocketOptions = {}, ): Operation> { return resource(function* (provide) { + let protocols = + typeof protocolsOrOptions === "string" ? protocolsOrOptions : undefined; + let options = + typeof protocolsOrOptions === "object" + ? protocolsOrOptions + : additionalOptions; + let { closeTimeout = 1000 } = options; let socket = typeof url === "string" ? new WebSocket(url, protocols) : url(); @@ -134,11 +175,18 @@ export function useWebSocket( close(next.value); }); + // The first close wins, so whoever calls this first picks the code the peer + // sees. On timeout we stop waiting rather than forcing a terminate. + function* closeSocket(code: number, reason: string): Operation { + socket.close(code, reason); + yield* timebox(closeTimeout, () => closed); + } + // Don't hoist this above the spawns — teardown would hang waiting on // `closed`. yield* ensure(function* () { - socket.close(1000, "released"); - yield* closed; + // A no-op if the caller already closed with an explicit code via `close()`. + yield* closeSocket(1000, "released"); socket.removeEventListener("message", messages.send); socket.removeEventListener("close", messages.close); }); @@ -167,7 +215,12 @@ 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], }), ]);