zero-query 1.1.1 → 1.2.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/LICENSE +21 -21
- package/README.md +2 -0
- package/cli/args.js +33 -33
- package/cli/commands/build-api.js +443 -442
- package/cli/commands/build.js +254 -247
- package/cli/commands/bundle.js +1228 -1224
- package/cli/commands/create.js +137 -121
- package/cli/commands/dev/devtools/index.js +56 -56
- package/cli/commands/dev/devtools/js/components.js +49 -49
- package/cli/commands/dev/devtools/js/core.js +423 -423
- package/cli/commands/dev/devtools/js/elements.js +421 -421
- package/cli/commands/dev/devtools/js/network.js +166 -166
- package/cli/commands/dev/devtools/js/performance.js +73 -73
- package/cli/commands/dev/devtools/js/router.js +105 -105
- package/cli/commands/dev/devtools/js/source.js +132 -132
- package/cli/commands/dev/devtools/js/stats.js +35 -35
- package/cli/commands/dev/devtools/js/tabs.js +79 -79
- package/cli/commands/dev/devtools/panel.html +95 -95
- package/cli/commands/dev/devtools/styles.css +244 -244
- package/cli/commands/dev/index.js +107 -107
- package/cli/commands/dev/logger.js +75 -75
- package/cli/commands/dev/overlay.js +858 -858
- package/cli/commands/dev/server.js +220 -220
- package/cli/commands/dev/validator.js +94 -94
- package/cli/commands/dev/watcher.js +172 -172
- package/cli/help.js +114 -112
- package/cli/index.js +52 -52
- package/cli/scaffold/default/LICENSE +21 -21
- package/cli/scaffold/default/app/app.js +207 -207
- package/cli/scaffold/default/app/components/about.js +201 -201
- package/cli/scaffold/default/app/components/api-demo.js +143 -143
- package/cli/scaffold/default/app/components/contact-card.js +231 -231
- package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
- package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
- package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
- package/cli/scaffold/default/app/components/counter.js +127 -127
- package/cli/scaffold/default/app/components/home.js +249 -249
- package/cli/scaffold/default/app/components/not-found.js +16 -16
- package/cli/scaffold/default/app/components/playground/playground.css +115 -115
- package/cli/scaffold/default/app/components/playground/playground.html +161 -161
- package/cli/scaffold/default/app/components/playground/playground.js +116 -116
- package/cli/scaffold/default/app/components/todos.js +225 -225
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
- package/cli/scaffold/default/app/routes.js +15 -15
- package/cli/scaffold/default/app/store.js +101 -101
- package/cli/scaffold/default/global.css +552 -552
- package/cli/scaffold/default/index.html +99 -99
- package/cli/scaffold/minimal/app/app.js +85 -85
- package/cli/scaffold/minimal/app/components/about.js +68 -68
- package/cli/scaffold/minimal/app/components/counter.js +122 -122
- package/cli/scaffold/minimal/app/components/home.js +68 -68
- package/cli/scaffold/minimal/app/components/not-found.js +16 -16
- package/cli/scaffold/minimal/app/routes.js +9 -9
- package/cli/scaffold/minimal/app/store.js +36 -36
- package/cli/scaffold/minimal/global.css +300 -300
- package/cli/scaffold/minimal/index.html +44 -44
- package/cli/scaffold/ssr/app/app.js +41 -41
- package/cli/scaffold/ssr/app/components/about.js +55 -55
- package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
- package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
- package/cli/scaffold/ssr/app/components/home.js +37 -37
- package/cli/scaffold/ssr/app/components/not-found.js +15 -15
- package/cli/scaffold/ssr/app/routes.js +8 -8
- package/cli/scaffold/ssr/global.css +228 -228
- package/cli/scaffold/ssr/index.html +37 -37
- package/cli/scaffold/ssr/package.json +8 -8
- package/cli/scaffold/ssr/server/data/posts.js +144 -144
- package/cli/scaffold/ssr/server/index.js +213 -213
- package/cli/scaffold/webrtc/app/app.js +11 -0
- package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
- package/cli/scaffold/webrtc/app/lib/room.js +252 -0
- package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
- package/cli/scaffold/webrtc/global.css +250 -0
- package/cli/scaffold/webrtc/index.html +21 -0
- package/cli/utils.js +305 -287
- package/dist/API.md +661 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +10313 -6614
- package/dist/zquery.min.js +8 -631
- package/index.d.ts +570 -371
- package/index.js +311 -240
- package/package.json +76 -70
- package/src/component.js +1709 -1691
- package/src/core.js +921 -921
- package/src/diff.js +497 -497
- package/src/errors.js +209 -209
- package/src/expression.js +922 -922
- package/src/http.js +242 -242
- package/src/package.json +1 -1
- package/src/reactive.js +255 -255
- package/src/router.js +843 -843
- package/src/ssr.js +418 -418
- package/src/store.js +318 -318
- package/src/utils.js +515 -515
- package/src/webrtc/e2ee.js +351 -0
- package/src/webrtc/errors.js +116 -0
- package/src/webrtc/ice.js +301 -0
- package/src/webrtc/index.js +131 -0
- package/src/webrtc/joinToken.js +119 -0
- package/src/webrtc/observe.js +172 -0
- package/src/webrtc/peer.js +351 -0
- package/src/webrtc/reactive.js +268 -0
- package/src/webrtc/room.js +625 -0
- package/src/webrtc/sdp.js +302 -0
- package/src/webrtc/sfu/index.js +43 -0
- package/src/webrtc/sfu/livekit.js +131 -0
- package/src/webrtc/sfu/mediasoup.js +150 -0
- package/src/webrtc/signaling.js +373 -0
- package/src/webrtc/turn.js +237 -0
- package/tests/_helpers/webrtcFakes.js +289 -0
- package/tests/audit.test.js +4158 -4158
- package/tests/cli.test.js +1136 -1103
- package/tests/compare.test.js +497 -486
- package/tests/component.test.js +3969 -3938
- package/tests/core.test.js +1910 -1910
- package/tests/dev-server.test.js +489 -489
- package/tests/diff.test.js +1416 -1416
- package/tests/docs.test.js +1664 -1650
- package/tests/electron-features.test.js +864 -864
- package/tests/errors.test.js +619 -619
- package/tests/expression.test.js +1056 -1056
- package/tests/http.test.js +648 -648
- package/tests/reactive.test.js +819 -819
- package/tests/router.test.js +2327 -2327
- package/tests/ssr.test.js +870 -870
- package/tests/store.test.js +830 -830
- package/tests/test-minifier.js +153 -153
- package/tests/test-ssr.js +27 -27
- package/tests/utils.test.js +1377 -1377
- package/tests/webrtc/e2ee.test.js +283 -0
- package/tests/webrtc/ice.test.js +202 -0
- package/tests/webrtc/joinToken.test.js +89 -0
- package/tests/webrtc/observe.test.js +111 -0
- package/tests/webrtc/peer.test.js +373 -0
- package/tests/webrtc/reactive.test.js +235 -0
- package/tests/webrtc/room.test.js +406 -0
- package/tests/webrtc/sdp.test.js +151 -0
- package/tests/webrtc/sfu-livekit.test.js +119 -0
- package/tests/webrtc/sfu.test.js +160 -0
- package/tests/webrtc/signaling.test.js +251 -0
- package/tests/webrtc/turn.test.js +256 -0
- package/types/collection.d.ts +383 -383
- package/types/component.d.ts +186 -186
- package/types/errors.d.ts +135 -135
- package/types/http.d.ts +92 -92
- package/types/misc.d.ts +201 -201
- package/types/reactive.d.ts +98 -98
- package/types/router.d.ts +190 -190
- package/types/ssr.d.ts +102 -102
- package/types/store.d.ts +146 -146
- package/types/utils.d.ts +245 -245
- package/types/webrtc.d.ts +653 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/webrtc/sdp.js - minimal read-only SDP helpers
|
|
3
|
+
*
|
|
4
|
+
* A trimmed port of the server-side `@zero-server/webrtc` SDP module.
|
|
5
|
+
* The client only needs to read a few keys out of an SDP (for stats,
|
|
6
|
+
* ICE bookkeeping, and a sanity check before sending it through
|
|
7
|
+
* signaling); full RFC 8866 conformance lives on the server.
|
|
8
|
+
*
|
|
9
|
+
* Surface:
|
|
10
|
+
* - `parseSdp(text)` -> structured `{ version, origin, sessionName, media: [...] }`
|
|
11
|
+
* - `validateSdp(text)` -> throws `SdpError` if the SDP would be rejected by the hub
|
|
12
|
+
* (missing `UDP/TLS/RTP/SAVPF` proto, `ice-ufrag`, `ice-pwd`,
|
|
13
|
+
* or `fingerprint` on any non-rejected m-section).
|
|
14
|
+
*
|
|
15
|
+
* Pure functions, no globals, SSR-safe.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { SdpError } from './errors.js';
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
/** Max SDP size accepted by the server. */
|
|
22
|
+
const DEFAULT_MAX_BYTES = 65_536;
|
|
23
|
+
|
|
24
|
+
/** Required transport protocol per server validator. */
|
|
25
|
+
const REQUIRED_PROTO = 'UDP/TLS/RTP/SAVPF';
|
|
26
|
+
|
|
27
|
+
/** Valid SDP direction attributes (RFC 8866 §6.7). */
|
|
28
|
+
export const SDP_DIRECTIONS = Object.freeze(['sendrecv', 'sendonly', 'recvonly', 'inactive']);
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse an SDP document into a minimal, structured form.
|
|
33
|
+
*
|
|
34
|
+
* Only the fields the client needs are lifted onto named keys; everything
|
|
35
|
+
* else is preserved verbatim on `attributes`/`media[i].attributes` so the
|
|
36
|
+
* caller can still inspect unusual lines without losing information.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} text
|
|
39
|
+
* @param {object} [opts]
|
|
40
|
+
* @param {number} [opts.maxBytes=65536]
|
|
41
|
+
* @returns {{
|
|
42
|
+
* version: number,
|
|
43
|
+
* origin: ?object,
|
|
44
|
+
* sessionName: string,
|
|
45
|
+
* attributes: Array<{key:string,value:string}>,
|
|
46
|
+
* media: Array<{
|
|
47
|
+
* kind: string,
|
|
48
|
+
* port: number,
|
|
49
|
+
* proto: string,
|
|
50
|
+
* fmts: string[],
|
|
51
|
+
* mid?: string,
|
|
52
|
+
* iceUfrag?: string,
|
|
53
|
+
* icePwd?: string,
|
|
54
|
+
* fingerprint?: { algorithm: string, value: string },
|
|
55
|
+
* setup?: string,
|
|
56
|
+
* direction?: string,
|
|
57
|
+
* rtcpMux: boolean,
|
|
58
|
+
* candidates: string[],
|
|
59
|
+
* rtpmaps: Array<{ payload: number, codec: string, clockRate: number, channels?: number }>,
|
|
60
|
+
* attributes: Array<{key:string,value:string}>,
|
|
61
|
+
* }>,
|
|
62
|
+
* }}
|
|
63
|
+
*/
|
|
64
|
+
export function parseSdp(text, opts = {}) {
|
|
65
|
+
if (typeof text !== 'string') {
|
|
66
|
+
throw new SdpError('parseSdp: input must be a string', { code: 'ZQ_WEBRTC_SDP_PARSE' });
|
|
67
|
+
}
|
|
68
|
+
const maxBytes = typeof opts.maxBytes === 'number' ? opts.maxBytes : DEFAULT_MAX_BYTES;
|
|
69
|
+
if (text.length > maxBytes) {
|
|
70
|
+
throw new SdpError(`parseSdp: payload exceeds ${maxBytes} bytes`, { code: 'ZQ_WEBRTC_SDP_TOO_LARGE' });
|
|
71
|
+
}
|
|
72
|
+
if (text.length === 0) {
|
|
73
|
+
throw new SdpError('parseSdp: empty input', { code: 'ZQ_WEBRTC_SDP_PARSE' });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n').filter((l) => l.length > 0);
|
|
77
|
+
if (lines.length === 0) {
|
|
78
|
+
throw new SdpError('parseSdp: no non-empty lines', { code: 'ZQ_WEBRTC_SDP_PARSE' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const session = {
|
|
82
|
+
version: 0,
|
|
83
|
+
origin: null,
|
|
84
|
+
sessionName: '',
|
|
85
|
+
attributes: [],
|
|
86
|
+
media: [],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
let current = session;
|
|
90
|
+
let currentMedia = null;
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < lines.length; i++) {
|
|
93
|
+
const raw = lines[i];
|
|
94
|
+
const eq = raw.indexOf('=');
|
|
95
|
+
if (eq < 1) {
|
|
96
|
+
throw new SdpError(`parseSdp: malformed line ${i + 1}`, {
|
|
97
|
+
code: 'ZQ_WEBRTC_SDP_PARSE',
|
|
98
|
+
context: { line: i + 1 },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const type = raw.slice(0, eq);
|
|
102
|
+
const val = raw.slice(eq + 1);
|
|
103
|
+
|
|
104
|
+
if (i === 0 && type !== 'v') {
|
|
105
|
+
throw new SdpError('parseSdp: SDP must start with v=', {
|
|
106
|
+
code: 'ZQ_WEBRTC_SDP_PARSE',
|
|
107
|
+
context: { line: 1 },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
switch (type) {
|
|
112
|
+
case 'v':
|
|
113
|
+
session.version = Number(val);
|
|
114
|
+
break;
|
|
115
|
+
case 'o':
|
|
116
|
+
session.origin = _parseOrigin(val);
|
|
117
|
+
break;
|
|
118
|
+
case 's':
|
|
119
|
+
session.sessionName = val;
|
|
120
|
+
break;
|
|
121
|
+
case 'm': {
|
|
122
|
+
currentMedia = _newMedia(val);
|
|
123
|
+
session.media.push(currentMedia);
|
|
124
|
+
current = currentMedia;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case 'a':
|
|
128
|
+
_applyAttribute(current, val);
|
|
129
|
+
break;
|
|
130
|
+
default:
|
|
131
|
+
// ignore the lines we don't need for the client subset
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return session;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validate that the SDP would survive the server-side hub's strict checks.
|
|
142
|
+
* Throws `SdpError` on missing required attributes; returns the parsed
|
|
143
|
+
* structure on success so callers can chain.
|
|
144
|
+
*
|
|
145
|
+
* Required, per `@zero-server/webrtc` signaling validator:
|
|
146
|
+
* - at least one m-line
|
|
147
|
+
* - non-rejected m-lines (port != 0) must use `UDP/TLS/RTP/SAVPF`
|
|
148
|
+
* - each non-rejected m-line must carry `ice-ufrag`, `ice-pwd`, and
|
|
149
|
+
* a `fingerprint` attribute (session-level fallback is honored).
|
|
150
|
+
*
|
|
151
|
+
* @param {string} text
|
|
152
|
+
* @returns {ReturnType<typeof parseSdp>}
|
|
153
|
+
*/
|
|
154
|
+
export function validateSdp(text) {
|
|
155
|
+
const parsed = parseSdp(text);
|
|
156
|
+
if (parsed.media.length === 0) {
|
|
157
|
+
throw new SdpError('validateSdp: SDP has no m-lines', { code: 'ZQ_WEBRTC_SDP_NO_MEDIA' });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Session-level fallbacks: ice-ufrag / ice-pwd / fingerprint can appear
|
|
161
|
+
// once at the session level and apply to every m-section.
|
|
162
|
+
const sessIceUfrag = _findAttr(parsed.attributes, 'ice-ufrag');
|
|
163
|
+
const sessIcePwd = _findAttr(parsed.attributes, 'ice-pwd');
|
|
164
|
+
const sessFingerprint = _findAttr(parsed.attributes, 'fingerprint');
|
|
165
|
+
|
|
166
|
+
for (let i = 0; i < parsed.media.length; i++) {
|
|
167
|
+
const m = parsed.media[i];
|
|
168
|
+
if (m.port === 0) continue;
|
|
169
|
+
if (m.proto !== REQUIRED_PROTO) {
|
|
170
|
+
throw new SdpError(
|
|
171
|
+
`validateSdp: m-line ${i} proto "${m.proto}" must be "${REQUIRED_PROTO}"`,
|
|
172
|
+
{ code: 'ZQ_WEBRTC_SDP_BAD_PROTO', context: { index: i, proto: m.proto } }
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const ufrag = m.iceUfrag || sessIceUfrag;
|
|
176
|
+
const pwd = m.icePwd || sessIcePwd;
|
|
177
|
+
const fp = m.fingerprint || sessFingerprint;
|
|
178
|
+
if (!ufrag) {
|
|
179
|
+
throw new SdpError(`validateSdp: m-line ${i} missing ice-ufrag`, {
|
|
180
|
+
code: 'ZQ_WEBRTC_SDP_NO_ICE_UFRAG', context: { index: i },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (!pwd) {
|
|
184
|
+
throw new SdpError(`validateSdp: m-line ${i} missing ice-pwd`, {
|
|
185
|
+
code: 'ZQ_WEBRTC_SDP_NO_ICE_PWD', context: { index: i },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (!fp) {
|
|
189
|
+
throw new SdpError(`validateSdp: m-line ${i} missing fingerprint`, {
|
|
190
|
+
code: 'ZQ_WEBRTC_SDP_NO_FINGERPRINT', context: { index: i },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return parsed;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Internals
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
/** @param {string} val */
|
|
204
|
+
function _parseOrigin(val) {
|
|
205
|
+
const t = val.split(/\s+/);
|
|
206
|
+
if (t.length < 6) return null;
|
|
207
|
+
return {
|
|
208
|
+
username: t[0],
|
|
209
|
+
sessionId: t[1],
|
|
210
|
+
sessionVersion: Number(t[2]),
|
|
211
|
+
netType: t[3],
|
|
212
|
+
addrType: t[4],
|
|
213
|
+
address: t[5],
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @param {string} val - the part after `m=` */
|
|
218
|
+
function _newMedia(val) {
|
|
219
|
+
const t = val.split(/\s+/);
|
|
220
|
+
return {
|
|
221
|
+
kind: t[0] || '',
|
|
222
|
+
port: Number(t[1]) || 0,
|
|
223
|
+
proto: t[2] || '',
|
|
224
|
+
fmts: t.slice(3),
|
|
225
|
+
mid: undefined,
|
|
226
|
+
iceUfrag: undefined,
|
|
227
|
+
icePwd: undefined,
|
|
228
|
+
fingerprint: undefined,
|
|
229
|
+
setup: undefined,
|
|
230
|
+
direction: undefined,
|
|
231
|
+
rtcpMux: false,
|
|
232
|
+
candidates: [],
|
|
233
|
+
rtpmaps: [],
|
|
234
|
+
attributes: [],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Apply a single `a=...` line to the current section (session or media).
|
|
240
|
+
* @param {object} section
|
|
241
|
+
* @param {string} val
|
|
242
|
+
*/
|
|
243
|
+
function _applyAttribute(section, val) {
|
|
244
|
+
const colon = val.indexOf(':');
|
|
245
|
+
const key = colon === -1 ? val : val.slice(0, colon);
|
|
246
|
+
const value = colon === -1 ? '' : val.slice(colon + 1);
|
|
247
|
+
section.attributes.push({ key, value });
|
|
248
|
+
|
|
249
|
+
switch (key) {
|
|
250
|
+
case 'mid': if ('mid' in section) section.mid = value; break;
|
|
251
|
+
case 'ice-ufrag': if ('iceUfrag' in section) section.iceUfrag = value; break;
|
|
252
|
+
case 'ice-pwd': if ('icePwd' in section) section.icePwd = value; break;
|
|
253
|
+
case 'setup': if ('setup' in section) section.setup = value; break;
|
|
254
|
+
case 'rtcp-mux': if ('rtcpMux' in section) section.rtcpMux = true; break;
|
|
255
|
+
case 'fingerprint': {
|
|
256
|
+
const sp = value.indexOf(' ');
|
|
257
|
+
const fp = sp === -1
|
|
258
|
+
? { algorithm: value, value: '' }
|
|
259
|
+
: { algorithm: value.slice(0, sp), value: value.slice(sp + 1) };
|
|
260
|
+
if ('fingerprint' in section) section.fingerprint = fp;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
case 'candidate':
|
|
264
|
+
if ('candidates' in section) section.candidates.push(`candidate:${value}`);
|
|
265
|
+
break;
|
|
266
|
+
case 'rtpmap': {
|
|
267
|
+
if (!('rtpmaps' in section)) break;
|
|
268
|
+
const sp = value.indexOf(' ');
|
|
269
|
+
if (sp === -1) break;
|
|
270
|
+
const payload = Number(value.slice(0, sp));
|
|
271
|
+
const desc = value.slice(sp + 1).split('/');
|
|
272
|
+
section.rtpmaps.push({
|
|
273
|
+
payload,
|
|
274
|
+
codec: desc[0] || '',
|
|
275
|
+
clockRate: Number(desc[1]) || 0,
|
|
276
|
+
channels: desc[2] ? Number(desc[2]) : undefined,
|
|
277
|
+
});
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
case 'sendrecv':
|
|
281
|
+
case 'sendonly':
|
|
282
|
+
case 'recvonly':
|
|
283
|
+
case 'inactive':
|
|
284
|
+
if ('direction' in section) section.direction = key;
|
|
285
|
+
break;
|
|
286
|
+
default:
|
|
287
|
+
// unknown attribute - already preserved on attributes[]
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Find the first attribute value for `key` in an attribute list, or `undefined`.
|
|
294
|
+
* @param {Array<{key:string,value:string}>} list
|
|
295
|
+
* @param {string} key
|
|
296
|
+
*/
|
|
297
|
+
function _findAttr(list, key) {
|
|
298
|
+
for (let i = 0; i < list.length; i++) {
|
|
299
|
+
if (list[i].key === key) return list[i].value || true;
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/webrtc/sfu/index.js
|
|
3
|
+
*
|
|
4
|
+
* SFU adapter registry. Adapters are dynamic-imported so the (optional)
|
|
5
|
+
* peer dependencies (`mediasoup-client`, `livekit-client`) only load
|
|
6
|
+
* when actually used.
|
|
7
|
+
*
|
|
8
|
+
* Public surface:
|
|
9
|
+
* - `loadSfuAdapter(name, opts?)` → `Promise<SfuAdapter>`
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { SfuError } from '../errors.js';
|
|
13
|
+
import { createMediasoupAdapter } from './mediasoup.js';
|
|
14
|
+
import { createLivekitAdapter } from './livekit.js';
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} SfuAdapter
|
|
19
|
+
* @property {'mediasoup'|'livekit'} name
|
|
20
|
+
* @property {(joinOpts: any) => Promise<any>} join
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Load an SFU adapter by name. The adapter's peer dependency must be
|
|
26
|
+
* installed by the consuming app.
|
|
27
|
+
*
|
|
28
|
+
* @param {'mediasoup'|'livekit'} name
|
|
29
|
+
* @param {object} [opts]
|
|
30
|
+
* @returns {Promise<SfuAdapter>}
|
|
31
|
+
*/
|
|
32
|
+
export async function loadSfuAdapter(name, opts = {}) {
|
|
33
|
+
if (name === 'mediasoup') {
|
|
34
|
+
return createMediasoupAdapter(opts);
|
|
35
|
+
}
|
|
36
|
+
if (name === 'livekit') {
|
|
37
|
+
return createLivekitAdapter(opts);
|
|
38
|
+
}
|
|
39
|
+
throw new SfuError(`unknown SFU adapter: ${name}`, {
|
|
40
|
+
code: 'ZQ_WEBRTC_SFU_UNKNOWN',
|
|
41
|
+
context: { name },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/webrtc/sfu/livekit.js
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper around the optional `livekit-client` peer dependency.
|
|
5
|
+
*
|
|
6
|
+
* - `livekit-client` is intentionally NOT bundled. Apps that want it
|
|
7
|
+
* must `npm install livekit-client` themselves.
|
|
8
|
+
* - If it isn't installed, `createLivekitAdapter()` throws
|
|
9
|
+
* `ZQ_WEBRTC_SFU_PEER_MISSING` with an actionable message.
|
|
10
|
+
* - The adapter exposes a LiveKit `Room` instance plus `connect()` /
|
|
11
|
+
* `disconnect()` helpers. The higher-level zQuery `Room` mapping
|
|
12
|
+
* lives in the consuming app for now; calling `.join()` throws
|
|
13
|
+
* `ZQ_WEBRTC_SFU_JOIN_UNAVAILABLE`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { SfuError } from '../errors.js';
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Dynamically import `livekit-client`. Returns the module's exports.
|
|
21
|
+
* Throws `SfuError(ZQ_WEBRTC_SFU_PEER_MISSING)` if the package is absent.
|
|
22
|
+
*
|
|
23
|
+
* @returns {Promise<any>}
|
|
24
|
+
*/
|
|
25
|
+
async function _importLivekitClient() {
|
|
26
|
+
try {
|
|
27
|
+
// Compose the package name at runtime so static bundlers (Vite,
|
|
28
|
+
// Rollup, esbuild) don't try to resolve the optional peer dep.
|
|
29
|
+
const pkg = ['livekit', 'client'].join('-');
|
|
30
|
+
return await import(/* @vite-ignore */ pkg);
|
|
31
|
+
} catch (cause) {
|
|
32
|
+
throw new SfuError(
|
|
33
|
+
'livekit-client peer dependency is not installed; run `npm install livekit-client`',
|
|
34
|
+
{ code: 'ZQ_WEBRTC_SFU_PEER_MISSING', cause },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a LiveKit-client adapter.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} [opts]
|
|
44
|
+
* @param {any} [opts.client] Pre-imported livekit-client module (test hook).
|
|
45
|
+
* @param {object} [opts.roomOptions] Forwarded to `new Room(...)`.
|
|
46
|
+
* @returns {Promise<import('./index.js').SfuAdapter>}
|
|
47
|
+
*/
|
|
48
|
+
export async function createLivekitAdapter(opts = {}) {
|
|
49
|
+
const mod = opts.client || await _importLivekitClient();
|
|
50
|
+
const RoomCtor = mod.Room || (mod.default && mod.default.Room);
|
|
51
|
+
if (typeof RoomCtor !== 'function') {
|
|
52
|
+
throw new SfuError('livekit-client module did not expose a Room constructor', {
|
|
53
|
+
code: 'ZQ_WEBRTC_SFU_BAD_MODULE',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let room;
|
|
58
|
+
try {
|
|
59
|
+
room = new RoomCtor(opts.roomOptions || {});
|
|
60
|
+
} catch (cause) {
|
|
61
|
+
throw new SfuError('failed to construct livekit-client Room', {
|
|
62
|
+
code: 'ZQ_WEBRTC_SFU_ROOM_FAILED',
|
|
63
|
+
cause,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let connected = false;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
name: 'livekit',
|
|
71
|
+
room,
|
|
72
|
+
|
|
73
|
+
/** Has `connect()` resolved at least once and not been undone by disconnect? */
|
|
74
|
+
get connected() {
|
|
75
|
+
return connected;
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Connect to a LiveKit server.
|
|
80
|
+
* @param {string} url LiveKit signaling URL (`wss://...`).
|
|
81
|
+
* @param {string} token Room access token (JWT) minted server-side.
|
|
82
|
+
* @param {object} [connectOpts] Forwarded to `room.connect(...)`.
|
|
83
|
+
* @returns {Promise<void>}
|
|
84
|
+
*/
|
|
85
|
+
async connect(url, token, connectOpts) {
|
|
86
|
+
if (typeof url !== 'string' || !url) {
|
|
87
|
+
throw new SfuError('connect(url, token): url required', {
|
|
88
|
+
code: 'ZQ_WEBRTC_SFU_BAD_URL',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (typeof token !== 'string' || !token) {
|
|
92
|
+
throw new SfuError('connect(url, token): token required', {
|
|
93
|
+
code: 'ZQ_WEBRTC_SFU_BAD_TOKEN',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await room.connect(url, token, connectOpts);
|
|
98
|
+
connected = true;
|
|
99
|
+
} catch (cause) {
|
|
100
|
+
throw new SfuError('livekit-client Room.connect() failed', {
|
|
101
|
+
code: 'ZQ_WEBRTC_SFU_CONNECT_FAILED',
|
|
102
|
+
cause,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/** Disconnect from the LiveKit server (best effort). */
|
|
108
|
+
async disconnect() {
|
|
109
|
+
if (!connected) return;
|
|
110
|
+
try {
|
|
111
|
+
await room.disconnect();
|
|
112
|
+
} finally {
|
|
113
|
+
connected = false;
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Reserved for the higher-level join flow that maps a LiveKit `Room`
|
|
119
|
+
* to a zQuery `Room`. Not implemented yet.
|
|
120
|
+
*
|
|
121
|
+
* @param {any} _joinOpts
|
|
122
|
+
* @returns {Promise<never>}
|
|
123
|
+
*/
|
|
124
|
+
async join(_joinOpts) {
|
|
125
|
+
throw new SfuError(
|
|
126
|
+
'livekit adapter.join() not implemented; use connect(url, token) and the underlying livekit-client Room directly',
|
|
127
|
+
{ code: 'ZQ_WEBRTC_SFU_JOIN_UNAVAILABLE' },
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/webrtc/sfu/mediasoup.js
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper around the optional `mediasoup-client` peer dependency.
|
|
5
|
+
*
|
|
6
|
+
* - `mediasoup-client` is intentionally NOT bundled. Apps that want it
|
|
7
|
+
* must `npm install mediasoup-client` themselves.
|
|
8
|
+
* - If it isn't installed, `createMediasoupAdapter()` throws
|
|
9
|
+
* `ZQ_WEBRTC_SFU_PEER_MISSING` with an actionable message.
|
|
10
|
+
* - The adapter exposes the lower-level mediasoup `Device` plus
|
|
11
|
+
* helpers for `load(routerRtpCapabilities)`, `canProduce(kind)`,
|
|
12
|
+
* `createSendTransport(params)`, `createRecvTransport(params)`.
|
|
13
|
+
* - The higher-level `adapter.join(joinOpts)` requires SFU-specific
|
|
14
|
+
* signaling (request → response over a data channel or HTTP). That
|
|
15
|
+
* glue lives in the consuming app for now; calling `.join()` throws
|
|
16
|
+
* `ZQ_WEBRTC_SFU_JOIN_UNAVAILABLE` so the limitation is explicit.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { SfuError } from '../errors.js';
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Dynamically import `mediasoup-client`. Returns the module's exports.
|
|
24
|
+
* Throws `SfuError(ZQ_WEBRTC_SFU_PEER_MISSING)` if the package is absent.
|
|
25
|
+
*
|
|
26
|
+
* @returns {Promise<any>}
|
|
27
|
+
*/
|
|
28
|
+
async function _importMediasoupClient() {
|
|
29
|
+
try {
|
|
30
|
+
// The package name is computed at runtime so static bundlers (Vite,
|
|
31
|
+
// Rollup, esbuild) don't try to resolve the optional peer dep at
|
|
32
|
+
// build time and fail the build when it isn't installed.
|
|
33
|
+
const pkg = ['mediasoup', 'client'].join('-');
|
|
34
|
+
return await import(/* @vite-ignore */ pkg);
|
|
35
|
+
} catch (cause) {
|
|
36
|
+
throw new SfuError(
|
|
37
|
+
'mediasoup-client peer dependency is not installed; run `npm install mediasoup-client`',
|
|
38
|
+
{ code: 'ZQ_WEBRTC_SFU_PEER_MISSING', cause },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a mediasoup-client adapter.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} [opts]
|
|
48
|
+
* @param {any} [opts.client] Pre-imported mediasoup-client module (test hook).
|
|
49
|
+
* @param {object} [opts.deviceOptions] Forwarded to `new Device(...)`.
|
|
50
|
+
* @returns {Promise<import('./index.js').SfuAdapter>}
|
|
51
|
+
*/
|
|
52
|
+
export async function createMediasoupAdapter(opts = {}) {
|
|
53
|
+
const mod = opts.client || await _importMediasoupClient();
|
|
54
|
+
const Device = mod.Device || (mod.default && mod.default.Device);
|
|
55
|
+
if (typeof Device !== 'function') {
|
|
56
|
+
throw new SfuError('mediasoup-client module did not expose a Device constructor', {
|
|
57
|
+
code: 'ZQ_WEBRTC_SFU_BAD_MODULE',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let device;
|
|
62
|
+
try {
|
|
63
|
+
device = new Device(opts.deviceOptions || {});
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
throw new SfuError('failed to construct mediasoup-client Device', {
|
|
66
|
+
code: 'ZQ_WEBRTC_SFU_DEVICE_FAILED',
|
|
67
|
+
cause,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
name: 'mediasoup',
|
|
73
|
+
device,
|
|
74
|
+
|
|
75
|
+
/** Has `device.load({ routerRtpCapabilities })` been called yet? */
|
|
76
|
+
get loaded() {
|
|
77
|
+
return !!device.loaded;
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Load the device with the SFU router's RTP capabilities.
|
|
82
|
+
* @param {any} routerRtpCapabilities
|
|
83
|
+
* @returns {Promise<void>}
|
|
84
|
+
*/
|
|
85
|
+
async load(routerRtpCapabilities) {
|
|
86
|
+
if (!routerRtpCapabilities || typeof routerRtpCapabilities !== 'object') {
|
|
87
|
+
throw new SfuError('load(routerRtpCapabilities): routerRtpCapabilities required', {
|
|
88
|
+
code: 'ZQ_WEBRTC_SFU_BAD_RTP_CAPS',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (device.loaded) return;
|
|
92
|
+
try {
|
|
93
|
+
await device.load({ routerRtpCapabilities });
|
|
94
|
+
} catch (cause) {
|
|
95
|
+
throw new SfuError('device.load() failed', {
|
|
96
|
+
code: 'ZQ_WEBRTC_SFU_LOAD_FAILED',
|
|
97
|
+
cause,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {'audio'|'video'} kind
|
|
104
|
+
* @returns {boolean}
|
|
105
|
+
*/
|
|
106
|
+
canProduce(kind) {
|
|
107
|
+
if (!device.loaded) {
|
|
108
|
+
throw new SfuError('canProduce(): device not loaded yet', {
|
|
109
|
+
code: 'ZQ_WEBRTC_SFU_NOT_LOADED',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return !!device.canProduce(kind);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
/** @param {any} params */
|
|
116
|
+
createSendTransport(params) {
|
|
117
|
+
if (!device.loaded) {
|
|
118
|
+
throw new SfuError('createSendTransport(): device not loaded yet', {
|
|
119
|
+
code: 'ZQ_WEBRTC_SFU_NOT_LOADED',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return device.createSendTransport(params);
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
/** @param {any} params */
|
|
126
|
+
createRecvTransport(params) {
|
|
127
|
+
if (!device.loaded) {
|
|
128
|
+
throw new SfuError('createRecvTransport(): device not loaded yet', {
|
|
129
|
+
code: 'ZQ_WEBRTC_SFU_NOT_LOADED',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return device.createRecvTransport(params);
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reserved for the higher-level join flow once SFU-specific signaling
|
|
137
|
+
* is wired through `Room`. Today, callers should use the device and
|
|
138
|
+
* transport helpers above directly.
|
|
139
|
+
*
|
|
140
|
+
* @param {any} _joinOpts
|
|
141
|
+
* @returns {Promise<never>}
|
|
142
|
+
*/
|
|
143
|
+
async join(_joinOpts) {
|
|
144
|
+
throw new SfuError(
|
|
145
|
+
'mediasoup adapter.join() not implemented; use device + createSendTransport/createRecvTransport with your SFU signaling layer',
|
|
146
|
+
{ code: 'ZQ_WEBRTC_SFU_JOIN_UNAVAILABLE' },
|
|
147
|
+
);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|