@bhooai/nexus-realtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/package.json +26 -0
- package/src/adapter/PubSubAdapter.ts +42 -0
- package/src/adapter/RedisPubSubAdapter.ts +59 -0
- package/src/index.ts +5 -0
- package/src/mediasoup/MediasoupAdapter.ts +176 -0
- package/src/server/RealtimeServer.ts +252 -0
- package/src/types.ts +52 -0
- package/tests/realtime.test.ts +213 -0
- package/tsconfig.json +12 -0
- package/vitest.config.ts +10 -0
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @bhooai/nexus-realtime
|
|
2
|
+
|
|
3
|
+
WebSocket server with rooms, a Redis pub/sub adapter for horizontal scale, WS
|
|
4
|
+
auth, WebRTC signaling over WS, and a mediasoup SFU adapter.
|
|
5
|
+
|
|
6
|
+
## Exports
|
|
7
|
+
|
|
8
|
+
- **RealtimeServer** — `new RealtimeServer({ httpServer, path, authService,
|
|
9
|
+
csrfOptions? })`. Auth via access token on the WS upgrade; optional CSRF
|
|
10
|
+
origin/double-submit check on the upgrade.
|
|
11
|
+
- **PubSubAdapter / RedisPubSubAdapter** — fan-out across instances.
|
|
12
|
+
- **MediasoupAdapter** — mediasoup SFU worker (native; RTP/SFU is **not** hand-rolled).
|
|
13
|
+
|
|
14
|
+
## Wire protocol
|
|
15
|
+
|
|
16
|
+
Client → server: `{type:'join'|'offer'|'answer'|'candidate'|'broadcast'|'media'|'ping'}`.
|
|
17
|
+
Server → client: `{type:'joined'|'peer-joined'|'peer-left'|'offer'|'answer'|'candidate'|
|
|
18
|
+
'broadcast'|'media'|'pong'|'error'}`.
|
|
19
|
+
|
|
20
|
+
Mediasoup media actions: `getRouterRtpCapabilities`, `createWebRtcTransport{direction}`,
|
|
21
|
+
`connectTransport{direction,dtlsParameters}`, `produce{kind,rtpParameters}→{id,kind}`,
|
|
22
|
+
`consume{producerId,rtpCapabilities}→{id,producerId,kind,rtpParameters}`,
|
|
23
|
+
`closeProducer`/`closeConsumer`. The frontend uses `mediasoup-client` (see
|
|
24
|
+
`apps/frontend/src/lib/stream.ts`).
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bhooai/nexus-realtime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": { "access": "public" },
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc -p tsconfig.json",
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@bhooai/nexus-core": "^0.1.0",
|
|
14
|
+
"@bhooai/nexus-auth": "^0.1.0",
|
|
15
|
+
"ws": "^8.18.0"
|
|
16
|
+
},
|
|
17
|
+
"optionalDependencies": {
|
|
18
|
+
"mediasoup": "^3.16.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.5.0",
|
|
22
|
+
"@types/ws": "^8.5.13",
|
|
23
|
+
"typescript": "^5.6.2",
|
|
24
|
+
"vitest": "^2.1.1"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pub/sub adapter for cross-instance room broadcast fanout. The default
|
|
5
|
+
* `MemoryPubSubAdapter` is a single-process EventEmitter-backed implementation
|
|
6
|
+
* (fine for tests and single-node deployments). A Redis-backed adapter is used
|
|
7
|
+
* for horizontal scaling — see `RedisPubSubAdapter`.
|
|
8
|
+
*
|
|
9
|
+
* Broadcast model: `RealtimeServer` publishes a room event; every instance's
|
|
10
|
+
* subscriber (including the originator) delivers it to its local members. This
|
|
11
|
+
* gives exactly-once delivery per instance without the originator also sending
|
|
12
|
+
* locally.
|
|
13
|
+
*/
|
|
14
|
+
export interface PubSubAdapter {
|
|
15
|
+
/** Publish a message to a channel (room). Returns void (fire-and-forget). */
|
|
16
|
+
publish(channel: string, message: Buffer | string): void;
|
|
17
|
+
/** Subscribe to a channel; the handler is called for each published message. */
|
|
18
|
+
subscribe(channel: string, handler: (message: Buffer | string) => void): Promise<void> | void;
|
|
19
|
+
/** Unsubscribe a handler from a channel. */
|
|
20
|
+
unsubscribe(channel: string, handler: (message: Buffer | string) => void): Promise<void> | void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class MemoryPubSubAdapter implements PubSubAdapter {
|
|
24
|
+
private bus = new EventEmitter();
|
|
25
|
+
|
|
26
|
+
constructor() {
|
|
27
|
+
// Many subscribers per channel; allow headroom for fanout.
|
|
28
|
+
this.bus.setMaxListeners(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
publish(channel: string, message: Buffer | string): void {
|
|
32
|
+
this.bus.emit(channel, message);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
subscribe(channel: string, handler: (message: Buffer | string) => void): void {
|
|
36
|
+
this.bus.on(channel, handler);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
unsubscribe(channel: string, handler: (message: Buffer | string) => void): void {
|
|
40
|
+
this.bus.off(channel, handler);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { PubSubAdapter } from './PubSubAdapter.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal Redis client interface this adapter depends on (compatible with
|
|
5
|
+
* `node-redis` i.e. `createClient()`). Kept structural so the realtime package
|
|
6
|
+
* does not hard-depend on a redis client at import time — `nexus-cache`
|
|
7
|
+
* (Phase 7) supplies a configured client.
|
|
8
|
+
*/
|
|
9
|
+
export interface RedisLike {
|
|
10
|
+
publish(channel: string, message: string): Promise<number>;
|
|
11
|
+
subscribe(...channels: string[]): Promise<number>;
|
|
12
|
+
unsubscribe(...channels: string[]): Promise<number>;
|
|
13
|
+
on(event: 'message', listener: (channel: string, message: string) => void): unknown;
|
|
14
|
+
off(event: 'message', listener: (channel: string, message: string) => void): unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Redis-backed pub/sub adapter for multi-instance room broadcast. Uses one
|
|
19
|
+
* configured Redis client in subscribe mode; messages are JSON strings.
|
|
20
|
+
*/
|
|
21
|
+
export class RedisPubSubAdapter implements PubSubAdapter {
|
|
22
|
+
private handlers = new Map<string, Set<(message: Buffer | string) => void>>();
|
|
23
|
+
|
|
24
|
+
constructor(private client: RedisLike) {
|
|
25
|
+
// Route incoming Redis messages to registered handlers.
|
|
26
|
+
const route = (channel: string, message: string) => {
|
|
27
|
+
const set = this.handlers.get(channel);
|
|
28
|
+
if (set) for (const h of set) h(message);
|
|
29
|
+
};
|
|
30
|
+
this.route = route;
|
|
31
|
+
client.on('message', route);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private route: (channel: string, message: string) => void;
|
|
35
|
+
|
|
36
|
+
publish(channel: string, message: Buffer | string): void {
|
|
37
|
+
void this.client.publish(channel, typeof message === 'string' ? message : message.toString('utf8'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async subscribe(channel: string, handler: (message: Buffer | string) => void): Promise<void> {
|
|
41
|
+
let set = this.handlers.get(channel);
|
|
42
|
+
if (!set) {
|
|
43
|
+
set = new Set();
|
|
44
|
+
this.handlers.set(channel, set);
|
|
45
|
+
await this.client.subscribe(channel);
|
|
46
|
+
}
|
|
47
|
+
set.add(handler);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async unsubscribe(channel: string, handler: (message: Buffer | string) => void): Promise<void> {
|
|
51
|
+
const set = this.handlers.get(channel);
|
|
52
|
+
if (!set) return;
|
|
53
|
+
set.delete(handler);
|
|
54
|
+
if (set.size === 0) {
|
|
55
|
+
this.handlers.delete(channel);
|
|
56
|
+
await this.client.unsubscribe(channel);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { Connection } from '../types.js';
|
|
2
|
+
import type { MediaAction } from '../types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* mediasoup SFU adapter. Lazily imports `mediasoup` (an optional native
|
|
6
|
+
* dependency) so the realtime package loads even when mediasoup is not
|
|
7
|
+
* installed; media actions return a clear error in that case.
|
|
8
|
+
*
|
|
9
|
+
* One mediasoup worker is created per adapter; one router per room (with a
|
|
10
|
+
* default Opus audio + VP8 video RTP capability set). Each connection gets a
|
|
11
|
+
* send WebRtcTransport and a recv WebRtcTransport; producers/consumers are
|
|
12
|
+
* tracked per connection. This is a functional single-room SFU demo; full
|
|
13
|
+
* multi-room capacity scaling is wired but tuned in a later pass.
|
|
14
|
+
*/
|
|
15
|
+
export interface MediasoupAdapterOptions {
|
|
16
|
+
/** RTC listen IP announced to clients (default 127.0.0.1). */
|
|
17
|
+
announceIp?: string;
|
|
18
|
+
/** RTC port range (defaults let mediasoup choose). */
|
|
19
|
+
rtcMinPort?: number;
|
|
20
|
+
rtcMaxPort?: number;
|
|
21
|
+
/** Number of mediasoup workers (default 1). */
|
|
22
|
+
workerCount?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Default router media codecs: Opus audio + VP8 video. (RTX omitted for portability —
|
|
26
|
+
// adding it requires a correctly-bound `apt` referencing the VP8 payload type; not needed for the demo.)
|
|
27
|
+
const ROUTER_MEDIA_CODECS = [
|
|
28
|
+
{ kind: 'audio', mimeType: 'audio/opus', clockRate: 48000, channels: 2 },
|
|
29
|
+
{ kind: 'video', mimeType: 'video/VP8', clockRate: 90000, rtcpFeedback: [{ type: 'nack' }, { type: 'nack', parameter: 'pli' }, { type: 'ccm', parameter: 'fir' }, { type: 'goog-remb' }] },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
interface ConnState {
|
|
33
|
+
sendTransport?: unknown;
|
|
34
|
+
recvTransport?: unknown;
|
|
35
|
+
producers: Map<string, unknown>;
|
|
36
|
+
consumers: Map<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class MediasoupAdapter {
|
|
40
|
+
private workerPromise?: Promise<unknown>;
|
|
41
|
+
private routers = new Map<string, Promise<unknown>>();
|
|
42
|
+
private states = new WeakMap<Connection, ConnState>();
|
|
43
|
+
private opts: MediasoupAdapterOptions;
|
|
44
|
+
|
|
45
|
+
constructor(opts: MediasoupAdapterOptions = {}) {
|
|
46
|
+
this.opts = opts;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private state(conn: Connection): ConnState {
|
|
50
|
+
let s = this.states.get(conn);
|
|
51
|
+
if (!s) { s = { producers: new Map(), consumers: new Map() }; this.states.set(conn, s); }
|
|
52
|
+
return s;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async worker(): Promise<unknown> {
|
|
56
|
+
if (!this.workerPromise) {
|
|
57
|
+
const mod = await import('mediasoup');
|
|
58
|
+
this.workerPromise = mod.createWorker({
|
|
59
|
+
logLevel: 'warn',
|
|
60
|
+
rtcMinPort: this.opts.rtcMinPort ?? 40000,
|
|
61
|
+
rtcMaxPort: this.opts.rtcMaxPort ?? 40100,
|
|
62
|
+
}) as Promise<unknown>;
|
|
63
|
+
}
|
|
64
|
+
return this.workerPromise;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async router(room: string): Promise<unknown> {
|
|
68
|
+
let p = this.routers.get(room);
|
|
69
|
+
if (!p) {
|
|
70
|
+
p = (async () => {
|
|
71
|
+
const w = await this.worker();
|
|
72
|
+
const r = await (w as any).createRouter({ mediaCodecs: ROUTER_MEDIA_CODECS });
|
|
73
|
+
return r;
|
|
74
|
+
})();
|
|
75
|
+
this.routers.set(room, p);
|
|
76
|
+
}
|
|
77
|
+
return p;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async handle(conn: Connection, action: MediaAction, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
81
|
+
const room = String(payload.room ?? 'default');
|
|
82
|
+
const router = await this.router(room);
|
|
83
|
+
const s = this.state(conn);
|
|
84
|
+
|
|
85
|
+
switch (action) {
|
|
86
|
+
case 'getRouterRtpCapabilities':
|
|
87
|
+
return { rtpCapabilities: (router as any).rtpCapabilities };
|
|
88
|
+
|
|
89
|
+
case 'createWebRtcTransport': {
|
|
90
|
+
const dir = (payload.direction ?? 'send') as 'send' | 'recv';
|
|
91
|
+
const t = await (router as any).createWebRtcTransport({
|
|
92
|
+
listenIps: [{ ip: '0.0.0.0', announcedIp: this.opts.announceIp ?? '127.0.0.1' }],
|
|
93
|
+
enableUdp: true,
|
|
94
|
+
enableTcp: true,
|
|
95
|
+
preferUdp: true,
|
|
96
|
+
});
|
|
97
|
+
if (dir === 'send') s.sendTransport = t; else s.recvTransport = t;
|
|
98
|
+
return {
|
|
99
|
+
id: t.id,
|
|
100
|
+
iceParameters: t.iceParameters,
|
|
101
|
+
iceCandidates: t.iceCandidates,
|
|
102
|
+
dtlsParameters: t.dtlsParameters,
|
|
103
|
+
direction: dir,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case 'connectTransport': {
|
|
108
|
+
const dir = (payload.direction ?? 'send') as 'send' | 'recv';
|
|
109
|
+
const t = dir === 'send' ? s.sendTransport : s.recvTransport;
|
|
110
|
+
if (!t) return { error: 'transport not found' };
|
|
111
|
+
await (t as any).connect({ dtlsParameters: payload.dtlsParameters });
|
|
112
|
+
return { connected: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
case 'produce': {
|
|
116
|
+
if (!s.sendTransport) return { error: 'send transport not found' };
|
|
117
|
+
const producer = await (s.sendTransport as any).produce({
|
|
118
|
+
kind: payload.kind,
|
|
119
|
+
rtpParameters: payload.rtpParameters,
|
|
120
|
+
paused: false,
|
|
121
|
+
});
|
|
122
|
+
s.producers.set(producer.id, producer);
|
|
123
|
+
return { id: producer.id, kind: producer.kind };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
case 'consume': {
|
|
127
|
+
if (!s.recvTransport) return { error: 'recv transport not found' };
|
|
128
|
+
const canConsume = (router as any).canConsume({ producerId: payload.producerId, rtpCapabilities: payload.rtpCapabilities });
|
|
129
|
+
if (!canConsume) return { error: 'cannot consume' };
|
|
130
|
+
const consumer = await (s.recvTransport as any).consume({
|
|
131
|
+
producerId: payload.producerId,
|
|
132
|
+
rtpCapabilities: payload.rtpCapabilities,
|
|
133
|
+
paused: true,
|
|
134
|
+
});
|
|
135
|
+
s.consumers.set(consumer.id, consumer);
|
|
136
|
+
return {
|
|
137
|
+
id: consumer.id,
|
|
138
|
+
producerId: consumer.producerId,
|
|
139
|
+
kind: consumer.kind,
|
|
140
|
+
rtpParameters: consumer.rtpParameters,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case 'closeProducer': {
|
|
145
|
+
const p = s.producers.get(String(payload.producerId));
|
|
146
|
+
if (p) { (p as any).close(); s.producers.delete(String(payload.producerId)); }
|
|
147
|
+
return { closed: true };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case 'closeConsumer': {
|
|
151
|
+
const c = s.consumers.get(String(payload.consumerId));
|
|
152
|
+
if (c) { (c as any).close(); s.consumers.delete(String(payload.consumerId)); }
|
|
153
|
+
return { closed: true };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
default:
|
|
157
|
+
return { error: `unknown media action` };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Clean up a connection's transports/producers/consumers on disconnect. */
|
|
162
|
+
cleanup(conn: Connection): void {
|
|
163
|
+
const s = this.states.get(conn);
|
|
164
|
+
if (!s) return;
|
|
165
|
+
for (const p of s.producers.values()) try { (p as any).close(); } catch { /* ignore */ }
|
|
166
|
+
for (const c of s.consumers.values()) try { (c as any).close(); } catch { /* ignore */ }
|
|
167
|
+
try { (s.sendTransport as any)?.close(); } catch { /* ignore */ }
|
|
168
|
+
try { (s.recvTransport as any)?.close(); } catch { /* ignore */ }
|
|
169
|
+
this.states.delete(conn);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Whether mediasoup is importable on this process (for capability checks). */
|
|
173
|
+
static async isAvailable(): Promise<boolean> {
|
|
174
|
+
try { await import('mediasoup'); return true; } catch { return false; }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
2
|
+
import type { IncomingMessage, Server as HttpServer } from 'node:http';
|
|
3
|
+
import type { Duplex } from 'node:stream';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import type { AuthService, CsrfOptions } from '@bhooai/nexus-auth';
|
|
6
|
+
import { checkWsUpgrade } from '@bhooai/nexus-auth';
|
|
7
|
+
import { AuthenticationError } from '@bhooai/nexus-core';
|
|
8
|
+
import type { ClientMessage, ServerMessage, Connection } from '../types.js';
|
|
9
|
+
import type { PubSubAdapter } from '../adapter/PubSubAdapter.js';
|
|
10
|
+
import { MemoryPubSubAdapter } from '../adapter/PubSubAdapter.js';
|
|
11
|
+
import type { MediasoupAdapter } from '../mediasoup/MediasoupAdapter.js';
|
|
12
|
+
|
|
13
|
+
export interface RealtimeServerOptions {
|
|
14
|
+
/** The HTTP server to attach the WebSocket upgrade handler to. */
|
|
15
|
+
httpServer: HttpServer;
|
|
16
|
+
/** WS path (default '/ws'). */
|
|
17
|
+
path?: string;
|
|
18
|
+
/** Auth service for access-token verification on upgrade (optional → anonymous). */
|
|
19
|
+
authService?: AuthService;
|
|
20
|
+
/** CSRF options for the WS upgrade origin + double-submit check (optional). */
|
|
21
|
+
csrfOptions?: CsrfOptions;
|
|
22
|
+
/** Pub/sub adapter for cross-instance fanout (default in-memory). */
|
|
23
|
+
adapter?: PubSubAdapter;
|
|
24
|
+
/** mediasoup SFU adapter for media routing (optional). */
|
|
25
|
+
mediasoup?: MediasoupAdapter;
|
|
26
|
+
/** Require authentication on upgrade (default true when authService provided). */
|
|
27
|
+
requireAuth?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CONN_CHANNEL = (id: string) => `conn:${id}`;
|
|
31
|
+
const ROOM_CHANNEL = (room: string) => `room:${room}`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* WebSocket realtime server. Attaches to an HTTP server's 'upgrade' event,
|
|
35
|
+
* authenticates the upgrade (access token via `?token=` query, optional CSRF
|
|
36
|
+
* origin/double-submit check), and dispatches client messages: room join/leave,
|
|
37
|
+
* WebRTC offer/answer/ICE-candidate relay, and app broadcasts.
|
|
38
|
+
*
|
|
39
|
+
* Cross-instance fanout uses the pub/sub adapter: direct peer messages are
|
|
40
|
+
* published to `conn:<id>` (delivered by whichever instance hosts the peer);
|
|
41
|
+
* room events are published to `room:<room>` (delivered to every instance with
|
|
42
|
+
* local members).
|
|
43
|
+
*/
|
|
44
|
+
export class RealtimeServer {
|
|
45
|
+
private wss: WebSocketServer;
|
|
46
|
+
private adapter: PubSubAdapter;
|
|
47
|
+
private connections = new Map<string, Connection>();
|
|
48
|
+
/** Local members per room (connIds on THIS instance). */
|
|
49
|
+
private roomMembers = new Map<string, Set<string>>();
|
|
50
|
+
private subscribedRooms = new Set<string>();
|
|
51
|
+
private opts: RealtimeServerOptions;
|
|
52
|
+
|
|
53
|
+
constructor(opts: RealtimeServerOptions) {
|
|
54
|
+
this.opts = opts;
|
|
55
|
+
this.adapter = opts.adapter ?? new MemoryPubSubAdapter();
|
|
56
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
57
|
+
this.opts.httpServer.on('upgrade', (req, socket, head) => this.handleUpgrade(req, socket, head));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): Promise<void> {
|
|
61
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
62
|
+
// Only handle our WS path; let other upgrade listeners handle the rest.
|
|
63
|
+
if (url.pathname !== (this.opts.path ?? '/ws')) return;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
// 1. CSRF + origin check (WS has no CORS preflight).
|
|
67
|
+
if (this.opts.csrfOptions) checkWsUpgrade(req.headers as Record<string, string | string[] | undefined>, this.opts.csrfOptions);
|
|
68
|
+
|
|
69
|
+
// 2. Authenticate via ?token=<accessToken> (or subprotocol).
|
|
70
|
+
const token = (url.searchParams.get('token') ?? req.headers['sec-websocket-protocol']?.split(',').map((s) => s.trim())[0]) ?? '';
|
|
71
|
+
let userId: string | undefined;
|
|
72
|
+
let roles: string[] = [];
|
|
73
|
+
if (this.opts.authService && token) {
|
|
74
|
+
const payload = await this.opts.authService.verifyAccessToken(token);
|
|
75
|
+
userId = payload.sub;
|
|
76
|
+
roles = payload.roles ?? [];
|
|
77
|
+
} else if (this.opts.requireAuth ?? !!this.opts.authService) {
|
|
78
|
+
throw new AuthenticationError('Missing access token');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
82
|
+
const id = userId ?? randomBytes(9).toString('base64url');
|
|
83
|
+
this.register(ws, id, userId, roles);
|
|
84
|
+
});
|
|
85
|
+
} catch (err) {
|
|
86
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
87
|
+
socket.destroy();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private register(ws: WebSocket, id: string, userId: string | undefined, roles: string[]): void {
|
|
92
|
+
const conn: Connection = {
|
|
93
|
+
id,
|
|
94
|
+
userId,
|
|
95
|
+
roles,
|
|
96
|
+
rooms: new Set(),
|
|
97
|
+
get isOpen() { return ws.readyState === ws.OPEN; },
|
|
98
|
+
send: (message) => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(message)); },
|
|
99
|
+
close: (code, reason) => ws.close(code, reason),
|
|
100
|
+
};
|
|
101
|
+
this.connections.set(id, conn);
|
|
102
|
+
|
|
103
|
+
// Subscribe to this connection's direct channel (cross-instance delivery).
|
|
104
|
+
const direct = (raw: Buffer | string) => {
|
|
105
|
+
const msg = JSON.parse(raw.toString()) as ServerMessage;
|
|
106
|
+
conn.send(msg);
|
|
107
|
+
};
|
|
108
|
+
this.adapter.subscribe(CONN_CHANNEL(id), direct);
|
|
109
|
+
(conn as Connection & { _direct?: unknown })._direct = direct;
|
|
110
|
+
|
|
111
|
+
ws.on('message', (data) => this.onMessage(conn, data.toString('utf8')));
|
|
112
|
+
ws.on('close', () => this.onClose(conn, direct));
|
|
113
|
+
ws.on('error', () => this.onClose(conn, direct));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async onMessage(conn: Connection, raw: string): Promise<void> {
|
|
117
|
+
let msg: ClientMessage;
|
|
118
|
+
try {
|
|
119
|
+
msg = JSON.parse(raw) as ClientMessage;
|
|
120
|
+
} catch {
|
|
121
|
+
conn.send({ type: 'error', message: 'Invalid JSON' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
switch (msg.type) {
|
|
125
|
+
case 'ping':
|
|
126
|
+
conn.send({ type: 'pong' });
|
|
127
|
+
return;
|
|
128
|
+
case 'join':
|
|
129
|
+
await this.joinRoom(conn, msg.room);
|
|
130
|
+
return;
|
|
131
|
+
case 'leave':
|
|
132
|
+
await this.leaveRoom(conn, msg.room);
|
|
133
|
+
return;
|
|
134
|
+
case 'offer':
|
|
135
|
+
case 'answer':
|
|
136
|
+
case 'candidate': {
|
|
137
|
+
const to = this.connections.get(msg.to);
|
|
138
|
+
if (to) {
|
|
139
|
+
// Same instance: send directly.
|
|
140
|
+
to.send(this.relayFrom(conn, msg));
|
|
141
|
+
} else {
|
|
142
|
+
// Cross-instance: publish to the peer's direct channel (best-effort).
|
|
143
|
+
this.adapter.publish(CONN_CHANNEL(msg.to), JSON.stringify(this.relayFrom(conn, msg)));
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
case 'broadcast':
|
|
148
|
+
this.broadcastRoom(msg.room, { type: 'broadcast', room: msg.room, from: conn.id, event: msg.event, data: msg.data });
|
|
149
|
+
return;
|
|
150
|
+
case 'media':
|
|
151
|
+
await this.handleMedia(conn, msg);
|
|
152
|
+
return;
|
|
153
|
+
default:
|
|
154
|
+
conn.send({ type: 'error', message: `Unknown message type` });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private relayFrom(conn: Connection, msg: ClientMessage): ServerMessage {
|
|
159
|
+
if (msg.type === 'offer') return { type: 'offer', from: conn.id, sdp: msg.sdp };
|
|
160
|
+
if (msg.type === 'answer') return { type: 'answer', from: conn.id, sdp: msg.sdp };
|
|
161
|
+
if (msg.type === 'candidate') return { type: 'candidate', from: conn.id, candidate: msg.candidate };
|
|
162
|
+
throw new Error('Cannot relay a non-peer message');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async joinRoom(conn: Connection, room: string): Promise<void> {
|
|
166
|
+
if (conn.rooms.has(room)) return;
|
|
167
|
+
let set = this.roomMembers.get(room);
|
|
168
|
+
const wasEmpty = !set || set.size === 0;
|
|
169
|
+
if (!set) { set = new Set(); this.roomMembers.set(room, set); }
|
|
170
|
+
set.add(conn.id);
|
|
171
|
+
conn.rooms.add(room);
|
|
172
|
+
|
|
173
|
+
if (wasEmpty && !this.subscribedRooms.has(room)) {
|
|
174
|
+
this.adapter.subscribe(ROOM_CHANNEL(room), this.deliverRoom);
|
|
175
|
+
this.subscribedRooms.add(room);
|
|
176
|
+
}
|
|
177
|
+
// Notify everyone (including cross-instance) that a peer joined.
|
|
178
|
+
this.adapter.publish(ROOM_CHANNEL(room), JSON.stringify({ type: 'peer-joined', room, peer: conn.id }));
|
|
179
|
+
// Tell the joiner who's already here (local members).
|
|
180
|
+
const peers = [...set].filter((id) => id !== conn.id);
|
|
181
|
+
conn.send({ type: 'joined', room, peers });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async leaveRoom(conn: Connection, room: string): Promise<void> {
|
|
185
|
+
conn.rooms.delete(room);
|
|
186
|
+
const set = this.roomMembers.get(room);
|
|
187
|
+
if (!set) return;
|
|
188
|
+
set.delete(conn.id);
|
|
189
|
+
this.adapter.publish(ROOM_CHANNEL(room), JSON.stringify({ type: 'peer-left', room, peer: conn.id }));
|
|
190
|
+
conn.send({ type: 'left', room });
|
|
191
|
+
if (set.size === 0) {
|
|
192
|
+
this.roomMembers.delete(room);
|
|
193
|
+
this.subscribedRooms.delete(room);
|
|
194
|
+
this.adapter.unsubscribe(ROOM_CHANNEL(room), this.deliverRoom);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Deliver a room-channel message to all local members of that room, skipping the originator. */
|
|
199
|
+
private deliverRoom = (raw: Buffer | string): void => {
|
|
200
|
+
const msg = JSON.parse(raw.toString()) as ServerMessage & { room?: string };
|
|
201
|
+
const room = msg.room;
|
|
202
|
+
if (!room) return;
|
|
203
|
+
const set = this.roomMembers.get(room);
|
|
204
|
+
if (!set) return;
|
|
205
|
+
// The originator must not receive its own join/leave/broadcast echo.
|
|
206
|
+
const origin =
|
|
207
|
+
msg.type === 'peer-joined' || msg.type === 'peer-left' ? (msg as { peer: string }).peer
|
|
208
|
+
: msg.type === 'broadcast' ? (msg as { from: string }).from
|
|
209
|
+
: undefined;
|
|
210
|
+
for (const id of set) {
|
|
211
|
+
if (id === origin) continue;
|
|
212
|
+
this.connections.get(id)?.send(msg);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
private broadcastRoom(room: string, msg: ServerMessage): void {
|
|
217
|
+
this.adapter.publish(ROOM_CHANNEL(room), JSON.stringify(msg));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Publish an application event to every authenticated connection in a room. */
|
|
221
|
+
broadcast(room: string, event: string, data?: unknown): void {
|
|
222
|
+
this.broadcastRoom(room, { type: 'broadcast', room, from: 'server', event, data });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async handleMedia(conn: Connection, msg: ClientMessage & { type: 'media' }): Promise<void> {
|
|
226
|
+
if (!this.opts.mediasoup) {
|
|
227
|
+
conn.send({ type: 'error', message: 'mediasoup SFU not enabled', code: 'NO_SFU' });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const reply = await this.opts.mediasoup.handle(conn, msg.action, msg.payload);
|
|
231
|
+
conn.send({ type: 'media', action: msg.action, payload: reply });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async onClose(conn: Connection, direct: (raw: Buffer | string) => void): Promise<void> {
|
|
235
|
+
this.connections.delete(conn.id);
|
|
236
|
+
this.adapter.unsubscribe(CONN_CHANNEL(conn.id), direct);
|
|
237
|
+
for (const room of [...conn.rooms]) await this.leaveRoom(conn, room);
|
|
238
|
+
this.opts.mediasoup?.cleanup(conn);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Number of live connections (test/monitoring helper). */
|
|
242
|
+
get connectionCount(): number {
|
|
243
|
+
return this.connections.size;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Close the WS server and all connections. */
|
|
247
|
+
close(): Promise<void> {
|
|
248
|
+
for (const conn of this.connections.values()) conn.close(1001, 'server shutting down');
|
|
249
|
+
this.wss.close();
|
|
250
|
+
return Promise.resolve();
|
|
251
|
+
}
|
|
252
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Messages a client may send over the WebSocket. */
|
|
2
|
+
export type ClientMessage =
|
|
3
|
+
| { type: 'join'; room: string }
|
|
4
|
+
| { type: 'leave'; room: string }
|
|
5
|
+
| { type: 'offer'; to: string; sdp: string; room?: string }
|
|
6
|
+
| { type: 'answer'; to: string; sdp: string; room?: string }
|
|
7
|
+
| { type: 'candidate'; to: string; candidate: unknown; room?: string }
|
|
8
|
+
| { type: 'broadcast'; room: string; event: string; data?: unknown }
|
|
9
|
+
| { type: 'ping' }
|
|
10
|
+
| { type: 'media'; action: MediaAction; payload: Record<string, unknown> };
|
|
11
|
+
|
|
12
|
+
/** Messages the server sends to a client. */
|
|
13
|
+
export type ServerMessage =
|
|
14
|
+
| { type: 'joined'; room: string; peers: string[] }
|
|
15
|
+
| { type: 'left'; room: string }
|
|
16
|
+
| { type: 'peer-joined'; room: string; peer: string }
|
|
17
|
+
| { type: 'peer-left'; room: string; peer: string }
|
|
18
|
+
| { type: 'offer'; from: string; sdp: string }
|
|
19
|
+
| { type: 'answer'; from: string; sdp: string }
|
|
20
|
+
| { type: 'candidate'; from: string; candidate: unknown }
|
|
21
|
+
| { type: 'broadcast'; room: string; from: string; event: string; data?: unknown }
|
|
22
|
+
| { type: 'pong' }
|
|
23
|
+
| { type: 'media'; action: MediaAction; payload: Record<string, unknown> }
|
|
24
|
+
| { type: 'error'; message: string; code?: string };
|
|
25
|
+
|
|
26
|
+
/** mediasoup signaling actions relayed over WS. */
|
|
27
|
+
export type MediaAction =
|
|
28
|
+
| 'getRouterRtpCapabilities'
|
|
29
|
+
| 'createWebRtcTransport'
|
|
30
|
+
| 'connectTransport'
|
|
31
|
+
| 'produce'
|
|
32
|
+
| 'consume'
|
|
33
|
+
| 'closeProducer'
|
|
34
|
+
| 'closeConsumer';
|
|
35
|
+
|
|
36
|
+
/** A connected client's server-side record. */
|
|
37
|
+
export interface Connection {
|
|
38
|
+
/** Stable id for this connection (the user id when authenticated, else random). */
|
|
39
|
+
id: string;
|
|
40
|
+
/** Authenticated user id (undefined for anonymous connections). */
|
|
41
|
+
userId?: string;
|
|
42
|
+
/** Roles from the access token. */
|
|
43
|
+
roles: string[];
|
|
44
|
+
/** Rooms this connection has joined (on this instance). */
|
|
45
|
+
rooms: Set<string>;
|
|
46
|
+
/** Send a JSON message to the client. */
|
|
47
|
+
send(message: ServerMessage): void;
|
|
48
|
+
/** Close the connection. */
|
|
49
|
+
close(code?: number, reason?: string): void;
|
|
50
|
+
/** Whether the underlying socket is still open. */
|
|
51
|
+
readonly isOpen: boolean;
|
|
52
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, beforeAll } from 'vitest';
|
|
2
|
+
import { createServer, type Server as HttpServer } from 'node:http';
|
|
3
|
+
import { WebSocket, type RawData } from 'ws';
|
|
4
|
+
import type { AddressInfo } from 'node:net';
|
|
5
|
+
import { AuthService, MemorySessionStore } from '@bhooai/nexus-auth';
|
|
6
|
+
import { RealtimeServer, MemoryPubSubAdapter, MediasoupAdapter } from '../src/index.js';
|
|
7
|
+
|
|
8
|
+
const JWT = { secret: 'rt-test-secret-long-enough-for-hs256-signing', issuer: 'nexus-test', accessTtl: 60, refreshTtl: 3600 };
|
|
9
|
+
|
|
10
|
+
function boot(opts: { authService?: AuthService; requireAuth?: boolean } = {}): { http: HttpServer; rt: RealtimeServer; port: number; close: () => Promise<void> } {
|
|
11
|
+
const http = createServer((_req, res) => res.end());
|
|
12
|
+
const rt = new RealtimeServer({ httpServer: http, authService: opts.authService, requireAuth: opts.requireAuth, path: '/ws' });
|
|
13
|
+
return {
|
|
14
|
+
http,
|
|
15
|
+
rt,
|
|
16
|
+
port: 0,
|
|
17
|
+
async close() {
|
|
18
|
+
await rt.close();
|
|
19
|
+
await new Promise<void>((r) => http.close(() => r()));
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function listen(s: { http: HttpServer; port: number }): Promise<number> {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
s.http.listen(0, '127.0.0.1', () => {
|
|
27
|
+
s.port = (s.http.address() as AddressInfo).port;
|
|
28
|
+
resolve(s.port);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Client {
|
|
34
|
+
ws: WebSocket;
|
|
35
|
+
messages: import('../src/types.js').ServerMessage[];
|
|
36
|
+
next(): Promise<import('../src/types.js').ServerMessage>;
|
|
37
|
+
send(msg: unknown): void;
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function connect(port: number, query = '', protocols?: string[]): Promise<Client> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws${query}`, protocols);
|
|
44
|
+
const messages: import('../src/types.js').ServerMessage[] = [];
|
|
45
|
+
ws.on('open', () => resolve(makeClient(ws, messages)));
|
|
46
|
+
ws.on('error', reject);
|
|
47
|
+
ws.on('message', (d: RawData) => messages.push(JSON.parse(d.toString())));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeClient(ws: WebSocket, messages: import('../src/types.js').ServerMessage[]): Client {
|
|
52
|
+
return {
|
|
53
|
+
ws,
|
|
54
|
+
messages,
|
|
55
|
+
next() {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const tick = () => {
|
|
58
|
+
if (messages.length) resolve(messages.shift()!);
|
|
59
|
+
else setTimeout(tick, 10);
|
|
60
|
+
};
|
|
61
|
+
tick();
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
send(msg) { ws.send(JSON.stringify(msg)); },
|
|
65
|
+
close() { return new Promise<void>((r) => ws.on('close', () => r()).close()); },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('realtime: adapter', () => {
|
|
70
|
+
it('delivers published messages to subscribers', async () => {
|
|
71
|
+
const adapter = new MemoryPubSubAdapter();
|
|
72
|
+
const got: string[] = [];
|
|
73
|
+
adapter.subscribe('room:lobby', (m) => got.push(m.toString()));
|
|
74
|
+
adapter.publish('room:lobby', 'hello');
|
|
75
|
+
adapter.publish('room:other', 'nope');
|
|
76
|
+
expect(got).toEqual(['hello']);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('realtime: auth + connection', () => {
|
|
81
|
+
let svc: AuthService;
|
|
82
|
+
let s: ReturnType<typeof boot>;
|
|
83
|
+
beforeAll(() => { svc = new AuthService(JWT, new MemorySessionStore()); });
|
|
84
|
+
afterEach(async () => s && (await s.close()));
|
|
85
|
+
|
|
86
|
+
it('accepts a connection with a valid access token', async () => {
|
|
87
|
+
s = boot({ authService: svc });
|
|
88
|
+
const port = await listen(s);
|
|
89
|
+
const pair = await svc.login({ userId: 'u1', roles: ['user'] });
|
|
90
|
+
const c = await connect(port, `?token=${pair.accessToken}`);
|
|
91
|
+
c.send({ type: 'ping' });
|
|
92
|
+
const msg = await c.next();
|
|
93
|
+
expect(msg.type).toBe('pong');
|
|
94
|
+
expect(s.rt.connectionCount).toBe(1);
|
|
95
|
+
await c.close();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects a connection without a token when auth is required', async () => {
|
|
99
|
+
s = boot({ authService: svc, requireAuth: true });
|
|
100
|
+
const port = await listen(s);
|
|
101
|
+
await expect(connect(port)).rejects.toThrow();
|
|
102
|
+
expect(s.rt.connectionCount).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('allows anonymous connections when no authService is configured', async () => {
|
|
106
|
+
s = boot({ requireAuth: false });
|
|
107
|
+
const port = await listen(s);
|
|
108
|
+
const c = await connect(port);
|
|
109
|
+
c.send({ type: 'ping' });
|
|
110
|
+
expect((await c.next()).type).toBe('pong');
|
|
111
|
+
await c.close();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('realtime: rooms + signaling + broadcast', () => {
|
|
116
|
+
let svc: AuthService;
|
|
117
|
+
let s: ReturnType<typeof boot>;
|
|
118
|
+
beforeAll(() => { svc = new AuthService(JWT, new MemorySessionStore()); });
|
|
119
|
+
afterEach(async () => s && (await s.close()));
|
|
120
|
+
|
|
121
|
+
async function twoClients() {
|
|
122
|
+
s = boot({ authService: svc });
|
|
123
|
+
const port = await listen(s);
|
|
124
|
+
const p1 = await svc.login({ userId: 'u-a', roles: ['user'] });
|
|
125
|
+
const p2 = await svc.login({ userId: 'u-b', roles: ['user'] });
|
|
126
|
+
const a = await connect(port, `?token=${p1.accessToken}`);
|
|
127
|
+
const b = await connect(port, `?token=${p2.accessToken}`);
|
|
128
|
+
return { port, a, b };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
it('notifies peers when someone joins a room and reports existing peers', async () => {
|
|
132
|
+
const { a, b } = await twoClients();
|
|
133
|
+
a.send({ type: 'join', room: 'lobby' });
|
|
134
|
+
expect((await a.next()).type).toBe('joined');
|
|
135
|
+
b.send({ type: 'join', room: 'lobby' });
|
|
136
|
+
// B learns A is already here:
|
|
137
|
+
const bJoined = await b.next();
|
|
138
|
+
expect(bJoined.type).toBe('joined');
|
|
139
|
+
expect(bJoined).toMatchObject({ type: 'joined', room: 'lobby' });
|
|
140
|
+
if (bJoined.type === 'joined') expect(bJoined.peers).toContain('u-a');
|
|
141
|
+
// A is told B joined:
|
|
142
|
+
const aPeer = await a.next();
|
|
143
|
+
expect(aPeer.type).toBe('peer-joined');
|
|
144
|
+
if (aPeer.type === 'peer-joined') expect(aPeer.peer).toBe('u-b');
|
|
145
|
+
await a.close();
|
|
146
|
+
await b.close();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('relays WebRTC offer/answer/candidate between peers', async () => {
|
|
150
|
+
const { a, b } = await twoClients();
|
|
151
|
+
a.send({ type: 'join', room: 'call' });
|
|
152
|
+
await a.next(); // joined
|
|
153
|
+
b.send({ type: 'join', room: 'call' });
|
|
154
|
+
await b.next(); // joined
|
|
155
|
+
await a.next(); // peer-joined (B)
|
|
156
|
+
|
|
157
|
+
a.send({ type: 'offer', to: 'u-b', sdp: 'OFFER-SDP', room: 'call' });
|
|
158
|
+
const bOffer = await b.next();
|
|
159
|
+
expect(bOffer.type).toBe('offer');
|
|
160
|
+
if (bOffer.type === 'offer') { expect(bOffer.from).toBe('u-a'); expect(bOffer.sdp).toBe('OFFER-SDP'); }
|
|
161
|
+
|
|
162
|
+
b.send({ type: 'answer', to: 'u-a', sdp: 'ANSWER-SDP', room: 'call' });
|
|
163
|
+
const aAnswer = await a.next();
|
|
164
|
+
expect(aAnswer.type).toBe('answer');
|
|
165
|
+
if (aAnswer.type === 'answer') expect(aAnswer.sdp).toBe('ANSWER-SDP');
|
|
166
|
+
|
|
167
|
+
a.send({ type: 'candidate', to: 'u-b', candidate: { c: 1 }, room: 'call' });
|
|
168
|
+
const bCand = await b.next();
|
|
169
|
+
expect(bCand.type).toBe('candidate');
|
|
170
|
+
await a.close();
|
|
171
|
+
await b.close();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('broadcasts an event to all room members', async () => {
|
|
175
|
+
const { a, b } = await twoClients();
|
|
176
|
+
a.send({ type: 'join', room: 'stage' });
|
|
177
|
+
await a.next();
|
|
178
|
+
b.send({ type: 'join', room: 'stage' });
|
|
179
|
+
await b.next();
|
|
180
|
+
await a.next(); // peer-joined
|
|
181
|
+
a.send({ type: 'broadcast', room: 'stage', event: 'chat', data: { msg: 'hi' } });
|
|
182
|
+
const bEvt = await b.next();
|
|
183
|
+
expect(bEvt.type).toBe('broadcast');
|
|
184
|
+
if (bEvt.type === 'broadcast') { expect(bEvt.event).toBe('chat'); expect(bEvt.from).toBe('u-a'); }
|
|
185
|
+
await a.close();
|
|
186
|
+
await b.close();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('notifies peers when someone leaves', async () => {
|
|
190
|
+
const { a, b } = await twoClients();
|
|
191
|
+
a.send({ type: 'join', room: 'x' });
|
|
192
|
+
await a.next();
|
|
193
|
+
b.send({ type: 'join', room: 'x' });
|
|
194
|
+
await b.next();
|
|
195
|
+
await a.next(); // peer-joined
|
|
196
|
+
await a.close();
|
|
197
|
+
const left = await b.next();
|
|
198
|
+
expect(left.type).toBe('peer-left');
|
|
199
|
+
await b.close();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('realtime: mediasoup SFU (skip if unavailable)', () => {
|
|
204
|
+
it('returns router RTP capabilities', async (ctx) => {
|
|
205
|
+
if (!(await MediasoupAdapter.isAvailable())) ctx.skip();
|
|
206
|
+
const adapter = new MediasoupAdapter({ announceIp: '127.0.0.1', rtcMinPort: 41000, rtcMaxPort: 41100 });
|
|
207
|
+
// Stand-in connection object (the adapter only uses it as a WeakMap key).
|
|
208
|
+
const conn = { id: 'm1', roles: [], rooms: new Set(), send() {}, close() {}, isOpen: true } as unknown as import('../src/types.js').Connection;
|
|
209
|
+
const reply = await adapter.handle(conn, 'getRouterRtpCapabilities', { room: 'test' });
|
|
210
|
+
expect(reply.rtpCapabilities).toBeDefined();
|
|
211
|
+
adapter.cleanup(conn);
|
|
212
|
+
});
|
|
213
|
+
});
|
package/tsconfig.json
ADDED