@zero-server/sdk 0.9.5 → 0.9.7
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 +54 -64
- package/index.js +116 -4
- package/lib/app.js +22 -22
- package/lib/auth/authorize.js +11 -11
- package/lib/auth/enrollment.js +5 -5
- package/lib/auth/jwt.js +9 -9
- package/lib/auth/oauth.js +1 -1
- package/lib/auth/session.js +5 -5
- package/lib/auth/trustedDevice.js +2 -2
- package/lib/auth/twoFactor.js +11 -11
- package/lib/auth/webauthn.js +6 -6
- package/lib/body/json.js +1 -1
- package/lib/body/raw.js +1 -1
- package/lib/body/rawBuffer.js +1 -1
- package/lib/body/text.js +1 -1
- package/lib/body/urlencoded.js +3 -3
- package/lib/cli.js +19 -4
- package/lib/cluster.js +3 -3
- package/lib/debug.js +10 -10
- package/lib/env/index.js +11 -11
- package/lib/errors.js +131 -16
- package/lib/fetch/index.js +1 -1
- package/lib/grpc/call.js +14 -14
- package/lib/grpc/client.js +4 -4
- package/lib/grpc/codec.js +7 -7
- package/lib/grpc/credentials.js +2 -2
- package/lib/grpc/frame.js +2 -2
- package/lib/grpc/health.js +3 -3
- package/lib/grpc/index.js +3 -3
- package/lib/grpc/metadata.js +3 -3
- package/lib/grpc/proto.js +5 -5
- package/lib/grpc/reflection.js +2 -2
- package/lib/grpc/server.js +3 -3
- package/lib/grpc/status.js +2 -2
- package/lib/grpc/watch.js +1 -1
- package/lib/http/request.js +13 -13
- package/lib/http/response.js +2 -2
- package/lib/lifecycle.js +5 -5
- package/lib/middleware/compress.js +4 -4
- package/lib/observe/health.js +1 -1
- package/lib/observe/index.js +1 -1
- package/lib/observe/logger.js +3 -3
- package/lib/observe/metrics.js +4 -4
- package/lib/observe/tracing.js +4 -4
- package/lib/orm/adapters/json.js +1 -1
- package/lib/orm/adapters/memory.js +2 -2
- package/lib/orm/adapters/mongo.js +2 -2
- package/lib/orm/adapters/mysql.js +2 -2
- package/lib/orm/adapters/postgres.js +2 -2
- package/lib/orm/adapters/sqlite.js +3 -3
- package/lib/orm/audit.js +1 -1
- package/lib/orm/index.js +7 -7
- package/lib/orm/migrate.js +1 -1
- package/lib/orm/model.js +15 -15
- package/lib/orm/procedures.js +1 -1
- package/lib/orm/profiler.js +1 -1
- package/lib/orm/query.js +9 -9
- package/lib/orm/schema.js +1 -1
- package/lib/orm/seed/data/person.js +1 -1
- package/lib/orm/seed/fake.js +10 -10
- package/lib/orm/seed/index.js +4 -4
- package/lib/orm/seed/rng.js +1 -1
- package/lib/orm/snapshot.js +2 -2
- package/lib/orm/tenancy.js +6 -6
- package/lib/orm/views.js +1 -1
- package/lib/router/index.js +9 -9
- package/lib/webrtc/bot.js +361 -0
- package/lib/webrtc/cli.js +182 -0
- package/lib/webrtc/cluster.js +350 -0
- package/lib/webrtc/e2ee.js +282 -0
- package/lib/webrtc/ice.js +370 -0
- package/lib/webrtc/index.js +132 -0
- package/lib/webrtc/joinToken.js +116 -0
- package/lib/webrtc/observe.js +229 -0
- package/lib/webrtc/peer.js +116 -0
- package/lib/webrtc/room.js +171 -0
- package/lib/webrtc/sdp.js +508 -0
- package/lib/webrtc/sfu/index.js +201 -0
- package/lib/webrtc/sfu/livekit.js +301 -0
- package/lib/webrtc/sfu/mediasoup.js +317 -0
- package/lib/webrtc/sfu/memory.js +204 -0
- package/lib/webrtc/signaling.js +546 -0
- package/lib/webrtc/stun.js +492 -0
- package/lib/webrtc/turn/codec.js +370 -0
- package/lib/webrtc/turn/credentials.js +141 -0
- package/lib/webrtc/turn/server.js +633 -0
- package/package.json +2 -2
- package/types/body.d.ts +1 -1
- package/types/cli.d.ts +1 -1
- package/types/index.d.ts +16 -4
- package/types/middleware.d.ts +1 -1
- package/types/orm.d.ts +3 -3
- package/types/request.d.ts +3 -3
- package/types/webrtc.d.ts +501 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/cli
|
|
3
|
+
* @description CLI subcommands for the `zh webrtc:*` namespace.
|
|
4
|
+
*
|
|
5
|
+
* Pure-function entry point `runWebRTCCommand(subcmd, flags, deps)` so
|
|
6
|
+
* the dispatch can be exercised in tests without spawning a child
|
|
7
|
+
* process or hitting the network. All side effects (stdout / stderr /
|
|
8
|
+
* process.exitCode) are injected through `deps`, defaulting to the
|
|
9
|
+
* real globals when called from `lib/cli.js`.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // From the shell, via the top-level CLI:
|
|
13
|
+
* // npx zh webrtc:stun --host stun.l.google.com --port 19302
|
|
14
|
+
* // npx zh webrtc:turn-creds --secret $SECRET --user alice \
|
|
15
|
+
* // --servers turn:turn.example.com:3478
|
|
16
|
+
* // npx zh webrtc:join-token --secret $JT_SECRET --room lobby --sub u1
|
|
17
|
+
* // npx zh webrtc:verify-token --secret $JT_SECRET --token $TOKEN
|
|
18
|
+
*
|
|
19
|
+
* // Programmatically:
|
|
20
|
+
* const { runWebRTCCommand } = require('@zero-server/webrtc/cli');
|
|
21
|
+
* await runWebRTCCommand('join-token', new Map([
|
|
22
|
+
* ['secret', 's'], ['room', 'lobby'], ['sub', 'u1'],
|
|
23
|
+
* ]));
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const defaultStun = require('./stun').stunBinding;
|
|
29
|
+
const { issueTurnCredentials } = require('./turn/credentials');
|
|
30
|
+
const { signJoinToken, verifyJoinToken } = require('./joinToken');
|
|
31
|
+
|
|
32
|
+
const SUBCOMMANDS = ['stun', 'turn-creds', 'join-token', 'verify-token', 'help'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @private
|
|
36
|
+
* Coerce a flag Map value to a number; return defaultValue if undefined.
|
|
37
|
+
*/
|
|
38
|
+
function flagNumber(flags, key, defaultValue)
|
|
39
|
+
{
|
|
40
|
+
if (!flags.has(key)) return defaultValue;
|
|
41
|
+
const raw = flags.get(key);
|
|
42
|
+
const n = Number(raw);
|
|
43
|
+
if (!Number.isFinite(n))
|
|
44
|
+
throw new Error(`--${key} must be a number, got "${raw}"`);
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @private
|
|
50
|
+
* Split a comma-separated flag into a trimmed, non-empty list.
|
|
51
|
+
*/
|
|
52
|
+
function flagList(flags, key)
|
|
53
|
+
{
|
|
54
|
+
if (!flags.has(key)) return [];
|
|
55
|
+
return String(flags.get(key))
|
|
56
|
+
.split(',')
|
|
57
|
+
.map((s) => s.trim())
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @private
|
|
63
|
+
* Require a flag, throwing a friendly error otherwise.
|
|
64
|
+
*/
|
|
65
|
+
function flagRequired(flags, key)
|
|
66
|
+
{
|
|
67
|
+
if (!flags.has(key) || flags.get(key) === 'true')
|
|
68
|
+
throw new Error(`--${key} is required`);
|
|
69
|
+
return flags.get(key);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run a single `webrtc:*` subcommand.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} subcmd
|
|
76
|
+
* One of `stun`, `turn-creds`, `join-token`, `verify-token`, `help`.
|
|
77
|
+
* @param {Map<string,string>} flags
|
|
78
|
+
* @param {object} [deps]
|
|
79
|
+
* Injection seam for tests.
|
|
80
|
+
* @param {(line: string) => void} [deps.out]
|
|
81
|
+
* @param {(line: string) => void} [deps.err]
|
|
82
|
+
* @param {(code: number) => void} [deps.setExit]
|
|
83
|
+
* @param {typeof defaultStun} [deps.stunBinding]
|
|
84
|
+
* @returns {Promise<number>} The exit code that would have been set.
|
|
85
|
+
*/
|
|
86
|
+
async function runWebRTCCommand(subcmd, flags = new Map(), deps = {})
|
|
87
|
+
{
|
|
88
|
+
const out = deps.out || ((line) => console.log(line));
|
|
89
|
+
const err = deps.err || ((line) => console.error(line));
|
|
90
|
+
const setExit = deps.setExit || ((code) => { process.exitCode = code; });
|
|
91
|
+
const stunFn = deps.stunBinding || defaultStun;
|
|
92
|
+
|
|
93
|
+
const name = String(subcmd || '').trim();
|
|
94
|
+
if (!name || name === 'help' || name === '--help' || name === '-h')
|
|
95
|
+
{
|
|
96
|
+
out(helpText());
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
if (!SUBCOMMANDS.includes(name))
|
|
100
|
+
{
|
|
101
|
+
err(`Unknown webrtc subcommand: "${name}"`);
|
|
102
|
+
out(helpText());
|
|
103
|
+
setExit(1);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try
|
|
108
|
+
{
|
|
109
|
+
switch (name)
|
|
110
|
+
{
|
|
111
|
+
case 'stun': await runStun(flags, { out, stunFn }); break;
|
|
112
|
+
case 'turn-creds': runTurnCreds(flags, { out }); break;
|
|
113
|
+
case 'join-token': runJoinToken(flags, { out }); break;
|
|
114
|
+
case 'verify-token': runVerifyToken(flags, { out }); break;
|
|
115
|
+
}
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
catch (e)
|
|
119
|
+
{
|
|
120
|
+
err(`webrtc:${name} failed: ${e.message}`);
|
|
121
|
+
setExit(1);
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runStun(flags, { out, stunFn })
|
|
127
|
+
{
|
|
128
|
+
const host = flagRequired(flags, 'host');
|
|
129
|
+
const port = flagNumber(flags, 'port', 3478);
|
|
130
|
+
const timeout = flagNumber(flags, 'timeout', 1000);
|
|
131
|
+
const retries = flagNumber(flags, 'retries', 1);
|
|
132
|
+
const result = await stunFn({ host, port, timeoutMs: timeout, retries });
|
|
133
|
+
out(JSON.stringify(result));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function runTurnCreds(flags, { out })
|
|
137
|
+
{
|
|
138
|
+
const secret = flagRequired(flags, 'secret');
|
|
139
|
+
const userId = flagRequired(flags, 'user');
|
|
140
|
+
const servers = flagList(flags, 'servers');
|
|
141
|
+
if (servers.length === 0)
|
|
142
|
+
throw new Error('--servers is required (comma-separated turn: or turns: URIs)');
|
|
143
|
+
const ttl = flags.has('ttl') ? flags.get('ttl') : 3600;
|
|
144
|
+
const realm = flags.has('realm') ? flags.get('realm') : undefined;
|
|
145
|
+
const creds = issueTurnCredentials({ secret, userId, servers, ttl, realm });
|
|
146
|
+
out(JSON.stringify(creds));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function runJoinToken(flags, { out })
|
|
150
|
+
{
|
|
151
|
+
const secret = flagRequired(flags, 'secret');
|
|
152
|
+
const room = flagRequired(flags, 'room');
|
|
153
|
+
const user = flagRequired(flags, 'user');
|
|
154
|
+
const ttl = flagNumber(flags, 'ttl', 300);
|
|
155
|
+
const token = signJoinToken({ secret, user, room, ttl });
|
|
156
|
+
out(token);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function runVerifyToken(flags, { out })
|
|
160
|
+
{
|
|
161
|
+
const secret = flagRequired(flags, 'secret');
|
|
162
|
+
const token = flagRequired(flags, 'token');
|
|
163
|
+
const room = flags.has('room') ? flags.get('room') : undefined;
|
|
164
|
+
const payload = verifyJoinToken(token, { secret, room });
|
|
165
|
+
out(JSON.stringify(payload));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function helpText()
|
|
169
|
+
{
|
|
170
|
+
return [
|
|
171
|
+
'zh webrtc:* - WebRTC tooling',
|
|
172
|
+
'',
|
|
173
|
+
'Subcommands:',
|
|
174
|
+
' webrtc:stun --host H [--port 3478] [--timeout 1000] [--retries 1]',
|
|
175
|
+
' webrtc:turn-creds --secret S --user U --servers turn:host:port[,...] [--ttl 3600] [--realm R]',
|
|
176
|
+
' webrtc:join-token --secret S --room R --user U [--ttl 300]',
|
|
177
|
+
' webrtc:verify-token --secret S --token T [--room R]',
|
|
178
|
+
' webrtc:help Show this message',
|
|
179
|
+
].join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { runWebRTCCommand, SUBCOMMANDS };
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/cluster
|
|
3
|
+
* @description Cluster adapter for the WebRTC signaling hub.
|
|
4
|
+
*
|
|
5
|
+
* `useCluster(hub, adapter)` glues a `SignalingHub` to any pub/sub
|
|
6
|
+
* adapter that implements `{ publish(channel, message), subscribe(
|
|
7
|
+
* channel, cb) -> unsubscribe }`. Once attached:
|
|
8
|
+
*
|
|
9
|
+
* - Every local `join` / `leave` is announced cluster-wide so other
|
|
10
|
+
* nodes can resolve a `peer.id` to its owning node.
|
|
11
|
+
* - Every `room.broadcast(...)` is mirrored to peers in the same room
|
|
12
|
+
* on other nodes.
|
|
13
|
+
* - Direct frames (`offer`, `answer`, `ice`) addressed to a peer that
|
|
14
|
+
* lives on a different node are forwarded to that node's inbox.
|
|
15
|
+
*
|
|
16
|
+
* The adapter itself is intentionally tiny so production deployments
|
|
17
|
+
* can wire it up to Redis, NATS, Kafka, or any in-house bus. A
|
|
18
|
+
* `MemoryClusterAdapter` is provided for tests and single-process
|
|
19
|
+
* simulations.
|
|
20
|
+
*
|
|
21
|
+
* @section Cluster
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const crypto = require('node:crypto');
|
|
27
|
+
|
|
28
|
+
// --- Channel naming ---
|
|
29
|
+
|
|
30
|
+
const CH_ANNOUNCE = 'zs:rtc:announce';
|
|
31
|
+
const chRoom = (name) => `zs:rtc:room:${name}`;
|
|
32
|
+
const chNode = (id) => `zs:rtc:node:${id}`;
|
|
33
|
+
|
|
34
|
+
// --- ClusterCoordinator ---
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Per-hub cluster glue. Created by {@link useCluster} and parked on
|
|
38
|
+
* `hub._cluster`. Owns the directory of remote peers and the set of
|
|
39
|
+
* pub/sub subscriptions.
|
|
40
|
+
*
|
|
41
|
+
* @class
|
|
42
|
+
* @section Cluster
|
|
43
|
+
*/
|
|
44
|
+
class ClusterCoordinator
|
|
45
|
+
{
|
|
46
|
+
/**
|
|
47
|
+
* @constructor
|
|
48
|
+
* @param {import('./signaling').SignalingHub} hub
|
|
49
|
+
* @param {{publish:Function, subscribe:Function}} adapter
|
|
50
|
+
* @param {object} [opts]
|
|
51
|
+
* @param {string} [opts.nodeId] - Stable id for this node. Defaults to
|
|
52
|
+
* a random 8-byte hex string.
|
|
53
|
+
*/
|
|
54
|
+
constructor(hub, adapter, opts = {})
|
|
55
|
+
{
|
|
56
|
+
/** @type {import('./signaling').SignalingHub} */
|
|
57
|
+
this.hub = hub;
|
|
58
|
+
/** @type {{publish:Function, subscribe:Function}} */
|
|
59
|
+
this.adapter = adapter;
|
|
60
|
+
/** @type {string} */
|
|
61
|
+
this.nodeId = opts.nodeId || crypto.randomBytes(8).toString('hex');
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Remote peer directory. `peerId -> { nodeId, room }`.
|
|
65
|
+
* @type {Map<string, {nodeId: string, room: string}>}
|
|
66
|
+
*/
|
|
67
|
+
this._remotePeers = new Map();
|
|
68
|
+
|
|
69
|
+
/** @type {Map<string, Function|null>} */
|
|
70
|
+
this._roomSubs = new Map();
|
|
71
|
+
|
|
72
|
+
/** @type {Function[]} */
|
|
73
|
+
this._unsubs = [];
|
|
74
|
+
|
|
75
|
+
/** @type {boolean} */
|
|
76
|
+
this._closed = false;
|
|
77
|
+
|
|
78
|
+
this._wire();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @private */
|
|
82
|
+
_wire()
|
|
83
|
+
{
|
|
84
|
+
const offAnnounce = this.adapter.subscribe(CH_ANNOUNCE, (m) => this._onAnnounce(m));
|
|
85
|
+
const offNode = this.adapter.subscribe(chNode(this.nodeId), (m) => this._onNodeMsg(m));
|
|
86
|
+
if (typeof offAnnounce === 'function') this._unsubs.push(offAnnounce);
|
|
87
|
+
if (typeof offNode === 'function') this._unsubs.push(offNode);
|
|
88
|
+
|
|
89
|
+
this._onJoin = ({ peer, room }) =>
|
|
90
|
+
{
|
|
91
|
+
this._ensureRoomSub(room.name);
|
|
92
|
+
this._safePub(CH_ANNOUNCE, {
|
|
93
|
+
kind: 'join', nodeId: this.nodeId, peerId: peer.id, room: room.name,
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
this._onLeave = ({ peer, room }) =>
|
|
97
|
+
{
|
|
98
|
+
this._safePub(CH_ANNOUNCE, {
|
|
99
|
+
kind: 'leave', nodeId: this.nodeId, peerId: peer.id, room: room.name,
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
this.hub.on('join', this._onJoin);
|
|
103
|
+
this.hub.on('leave', this._onLeave);
|
|
104
|
+
|
|
105
|
+
// Ask existing nodes to rebroadcast their directory.
|
|
106
|
+
this._safePub(CH_ANNOUNCE, { kind: 'hello', nodeId: this.nodeId });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @private */
|
|
110
|
+
_ensureRoomSub(roomName)
|
|
111
|
+
{
|
|
112
|
+
if (this._roomSubs.has(roomName)) return;
|
|
113
|
+
const off = this.adapter.subscribe(chRoom(roomName), (m) => this._onRoomMsg(roomName, m));
|
|
114
|
+
this._roomSubs.set(roomName, typeof off === 'function' ? off : null);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @private */
|
|
118
|
+
_onAnnounce(m)
|
|
119
|
+
{
|
|
120
|
+
if (!m || m.nodeId === this.nodeId || this._closed) return;
|
|
121
|
+
if (m.kind === 'join')
|
|
122
|
+
{
|
|
123
|
+
this._remotePeers.set(m.peerId, { nodeId: m.nodeId, room: m.room });
|
|
124
|
+
this._ensureRoomSub(m.room);
|
|
125
|
+
}
|
|
126
|
+
else if (m.kind === 'leave')
|
|
127
|
+
{
|
|
128
|
+
const entry = this._remotePeers.get(m.peerId);
|
|
129
|
+
if (entry && entry.nodeId === m.nodeId) this._remotePeers.delete(m.peerId);
|
|
130
|
+
}
|
|
131
|
+
else if (m.kind === 'hello')
|
|
132
|
+
{
|
|
133
|
+
// Replay our local directory so the newcomer learns about us.
|
|
134
|
+
for (const peer of this.hub._peers.values())
|
|
135
|
+
{
|
|
136
|
+
if (peer.room)
|
|
137
|
+
{
|
|
138
|
+
this._safePub(CH_ANNOUNCE, {
|
|
139
|
+
kind: 'join', nodeId: this.nodeId, peerId: peer.id, room: peer.room.name,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** @private */
|
|
147
|
+
_onNodeMsg(m)
|
|
148
|
+
{
|
|
149
|
+
if (!m || m.nodeId === this.nodeId || this._closed) return;
|
|
150
|
+
const target = this.hub._peers.get(m.target);
|
|
151
|
+
if (!target) return;
|
|
152
|
+
target.send(m.type, m.payload);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** @private */
|
|
156
|
+
_onRoomMsg(roomName, m)
|
|
157
|
+
{
|
|
158
|
+
if (!m || m.nodeId === this.nodeId || this._closed) return;
|
|
159
|
+
const room = this.hub._rooms.get(roomName);
|
|
160
|
+
if (!room) return;
|
|
161
|
+
for (const p of room._peers)
|
|
162
|
+
{
|
|
163
|
+
if (m.exclude && p.id === m.exclude) continue;
|
|
164
|
+
p.send(m.type, m.payload);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Look up the node that owns a remote peer, if any.
|
|
170
|
+
* @param {string} peerId
|
|
171
|
+
* @returns {{nodeId:string, room:string}|null}
|
|
172
|
+
*/
|
|
173
|
+
locate(peerId)
|
|
174
|
+
{
|
|
175
|
+
return this._remotePeers.get(peerId) || null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Forward a direct frame to a peer on another node. Called by the
|
|
180
|
+
* hub when a routed message (`offer` / `answer` / `ice`) targets a
|
|
181
|
+
* peer id that is not in the local registry.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} toPeerId
|
|
184
|
+
* @param {string} type
|
|
185
|
+
* @param {object} payload
|
|
186
|
+
* @returns {boolean} `true` if a remote node was addressed.
|
|
187
|
+
*/
|
|
188
|
+
routeDirect(toPeerId, type, payload)
|
|
189
|
+
{
|
|
190
|
+
const entry = this._remotePeers.get(toPeerId);
|
|
191
|
+
if (!entry) return false;
|
|
192
|
+
this._safePub(chNode(entry.nodeId), {
|
|
193
|
+
nodeId: this.nodeId, target: toPeerId, type, payload,
|
|
194
|
+
});
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Mirror a `room.broadcast(...)` to peers in the same room on other
|
|
200
|
+
* nodes. Called automatically from `Room#broadcast`.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} roomName
|
|
203
|
+
* @param {string} type
|
|
204
|
+
* @param {object} payload
|
|
205
|
+
* @param {string} [excludeId]
|
|
206
|
+
*/
|
|
207
|
+
fanoutRoom(roomName, type, payload, excludeId)
|
|
208
|
+
{
|
|
209
|
+
this._safePub(chRoom(roomName), {
|
|
210
|
+
nodeId: this.nodeId, type, payload, exclude: excludeId || null,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Tear down all subscriptions and clear remote state. */
|
|
215
|
+
close()
|
|
216
|
+
{
|
|
217
|
+
if (this._closed) return;
|
|
218
|
+
this._closed = true;
|
|
219
|
+
try { this.hub.off('join', this._onJoin); } catch { /* ignore */ }
|
|
220
|
+
try { this.hub.off('leave', this._onLeave); } catch { /* ignore */ }
|
|
221
|
+
for (const off of this._unsubs) { try { off(); } catch { /* ignore */ } }
|
|
222
|
+
for (const off of this._roomSubs.values())
|
|
223
|
+
{
|
|
224
|
+
if (typeof off === 'function') { try { off(); } catch { /* ignore */ } }
|
|
225
|
+
}
|
|
226
|
+
this._unsubs.length = 0;
|
|
227
|
+
this._roomSubs.clear();
|
|
228
|
+
this._remotePeers.clear();
|
|
229
|
+
if (this.hub._cluster === this) this.hub._cluster = null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** @private */
|
|
233
|
+
_safePub(channel, message)
|
|
234
|
+
{
|
|
235
|
+
try
|
|
236
|
+
{
|
|
237
|
+
const result = this.adapter.publish(channel, message);
|
|
238
|
+
if (result && typeof result.catch === 'function')
|
|
239
|
+
result.catch((err) => this.hub.emit('clusterError', err));
|
|
240
|
+
}
|
|
241
|
+
catch (err)
|
|
242
|
+
{
|
|
243
|
+
this.hub.emit('clusterError', err);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- useCluster ---
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Attach a cluster adapter to a `SignalingHub`.
|
|
252
|
+
*
|
|
253
|
+
* @param {import('./signaling').SignalingHub} hub
|
|
254
|
+
* @param {{publish:Function, subscribe:Function}} adapter
|
|
255
|
+
* @param {object} [opts]
|
|
256
|
+
* @param {string} [opts.nodeId]
|
|
257
|
+
* @returns {ClusterCoordinator}
|
|
258
|
+
*
|
|
259
|
+
* @section Cluster
|
|
260
|
+
*
|
|
261
|
+
* @example | In-memory cluster (tests / single-process simulation)
|
|
262
|
+
* const { SignalingHub, useCluster, MemoryClusterAdapter } = require('@zero-server/webrtc');
|
|
263
|
+
* const adapter = new MemoryClusterAdapter();
|
|
264
|
+
* const a = new SignalingHub(); useCluster(a, adapter, { nodeId: 'a' });
|
|
265
|
+
* const b = new SignalingHub(); useCluster(b, adapter, { nodeId: 'b' });
|
|
266
|
+
*
|
|
267
|
+
* @example | Redis adapter (BYO ioredis)
|
|
268
|
+
* const Redis = require('ioredis');
|
|
269
|
+
* const pub = new Redis(), sub = new Redis();
|
|
270
|
+
* const adapter = {
|
|
271
|
+
* publish: (ch, msg) => pub.publish(ch, JSON.stringify(msg)),
|
|
272
|
+
* subscribe: (ch, cb) => {
|
|
273
|
+
* sub.subscribe(ch);
|
|
274
|
+
* const on = (c, raw) => { if (c === ch) cb(JSON.parse(raw)); };
|
|
275
|
+
* sub.on('message', on);
|
|
276
|
+
* return () => { sub.off('message', on); sub.unsubscribe(ch); };
|
|
277
|
+
* },
|
|
278
|
+
* };
|
|
279
|
+
* useCluster(hub, adapter);
|
|
280
|
+
*/
|
|
281
|
+
function useCluster(hub, adapter, opts)
|
|
282
|
+
{
|
|
283
|
+
if (!adapter || typeof adapter.publish !== 'function' || typeof adapter.subscribe !== 'function')
|
|
284
|
+
throw new TypeError('useCluster: adapter must implement { publish, subscribe }');
|
|
285
|
+
const coord = new ClusterCoordinator(hub, adapter, opts);
|
|
286
|
+
hub._cluster = coord;
|
|
287
|
+
return coord;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- MemoryClusterAdapter ---
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* In-memory pub/sub adapter for tests and single-process simulations.
|
|
294
|
+
* Delivers synchronously to all subscribers on the same channel.
|
|
295
|
+
*
|
|
296
|
+
* @class
|
|
297
|
+
* @section Cluster
|
|
298
|
+
*
|
|
299
|
+
* @example
|
|
300
|
+
* const adapter = new MemoryClusterAdapter();
|
|
301
|
+
* useCluster(hubA, adapter); useCluster(hubB, adapter);
|
|
302
|
+
*/
|
|
303
|
+
class MemoryClusterAdapter
|
|
304
|
+
{
|
|
305
|
+
constructor()
|
|
306
|
+
{
|
|
307
|
+
/** @type {Map<string, Function[]>} */
|
|
308
|
+
this._channels = new Map();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {string} channel
|
|
313
|
+
* @param {*} message
|
|
314
|
+
*/
|
|
315
|
+
publish(channel, message)
|
|
316
|
+
{
|
|
317
|
+
const list = this._channels.get(channel);
|
|
318
|
+
if (!list || list.length === 0) return;
|
|
319
|
+
for (const fn of list.slice())
|
|
320
|
+
{
|
|
321
|
+
try { fn(message); } catch { /* swallow subscriber errors */ }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* @param {string} channel
|
|
327
|
+
* @param {(msg:*) => void} fn
|
|
328
|
+
* @returns {() => void} unsubscribe
|
|
329
|
+
*/
|
|
330
|
+
subscribe(channel, fn)
|
|
331
|
+
{
|
|
332
|
+
let list = this._channels.get(channel);
|
|
333
|
+
if (!list) { list = []; this._channels.set(channel, list); }
|
|
334
|
+
list.push(fn);
|
|
335
|
+
return () =>
|
|
336
|
+
{
|
|
337
|
+
const cur = this._channels.get(channel);
|
|
338
|
+
if (!cur) return;
|
|
339
|
+
const i = cur.indexOf(fn);
|
|
340
|
+
if (i >= 0) cur.splice(i, 1);
|
|
341
|
+
if (cur.length === 0) this._channels.delete(channel);
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = {
|
|
347
|
+
useCluster,
|
|
348
|
+
ClusterCoordinator,
|
|
349
|
+
MemoryClusterAdapter,
|
|
350
|
+
};
|