UNPKG

@furystack/websocket-api

Version:

WebSocket API implementation for FuryStack

159 lines 7.42 kB
import { AggregatedError, IdentityContext } from '@furystack/core'; import { defineService } from '@furystack/inject'; import { extractSessionIdFromCookies, HttpAuthenticationSettings, HttpServerPoolToken, HttpUserContext, IdentityEventBus, ServerTelemetryToken, } from '@furystack/rest-service'; import { EventHub } from '@furystack/utils'; import { URL } from 'url'; import ws, { WebSocketServer } from 'ws'; /** * Scoped registry of per-injector websocket-api cleanup callbacks. Using * a DI-managed set lets `useWebSocketApi` register disposal through the * injector's `onDispose` hook without needing a factory context at the * call site. Callbacks run before the HTTP server pool tears down its * `http.Server` instances because they are registered after pool * resolution — the injector disposes in reverse registration order. */ const WebSocketApiCleanupRegistry = defineService({ name: 'furystack/websocket-api/WebSocketApiCleanupRegistry', lifetime: 'scoped', factory: ({ onDispose }) => { const cleanups = new Set(); onDispose(async () => { // Snapshot + clear first so nested emits during cleanup can't re-enter. const pending = [...cleanups]; cleanups.clear(); await Promise.allSettled(pending.map((cleanup) => cleanup())); }); return cleanups; }, }); /** * Opens a websocket endpoint on the pooled HTTP server identified by * `port` / `hostName`. Returns the {@link WebSocketApi} handle; disposal * is tied to the injector scope that owns it — closing open clients, * tearing down per-connection scopes and closing the `WebSocketServer`. */ export const useWebSocketApi = async (options) => { const { injector, port, hostName, path = '/socket', actions = [] } = options; const telemetry = injector.get(ServerTelemetryToken); const pool = injector.get(HttpServerPoolToken); const cleanups = injector.get(WebSocketApiCleanupRegistry); const authentication = injector.get(HttpAuthenticationSettings); const identityEventBus = injector.get(IdentityEventBus); const socket = new WebSocketServer({ noServer: true }); const clients = new Map(); // Cross-node logout drops every websocket whose connect-time cookie carries // the invalidated session id, on every node — fires for both local and // remote `userLoggedOut` events. const identitySubscription = identityEventBus.subscribe('userLoggedOut', ({ sessionId }) => { for (const client of clients.values()) { if (extractSessionIdFromCookies(client.message, authentication.cookieName) !== sessionId) continue; try { client.ws.close(1008, 'Session invalidated'); } catch (error) { telemetry.emit('onWebSocketActionFailed', { error, socket: client.ws }); } } }); class WebSocketApiHub extends EventHub { } const handle = new WebSocketApiHub(); const execute = async (data, request, connectionInjector, client) => { // Each incoming message runs in its own scope so per-request services // (notably `HttpUserContext` with its cached user) resolve fresh every // time — mirrors the rest-service per-request scope pattern. const messageScope = connectionInjector.createScope({ owner: data }); try { // IdentityContext binding is lazy so actions that never touch // authentication don't force `HttpUserContext` (and its upstream // auth stores) to be configured. messageScope.bind(IdentityContext, () => { const httpUserContext = messageScope.get(HttpUserContext); return { getCurrentUser: () => httpUserContext.getCurrentUser(request), isAuthorized: (...roles) => httpUserContext.isAuthorized(request, ...roles), isAuthenticated: () => httpUserContext.isAuthenticated(request), }; }); const action = actions.find((candidate) => candidate.canExecute({ data, request, socket: client })); if (action) { await action.execute({ data, request, socket: client, injector: messageScope }); } } catch (error) { telemetry.emit('onWebSocketActionFailed', { error, data, socket: client }); } finally { await messageScope[Symbol.asyncDispose]().catch((error) => { telemetry.emit('onWebSocketActionFailed', { error, data, socket: client }); }); } }; socket.on('connection', (client, msg) => { const connectionInjector = injector.createScope({ owner: msg }); clients.set(client, { injector: connectionInjector, message: msg, ws: client }); handle.emit('onClientConnected', { ws: client, message: msg }); client.on('message', (data) => { void execute(data, msg, connectionInjector, client); }); client.on('error', (error) => { telemetry.emit('onWebSocketActionFailed', { error, socket: client }); }); client.on('close', () => { clients.delete(client); connectionInjector[Symbol.asyncDispose]().catch((error) => { telemetry.emit('onWebSocketActionFailed', { error, socket: client }); }); handle.emit('onClientDisconnected', { ws: client }); }); }); const serverApi = { shouldExec: ({ req }) => { const { pathname } = new URL(req.url, `http://${req.headers.host}`); return pathname === path; }, onRequest: async () => { // WebSocket endpoint never serves regular HTTP requests. }, onUpgrade: async ({ req, socket: duplex, head }) => { socket.handleUpgrade(req, duplex, head, (client) => { socket.emit('connection', client, req); }); }, }; const record = await pool.acquire({ port, hostName }); record.apis.push(serverApi); const cleanup = async () => { identitySubscription[Symbol.dispose](); socket.clients.forEach((client) => client.close()); socket.clients.forEach((client) => client.terminate()); await new Promise((resolve, reject) => socket.close((err) => (err ? reject(err) : resolve()))); await Promise.allSettled([...clients.values()].map((client) => client.injector[Symbol.asyncDispose]())); clients.clear(); // eslint-disable-next-line furystack/prefer-using-wrapper handle[Symbol.dispose](); }; cleanups.add(cleanup); return Object.assign(handle, { socket, serverApi, broadcast: async (callback) => { const errors = []; await Promise.all([...clients.values()] .filter((client) => client.ws.readyState === ws.OPEN) .map(async (client) => { try { await callback(client); } catch (error) { errors.push(error); } })); if (errors.length) { throw new AggregatedError('The Broadcast operation encountered some errors', errors); } }, }); }; //# sourceMappingURL=websocket-api.js.map