@displayxr/inline3d 1.26.0 → 1.28.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.
@@ -0,0 +1,398 @@
1
+ // call/signaling.js — the SignalingAdapter seam, and the two adapters the SDK ships.
2
+ //
3
+ // A SignalingAdapter is ANY object with `join(room, hooks) → Promise<SignalingSession>`:
4
+ //
5
+ // join(room, {
6
+ // id, // this peer's id (the transport picks it; stable across reconnects)
7
+ // maxPeers, // the room size this peer asks for
8
+ // onPeerJoined(id), onPeerLeft(id), onSignal(fromId, data),
9
+ // onDisconnect(err?), onReconnect(peerIds),
10
+ // }) → Promise<{ id, peers: string[], iceServers?: RTCIceServer[], send(to, data), leave() }>
11
+ //
12
+ // It rejects with an Error whose `code` is 'room-full' when the room is at capacity. That is the
13
+ // whole contract: presence, a relay for opaque offer/answer/ICE blobs, and leaving. Bring your
14
+ // own (your accounts, your push service) by implementing it — see call.d.ts.
15
+ //
16
+ // dxrSignaling(url) the `dxr-signal/1` JSON-over-WebSocket protocol (signaling/README.md), served
17
+ // by the reference Cloudflare Worker or `node signaling/dev-server.mjs`.
18
+ // peerjsCloud() DEMO ONLY: the free public PeerJS broker. No uptime guarantee, not ours, and
19
+ // every peer id it sees is derived from a hash of the room.
20
+
21
+ import { backoffMs } from './wire.js';
22
+
23
+ export const SIGNAL_PROTOCOL = 'dxr-signal/1';
24
+
25
+ function codedError(code, message) {
26
+ const e = new Error(`@displayxr/inline3d/call: ${message}`);
27
+ /** @type {any} */ (e).code = code;
28
+ return e;
29
+ }
30
+
31
+ /** SHA-256 hex of the room id: the only room-derived value that goes into a URL. */
32
+ export async function roomKey(room) {
33
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(room)));
34
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
35
+ }
36
+
37
+ /**
38
+ * The `dxr-signal/1` client. `url` is the server's base (`wss://signal.example.com` or
39
+ * `ws://localhost:8787`); the adapter connects to `<url>/v1/connect?k=<sha256(room)>`.
40
+ *
41
+ * A dropped socket is reconnected with backoff and the SAME peer id, so media that is flowing
42
+ * peer-to-peer is not torn down by a signalling blip.
43
+ *
44
+ * @param {string} url
45
+ * @param {{ WebSocket?: any, pingMs?: number }} [opts]
46
+ */
47
+ /**
48
+ * The hosted DisplayXR `dxr-signal/1` server (a Cloudflare Worker running `signaling/worker.mjs`,
49
+ * config in `signaling/deploy/displayxr.toml`). It also mints short-lived TURN credentials per
50
+ * join. `dxrSignaling()` with no URL uses it; pass your own URL to self-host.
51
+ */
52
+ export const DXR_SIGNAL_DEFAULT = 'wss://dxr-signal.displayxr.workers.dev';
53
+
54
+ export function dxrSignaling(url = DXR_SIGNAL_DEFAULT, opts = {}) {
55
+ if (!url || typeof url !== 'string') {
56
+ throw codedError(
57
+ 'no-signaling-url',
58
+ 'dxrSignaling(url): url must be a ws:// or wss:// string (omit it for the hosted DisplayXR server)'
59
+ );
60
+ }
61
+ const base = url.replace(/\/+$/, '');
62
+ const WS = opts.WebSocket || globalThis.WebSocket;
63
+ const pingMs = opts.pingMs || 20000;
64
+
65
+ return {
66
+ name: 'dxr',
67
+ url: base,
68
+ async join(room, hooks) {
69
+ const key = await roomKey(room);
70
+ const endpoint = `${base}/v1/connect?k=${key}`;
71
+ let ws = null;
72
+ let left = false;
73
+ let attempt = 0;
74
+ let pingTimer = null;
75
+ let known = new Set();
76
+ let queue = [];
77
+
78
+ const open = (isReconnect) =>
79
+ new Promise((resolve, reject) => {
80
+ let settled = false;
81
+ let joined = false;
82
+ const sock = new WS(endpoint);
83
+ ws = sock;
84
+ const fail = (err) => {
85
+ if (!settled) {
86
+ settled = true;
87
+ try {
88
+ sock.close(); // a failed join never leaves a socket behind
89
+ } catch {
90
+ /* already closing */
91
+ }
92
+ reject(err);
93
+ }
94
+ };
95
+ sock.onopen = () => {
96
+ sock.send(JSON.stringify({ t: 'join', v: 1, room, id: hooks.id, max: hooks.maxPeers }));
97
+ };
98
+ sock.onmessage = (ev) => {
99
+ let msg;
100
+ try {
101
+ msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data));
102
+ } catch {
103
+ return;
104
+ }
105
+ switch (msg.t) {
106
+ case 'welcome': {
107
+ attempt = 0;
108
+ const peers = Array.isArray(msg.peers) ? msg.peers.filter((p) => typeof p === 'string') : [];
109
+ clearInterval(pingTimer);
110
+ pingTimer = setInterval(() => sendRaw({ t: 'ping' }), pingMs);
111
+ const flush = queue;
112
+ queue = [];
113
+ for (const m of flush) sendRaw(m);
114
+ if (isReconnect) {
115
+ const now = new Set(peers);
116
+ for (const p of known) if (!now.has(p)) hooks.onPeerLeft?.(p);
117
+ known = now;
118
+ hooks.onReconnect?.(peers);
119
+ } else known = new Set(peers);
120
+ settled = true;
121
+ joined = true;
122
+ resolve({ id: msg.id, peers, iceServers: Array.isArray(msg.iceServers) ? msg.iceServers : undefined, max: msg.max });
123
+ break;
124
+ }
125
+ case 'full':
126
+ left = true;
127
+ fail(codedError('room-full', `this call is full (${msg.max} participants)`));
128
+ break;
129
+ case 'error':
130
+ if (!settled && ['bad-room', 'bad-id', 'bad-version'].includes(msg.code)) {
131
+ left = true;
132
+ fail(codedError(msg.code, msg.message || msg.code));
133
+ } else if (!settled && msg.code === 'id-taken') {
134
+ // A reconnect raced the server noticing the old socket close: retry shortly.
135
+ fail(codedError('id-taken', 'peer id still held; retrying'));
136
+ }
137
+ break;
138
+ case 'peer-joined':
139
+ if (typeof msg.id === 'string' && !known.has(msg.id)) {
140
+ known.add(msg.id);
141
+ hooks.onPeerJoined?.(msg.id);
142
+ }
143
+ break;
144
+ case 'peer-left':
145
+ if (typeof msg.id === 'string') {
146
+ known.delete(msg.id);
147
+ hooks.onPeerLeft?.(msg.id);
148
+ }
149
+ break;
150
+ case 'signal':
151
+ if (typeof msg.from === 'string') hooks.onSignal?.(msg.from, msg.data);
152
+ break;
153
+ }
154
+ };
155
+ sock.onerror = () => fail(codedError('signaling-unreachable', `cannot reach ${base}`));
156
+ sock.onclose = () => {
157
+ clearInterval(pingTimer);
158
+ fail(codedError('signaling-closed', 'signalling closed before joining'));
159
+ if (joined && !left && ws === sock) reconnect();
160
+ };
161
+ });
162
+
163
+ const sendRaw = (m) => {
164
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify(m));
165
+ else if (!left) queue.push(m);
166
+ };
167
+
168
+ const reconnect = () => {
169
+ hooks.onDisconnect?.();
170
+ const tryAgain = () => {
171
+ if (left) return;
172
+ open(true).catch((err) => {
173
+ if (left || (err && err.code === 'room-full')) {
174
+ hooks.onDisconnect?.(err);
175
+ return;
176
+ }
177
+ setTimeout(tryAgain, backoffMs(attempt++, { baseMs: 500, maxMs: 15000 }));
178
+ });
179
+ };
180
+ setTimeout(tryAgain, backoffMs(attempt++, { baseMs: 300, maxMs: 15000 }));
181
+ };
182
+
183
+ const first = await open(false);
184
+ return {
185
+ ...first,
186
+ send: (to, data) => sendRaw({ t: 'signal', to, data }),
187
+ leave: () => {
188
+ if (left) return;
189
+ left = true;
190
+ clearInterval(pingTimer);
191
+ try {
192
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify({ t: 'leave' }));
193
+ ws && ws.close(1000, 'leave');
194
+ } catch {
195
+ /* ignore */
196
+ }
197
+ },
198
+ };
199
+ },
200
+ };
201
+ }
202
+
203
+ /**
204
+ * DEMO ONLY — zero-setup signalling over the free public PeerJS broker (0.peerjs.com). It has no
205
+ * uptime guarantee and is not operated by DisplayXR; use `dxrSignaling(url)` for anything real.
206
+ *
207
+ * PeerJS has no rooms, so this adapter makes one out of SLOTS: each peer registers the broker id
208
+ * `dxrcall-<hash(room)>-<k>` for the first free k (a taken id is how "slot in use" is discovered),
209
+ * opens a PeerJS data connection to every other slot, and relays our signalling over those.
210
+ *
211
+ * A taken slot is NOT the same as a live participant, and that is what the rest of this adapter
212
+ * deals with:
213
+ * - A page that went away without its broker socket closing leaves a ZOMBIE slot. So there are
214
+ * twice as many slots as participants (a zombie never blocks a join), a slot counts as a
215
+ * participant only once it has said `hi`, and the room size is enforced on those. The page's own
216
+ * Peer is destroyed on `pagehide` / `beforeunload`, so a reload frees its slot at once.
217
+ * - A slot that exists but never says `hi` within `unreachableMs` is reported through
218
+ * `onPeerUnreachable('slot-<k>')`: the usual cause is a network where even the data
219
+ * connection's ICE never completes (it needs a relay). Silent slots are re-dialled every
220
+ * `retryMs` in the background; a `hi` (or the broker saying the slot is free) clears it again
221
+ * via `onPeerReachable`.
222
+ *
223
+ * @param {{ Peer?: any, peerOptions?: object, url?: string, helloMs?: number, unreachableMs?: number,
224
+ * retryMs?: number, heartbeatMs?: number, settleMs?: number }} [opts]
225
+ * `Peer`: the PeerJS constructor (default: `globalThis.Peer`, else the ESM build from jsDelivr).
226
+ */
227
+ export function peerjsCloud(opts = {}) {
228
+ const T = {
229
+ hello: opts.helloMs || 5000,
230
+ unreachable: opts.unreachableMs || 10000,
231
+ retry: opts.retryMs || 10000,
232
+ heartbeat: opts.heartbeatMs || 3000,
233
+ settle: opts.settleMs || 1500,
234
+ };
235
+ const loadPeer = async () => {
236
+ if (opts.Peer) return opts.Peer;
237
+ if (globalThis.Peer) return globalThis.Peer;
238
+ const mod = await import(/* @vite-ignore */ opts.url || 'https://cdn.jsdelivr.net/npm/peerjs@1.5.4/+esm');
239
+ return mod.Peer || mod.default;
240
+ };
241
+ return {
242
+ name: 'peerjs',
243
+ async join(room, hooks) {
244
+ const Peer = await loadPeer();
245
+ const tag = (await roomKey(room)).slice(0, 24);
246
+ const max = hooks.maxPeers || 4;
247
+ const nSlots = max * 2; // zombies must never make a room look full
248
+ const slotId = (k) => `dxrcall-${tag}-${k}`;
249
+
250
+ let peer = null;
251
+ let mySlot = -1;
252
+ for (let k = 0; k < nSlots && !peer; k++) {
253
+ peer = await new Promise((resolve) => {
254
+ const p = new Peer(slotId(k), opts.peerOptions || {});
255
+ const onErr = (e) => {
256
+ if (e && e.type === 'unavailable-id') {
257
+ p.destroy();
258
+ resolve(null);
259
+ }
260
+ };
261
+ p.on('error', onErr);
262
+ p.on('open', () => {
263
+ p.off?.('error', onErr);
264
+ mySlot = k;
265
+ resolve(p);
266
+ });
267
+ });
268
+ }
269
+ if (!peer) throw codedError('room-full', `every slot of this call is taken (${nSlots})`);
270
+
271
+ const byId = new Map(); // logical peer id -> { conn, seen }
272
+ const bySlot = new Map(); // broker id -> logical peer id
273
+ const silent = new Map(); // broker id -> { since, reported, conn }
274
+ let left = false;
275
+
276
+ const markReachable = (brokerId) => {
277
+ const s = silent.get(brokerId);
278
+ if (!s) return;
279
+ silent.delete(brokerId);
280
+ if (s.reported) hooks.onPeerReachable?.(`slot-${brokerId.split('-').pop()}`);
281
+ };
282
+ const wire = (conn) => {
283
+ conn.on('open', () => conn.send({ t: 'hi', id: hooks.id }));
284
+ conn.on('data', (msg) => {
285
+ if (!msg || typeof msg !== 'object') return;
286
+ const known = bySlot.get(conn.peer);
287
+ if (msg.t === 'hi' && typeof msg.id === 'string') {
288
+ markReachable(conn.peer);
289
+ if (!known) {
290
+ bySlot.set(conn.peer, msg.id);
291
+ const prev = byId.get(msg.id);
292
+ byId.set(msg.id, { conn, seen: Date.now() });
293
+ if (!prev) hooks.onPeerJoined?.(msg.id);
294
+ if (conn.open) conn.send({ t: 'hi', id: hooks.id });
295
+ }
296
+ return;
297
+ }
298
+ if (!known) return;
299
+ const rec = byId.get(known);
300
+ if (rec) rec.seen = Date.now();
301
+ if (msg.t === 'signal') hooks.onSignal?.(known, msg.data);
302
+ else if (msg.t === 'bye') drop(conn.peer);
303
+ });
304
+ conn.on('close', () => drop(conn.peer));
305
+ conn.on('error', () => {});
306
+ };
307
+ const drop = (brokerId) => {
308
+ const id = bySlot.get(brokerId);
309
+ if (!id) return;
310
+ bySlot.delete(brokerId);
311
+ byId.delete(id);
312
+ hooks.onPeerLeft?.(id);
313
+ };
314
+ const dial = (k) => {
315
+ const b = slotId(k);
316
+ if (!silent.has(b)) silent.set(b, { since: Date.now(), reported: false, conn: null });
317
+ const c = peer.connect(b, { reliable: true });
318
+ silent.get(b).conn = c;
319
+ wire(c);
320
+ };
321
+
322
+ peer.on('connection', wire);
323
+ peer.on('error', (e) => {
324
+ // 'peer-unavailable' names the broker id: that slot is FREE, so it is not a participant.
325
+ const m = e && e.type === 'peer-unavailable' && String(e.message || '').match(/(dxrcall-[0-9a-f]+-\d+)/);
326
+ if (m) markReachable(m[1]);
327
+ });
328
+ for (let k = 0; k < nSlots; k++) if (k !== mySlot) dial(k);
329
+
330
+ const hb = setInterval(() => {
331
+ const now = Date.now();
332
+ for (const [id, rec] of byId) {
333
+ if (now - rec.seen > 4 * T.heartbeat) {
334
+ for (const [b, pid] of bySlot) if (pid === id) drop(b);
335
+ } else if (rec.conn.open) rec.conn.send({ t: 'hb' });
336
+ }
337
+ for (const [b, st] of silent) {
338
+ // Silent past the hello window → not counted; past the unreachable window → reported.
339
+ if (!st.reported && now - st.since > T.unreachable) {
340
+ st.reported = true;
341
+ hooks.onPeerUnreachable?.(`slot-${b.split('-').pop()}`);
342
+ }
343
+ if (now - (st.lastDial || st.since) > T.retry) {
344
+ st.lastDial = now;
345
+ try {
346
+ if (st.conn && !st.conn.open) st.conn.close(); // one pending attempt per slot
347
+ } catch {
348
+ /* ignore */
349
+ }
350
+ st.conn = peer.connect(b, { reliable: true }); // background retry of a silent slot
351
+ wire(st.conn);
352
+ }
353
+ }
354
+ }, Math.min(T.heartbeat, 1000));
355
+
356
+ // Free this page's slot the moment it goes away — otherwise a reload leaves a zombie.
357
+ const unload = () => {
358
+ try {
359
+ for (const rec of byId.values()) if (rec.conn.open) rec.conn.send({ t: 'bye' });
360
+ peer.destroy();
361
+ } catch {
362
+ /* ignore */
363
+ }
364
+ };
365
+ globalThis.addEventListener?.('pagehide', unload);
366
+ globalThis.addEventListener?.('beforeunload', unload);
367
+
368
+ // Give the slot mesh a moment to say hello, so `peers` is useful. Only slots that said `hi`
369
+ // count (the transport enforces the room size on this list); late ones arrive through
370
+ // onPeerJoined like any other join.
371
+ await new Promise((r) => setTimeout(r, Math.min(T.settle, T.hello)));
372
+ // Hand the media connections the same relays PeerJS itself uses (its public TURN, or a
373
+ // page's `peerOptions.config`), so a demo call crosses the NATs a bare-PeerJS app crosses.
374
+ // Demo-grade: shared, no uptime guarantee — production TURN comes from dxrSignaling().
375
+ const relays = (peer.options?.config?.iceServers || []).filter((s) =>
376
+ [].concat(s.urls || s.url || []).some((u) => /^turns?:/.test(u)));
377
+ return {
378
+ id: hooks.id,
379
+ peers: [...byId.keys()],
380
+ iceServers: relays.length ? relays : undefined,
381
+ slot: mySlot,
382
+ send: (to, data) => {
383
+ const rec = byId.get(to);
384
+ if (rec && rec.conn.open) rec.conn.send({ t: 'signal', data });
385
+ },
386
+ leave: () => {
387
+ if (left) return;
388
+ left = true;
389
+ clearInterval(hb);
390
+ globalThis.removeEventListener?.('pagehide', unload);
391
+ globalThis.removeEventListener?.('beforeunload', unload);
392
+ for (const rec of byId.values()) if (rec.conn.open) rec.conn.send({ t: 'bye' });
393
+ setTimeout(() => peer.destroy(), 200);
394
+ },
395
+ };
396
+ },
397
+ };
398
+ }