UNPKG

@furystack/websocket-api

Version:

WebSocket API implementation for FuryStack

154 lines 8.31 kB
import { getPort } from '@furystack/core/port-generator'; import { createInjector } from '@furystack/inject'; import { HttpAuthenticationSettings, IdentityEventBus, ServerTelemetryToken } from '@furystack/rest-service'; import { usingAsync } from '@furystack/utils'; import { describe, expect, it, vi } from 'vitest'; import { WebSocket } from 'ws'; import { useWebSocketApi } from './websocket-api.js'; describe('useWebSocketApi', () => { it('returns a handle exposing the underlying WebSocketServer', async () => { await usingAsync(createInjector(), async (i) => { const api = await useWebSocketApi({ injector: i, port: getPort() }); expect(api.socket).toBeDefined(); expect(typeof api.broadcast).toBe('function'); expect(api.serverApi.shouldExec).toBeTypeOf('function'); }); }); it('matches the configured path on the serverApi', async () => { await usingAsync(createInjector(), async (i) => { const api = await useWebSocketApi({ injector: i, port: getPort(), path: '/web-socket' }); const req = { url: '/web-socket', headers: { host: 'localhost' } }; const noMatch = { url: '/other', headers: { host: 'localhost' } }; const res = {}; expect(api.serverApi.shouldExec({ req, res })).toBe(true); expect(api.serverApi.shouldExec({ req: noMatch, res })).toBe(false); }); }); it('broadcasts messages to every connected client', async () => { const port = getPort(); await usingAsync(createInjector(), async (i) => { const api = await useWebSocketApi({ injector: i, port, path: '/web-socket' }); const clients = await Promise.all([1, 2, 3, 4, 5].map(async () => { const client = new WebSocket(`ws://localhost:${port}/web-socket`); await new Promise((resolve) => client.once('open', () => resolve())); return client; })); const messagePromises = clients.map((client) => new Promise((resolve) => { client.once('message', (data) => resolve(data.toString())); })); await api.broadcast(({ ws: socket }) => { socket.send('alma'); }); const messages = await Promise.all(messagePromises); for (const msg of messages) { expect(msg).toBe('alma'); } await Promise.all(clients.map(async (client) => { client.close(); await new Promise((resolve) => client.once('close', () => resolve())); })); }); }); it('emits onClientConnected and onClientDisconnected', async () => { const port = getPort(); await usingAsync(createInjector(), async (i) => { const api = await useWebSocketApi({ injector: i, port, path: '/ws-events' }); const connected = vi.fn(); const disconnected = vi.fn(); api.addListener('onClientConnected', connected); api.addListener('onClientDisconnected', disconnected); const client = new WebSocket(`ws://localhost:${port}/ws-events`); await new Promise((resolve) => client.once('open', () => resolve())); expect(connected).toHaveBeenCalled(); expect(connected).toHaveBeenCalledWith(expect.objectContaining({ ws: expect.any(Object), message: expect.any(Object) })); client.close(); await new Promise((resolve) => client.once('close', () => resolve())); await new Promise((resolve) => setTimeout(resolve, 50)); expect(disconnected).toHaveBeenCalled(); }); }); it('forwards action execution errors to ServerTelemetry#onWebSocketActionFailed', async () => { const port = getPort(); await usingAsync(createInjector(), async (i) => { const failingAction = { canExecute: () => true, execute: async () => { throw new Error('action failed'); }, }; await useWebSocketApi({ injector: i, port, path: '/ws-error-test', actions: [failingAction] }); const telemetry = i.get(ServerTelemetryToken); const errorHandler = vi.fn(); telemetry.addListener('onWebSocketActionFailed', errorHandler); const client = new WebSocket(`ws://localhost:${port}/ws-error-test`); await new Promise((resolve) => client.once('open', () => resolve())); await new Promise((resolve, reject) => client.send('trigger', (err) => (err ? reject(err) : resolve()))); await new Promise((resolve) => setTimeout(resolve, 100)); expect(errorHandler).toHaveBeenCalled(); expect(errorHandler).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(Error) })); client.close(); await new Promise((resolve) => client.once('close', () => resolve())); }); }); it('closes connected sockets whose cookie carries an invalidated session id', async () => { const port = getPort(); await usingAsync(createInjector(), async (i) => { const { cookieName } = i.get(HttpAuthenticationSettings); await useWebSocketApi({ injector: i, port, path: '/ws-logout' }); const targetSession = 'session-target'; const otherSession = 'session-other'; const target = new WebSocket(`ws://localhost:${port}/ws-logout`, { headers: { cookie: `${cookieName}=${targetSession}` }, }); const bystander = new WebSocket(`ws://localhost:${port}/ws-logout`, { headers: { cookie: `${cookieName}=${otherSession}` }, }); await Promise.all([ new Promise((resolve) => target.once('open', () => resolve())), new Promise((resolve) => bystander.once('open', () => resolve())), ]); const targetClosed = new Promise((resolve) => target.once('close', (code) => resolve({ code }))); await i.get(IdentityEventBus).publish({ type: 'userLoggedOut', sessionId: targetSession }); const closed = await targetClosed; expect(closed.code).toBe(1008); expect(bystander.readyState).toBe(WebSocket.OPEN); bystander.close(); await new Promise((resolve) => bystander.once('close', () => resolve())); }); }); it('invokes the matched action with a per-connection injector', async () => { const port = getPort(); await usingAsync(createInjector(), async (i) => { const executed = vi.fn(); const action = { canExecute: ({ data }) => { try { // eslint-disable-next-line @typescript-eslint/no-base-to-string const parsed = JSON.parse(data.toString()); return (typeof parsed === 'object' && parsed !== null && 'value' in parsed && parsed.value === 'test-message-unique'); } catch { return false; } }, execute: async ({ socket, injector }) => { executed(injector !== i); socket.send('done'); }, }; await useWebSocketApi({ injector: i, port, path: '/web-socket-test', actions: [action] }); const client = new WebSocket(`ws://localhost:${port}/web-socket-test`); await new Promise((resolve) => client.once('open', () => resolve())); const reply = new Promise((resolve) => client.once('message', () => resolve())); await new Promise((resolve, reject) => client.send(JSON.stringify({ value: 'test-message-unique' }), (err) => (err ? reject(err) : resolve()))); await reply; expect(executed).toHaveBeenCalledWith(true); client.close(); await new Promise((resolve) => client.once('close', () => resolve())); }); }); }); //# sourceMappingURL=websocket-api.spec.js.map