@furystack/websocket-api
Version:
WebSocket API implementation for FuryStack
154 lines • 8.31 kB
JavaScript
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