@displayxr/inline3d 1.47.6 → 1.48.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/CHANGELOG.md +81 -1
- package/call.d.ts +98 -8
- package/camera.d.ts +34 -5
- package/dist/call.js +8 -6
- package/docs/woven-canvas-rules.md +25 -13
- package/js/call/element.js +7 -3
- package/js/call/options.js +20 -3
- package/js/call/transport.js +11 -1
- package/js/call/ui.js +8 -1
- package/js/call/wire.js +163 -6
- package/js/camera/capture.js +107 -20
- package/js/camera/conv-meter.js +152 -0
- package/js/inline3d-call.js +392 -71
- package/js/inline3d-camera.js +29 -6
- package/js/inline3d-player.js +8 -4
- package/package.json +8 -1
- package/signaling/README.md +289 -0
- package/signaling/dev-server.mjs +320 -0
- package/signaling/gate.mjs +112 -0
- package/signaling/keys.mjs +123 -0
- package/signaling/limits.mjs +221 -0
- package/signaling/room.mjs +322 -0
- package/signaling/turn-budget.mjs +254 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// signaling/limits.mjs — rate limits and caps for the hosted `dxr-signal/1` service (RFC 0003 §5b).
|
|
2
|
+
// Runtime-agnostic and pure: a `Meter` is the counters of ONE subject (a salted IP hash, or a key
|
|
3
|
+
// id) — fixed-window hit counters plus "leases" (the rooms that subject currently has open). The
|
|
4
|
+
// Cloudflare Worker keeps one Durable Object per subject around a Meter; the Node dev server keeps
|
|
5
|
+
// them in a Map. Both call exactly the same code, so a limit cannot drift between them.
|
|
6
|
+
//
|
|
7
|
+
// Every number here is a starting value from the RFC table and is overridable per deployment
|
|
8
|
+
// through environment variables (limitsFromEnv) and per key through the key record (`limits`).
|
|
9
|
+
|
|
10
|
+
/** @typedef {{ joinsPerMin: number, rooms: number, roomTtlS: number, mintsPerHour: number, turnTtl: number }} TierLimits */
|
|
11
|
+
|
|
12
|
+
/** RFC 0003 §5b starting values. `turnTtl` is §5a (anonymous 600 s, keyed 3600 s). */
|
|
13
|
+
export const DEFAULT_LIMITS = Object.freeze({
|
|
14
|
+
anon: Object.freeze({ joinsPerMin: 20, rooms: 5, roomTtlS: 2 * 3600, mintsPerHour: 30, turnTtl: 600 }),
|
|
15
|
+
key: Object.freeze({ joinsPerMin: 200, rooms: 50, roomTtlS: 8 * 3600, mintsPerHour: 300, turnTtl: 3600 }),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** Env var names, per tier, in `DEFAULT_LIMITS` key order (documented in README.md). */
|
|
19
|
+
const ENV_NAMES = {
|
|
20
|
+
anon: { joinsPerMin: 'ANON_JOINS_PER_MIN', rooms: 'ANON_ROOMS', roomTtlS: 'ANON_ROOM_TTL', mintsPerHour: 'ANON_MINTS_PER_HOUR', turnTtl: 'ANON_TURN_TTL' },
|
|
21
|
+
key: { joinsPerMin: 'KEY_JOINS_PER_MIN', rooms: 'KEY_ROOMS', roomTtlS: 'KEY_ROOM_TTL', mintsPerHour: 'KEY_MINTS_PER_HOUR', turnTtl: 'TURN_TTL' },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const posInt = (v, dflt) => {
|
|
25
|
+
const n = parseInt(String(v ?? ''), 10);
|
|
26
|
+
return Number.isFinite(n) && n >= 0 ? n : dflt;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Read both tiers' limits from an env object (unset vars keep the RFC defaults). */
|
|
30
|
+
export function limitsFromEnv(env = {}) {
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const tier of ['anon', 'key']) {
|
|
33
|
+
const t = {};
|
|
34
|
+
for (const [k, name] of Object.entries(ENV_NAMES[tier])) t[k] = posInt(env[name], DEFAULT_LIMITS[tier][k]);
|
|
35
|
+
t.turnTtl = Math.max(60, Math.min(86400, t.turnTtl || DEFAULT_LIMITS[tier].turnTtl));
|
|
36
|
+
out[tier] = t;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The effective limits for one session: the tier's base, then the key record's `limits`
|
|
43
|
+
* overrides (a keyed customer can be given more — or less — headroom by hand).
|
|
44
|
+
* @param {'anon'|'key'} tier
|
|
45
|
+
* @param {{limits?: Partial<TierLimits>}|null} keyRecord
|
|
46
|
+
* @param {{anon: TierLimits, key: TierLimits}} [base]
|
|
47
|
+
* @returns {TierLimits}
|
|
48
|
+
*/
|
|
49
|
+
export function limitsFor(tier, keyRecord, base = DEFAULT_LIMITS) {
|
|
50
|
+
const t = { ...(base[tier] || DEFAULT_LIMITS[tier]) };
|
|
51
|
+
const o = tier === 'key' && keyRecord && keyRecord.limits && typeof keyRecord.limits === 'object' ? keyRecord.limits : null;
|
|
52
|
+
if (o) for (const k of Object.keys(t)) if (Number.isFinite(+o[k]) && +o[k] >= 0) t[k] = Math.floor(+o[k]);
|
|
53
|
+
t.turnTtl = Math.max(60, Math.min(86400, t.turnTtl || DEFAULT_LIMITS[tier].turnTtl));
|
|
54
|
+
return t;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const MINUTE_MS = 60_000;
|
|
58
|
+
export const HOUR_MS = 3_600_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The counters of one subject. Serialisable (`toJSON` / `Meter.from`) so a Durable Object can
|
|
62
|
+
* persist it between requests and across hibernation.
|
|
63
|
+
*/
|
|
64
|
+
export class Meter {
|
|
65
|
+
/** @param {{ now?: () => number, state?: any }} [o] */
|
|
66
|
+
constructor({ now = () => Date.now(), state = null } = {}) {
|
|
67
|
+
this.now = now;
|
|
68
|
+
/** fixed windows: name -> { at: windowStartMs, count } */
|
|
69
|
+
this.windows = new Map();
|
|
70
|
+
/** leases: roomKey -> { count, expiresAt } — the rooms this subject currently has open */
|
|
71
|
+
this.leases = new Map();
|
|
72
|
+
if (state) this._load(state);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_load(s) {
|
|
76
|
+
if (s && s.windows && typeof s.windows === 'object') for (const [k, v] of Object.entries(s.windows)) if (v && Number.isFinite(v.at)) this.windows.set(k, { at: v.at, count: v.count | 0 });
|
|
77
|
+
if (s && s.leases && typeof s.leases === 'object') for (const [k, v] of Object.entries(s.leases)) if (v && Number.isFinite(v.expiresAt)) this.leases.set(k, { count: v.count | 0, expiresAt: v.expiresAt });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
toJSON() {
|
|
81
|
+
const t = this.now();
|
|
82
|
+
const windows = {};
|
|
83
|
+
for (const [k, v] of this.windows) if (t - v.at < HOUR_MS * 2) windows[k] = v; // nothing lives longer than an hour window
|
|
84
|
+
const leases = {};
|
|
85
|
+
for (const [k, v] of this.leases) if (v.expiresAt > t && v.count > 0) leases[k] = v;
|
|
86
|
+
return { windows, leases };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static from(json, now) {
|
|
90
|
+
return new Meter({ now, state: json });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Count one event in a fixed window. `ok` is false once the window's count exceeds `limit`
|
|
95
|
+
* (the event is still counted: a flood keeps its own window hot).
|
|
96
|
+
* @returns {{ ok: boolean, count: number, limit: number, retryMs: number }}
|
|
97
|
+
*/
|
|
98
|
+
hit(name, windowMs, limit) {
|
|
99
|
+
const t = this.now();
|
|
100
|
+
let w = this.windows.get(name);
|
|
101
|
+
if (!w || t - w.at >= windowMs) {
|
|
102
|
+
w = { at: t, count: 0 };
|
|
103
|
+
this.windows.set(name, w);
|
|
104
|
+
}
|
|
105
|
+
w.count++;
|
|
106
|
+
const ok = w.count <= limit;
|
|
107
|
+
return { ok, count: w.count, limit, retryMs: ok ? 0 : Math.max(1, windowMs - (t - w.at)) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Current count in a window without counting (for diagnostics). */
|
|
111
|
+
peek(name, windowMs) {
|
|
112
|
+
const w = this.windows.get(name);
|
|
113
|
+
return w && this.now() - w.at < windowMs ? w.count : 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_prune() {
|
|
117
|
+
const t = this.now();
|
|
118
|
+
for (const [k, v] of this.leases) if (v.expiresAt <= t || v.count <= 0) this.leases.delete(k);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Distinct rooms this subject holds open (expired leases pruned). */
|
|
122
|
+
rooms() {
|
|
123
|
+
this._prune();
|
|
124
|
+
return this.leases.size;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Take a lease on `roomKey` (a connection into that room). Opening a NEW room is refused once
|
|
129
|
+
* the subject already holds `limit` distinct rooms; a second connection into a room it already
|
|
130
|
+
* holds is always allowed (that is the same room, not a new one). Leases expire by themselves
|
|
131
|
+
* after `ttlMs`, so a lost `release` (an evicted object, a crashed server) cannot wedge a
|
|
132
|
+
* subject for ever.
|
|
133
|
+
* @returns {{ ok: boolean, rooms: number, limit: number }}
|
|
134
|
+
*/
|
|
135
|
+
acquire(roomKey, limit, ttlMs) {
|
|
136
|
+
this._prune();
|
|
137
|
+
const have = this.leases.get(roomKey);
|
|
138
|
+
if (!have && this.leases.size >= limit) return { ok: false, rooms: this.leases.size, limit };
|
|
139
|
+
const expiresAt = this.now() + Math.max(1000, ttlMs | 0 || HOUR_MS);
|
|
140
|
+
if (have) {
|
|
141
|
+
have.count++;
|
|
142
|
+
have.expiresAt = Math.max(have.expiresAt, expiresAt);
|
|
143
|
+
} else this.leases.set(roomKey, { count: 1, expiresAt });
|
|
144
|
+
return { ok: true, rooms: this.leases.size, limit };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Give a lease back. Idempotent for a room that is not held. */
|
|
148
|
+
release(roomKey) {
|
|
149
|
+
const have = this.leases.get(roomKey);
|
|
150
|
+
if (!have) return;
|
|
151
|
+
if (--have.count <= 0) this.leases.delete(roomKey);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* One round trip for a join: the join-rate window, then the room lease — in that order, so a
|
|
156
|
+
* flood is answered `rate-limited` before it can even ask about rooms, and a refused join never
|
|
157
|
+
* takes a lease.
|
|
158
|
+
* @param {{ roomKey: string, tierName: string, joinsPerMin: number, rooms?: number, leaseTtlMs?: number }} o
|
|
159
|
+
* `rooms` absent = do not lease here (the caller leases on another subject)
|
|
160
|
+
* @returns {{ ok: true } | { ok: false, code: 'rate-limited'|'quota', retryMs: number, message: string }}
|
|
161
|
+
*/
|
|
162
|
+
admit({ roomKey, tierName, joinsPerMin, rooms, leaseTtlMs }) {
|
|
163
|
+
const r = this.hit(`joins.${tierName}`, MINUTE_MS, joinsPerMin);
|
|
164
|
+
if (!r.ok) return { ok: false, code: 'rate-limited', retryMs: r.retryMs, message: `too many joins (${r.limit}/min); retry in ${Math.ceil(r.retryMs / 1000)} s` };
|
|
165
|
+
if (rooms !== undefined) {
|
|
166
|
+
const l = this.acquire(roomKey, rooms, leaseTtlMs);
|
|
167
|
+
if (!l.ok) return { ok: false, code: 'quota', retryMs: 0, message: `too many open rooms (${l.limit}); leave one first` };
|
|
168
|
+
}
|
|
169
|
+
return { ok: true };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Run a serialised command (the Durable Object and the dev server speak this one vocabulary).
|
|
174
|
+
* @param {{op: string} & Record<string, any>} cmd
|
|
175
|
+
*/
|
|
176
|
+
exec(cmd) {
|
|
177
|
+
switch (cmd && cmd.op) {
|
|
178
|
+
case 'admit':
|
|
179
|
+
return this.admit(cmd);
|
|
180
|
+
case 'hit':
|
|
181
|
+
return this.hit(cmd.name, cmd.windowMs, cmd.limit);
|
|
182
|
+
case 'acquire':
|
|
183
|
+
return this.acquire(cmd.roomKey, cmd.limit, cmd.ttlMs);
|
|
184
|
+
case 'release':
|
|
185
|
+
this.release(cmd.roomKey);
|
|
186
|
+
return { ok: true, rooms: this.rooms() };
|
|
187
|
+
case 'peek':
|
|
188
|
+
return { rooms: this.rooms(), windows: Object.fromEntries([...this.windows].map(([k, v]) => [k, v.count])) };
|
|
189
|
+
default:
|
|
190
|
+
return { ok: false, code: 'bad-op' };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The subject id for an IP: a salted SHA-256 prefix, so rate-limit state never holds a raw
|
|
197
|
+
* address (RFC 0003 §5e). The salt is a deployment var (`RATE_SALT`); rotating it forgets every
|
|
198
|
+
* counter, which is harmless.
|
|
199
|
+
*/
|
|
200
|
+
export async function subjectForIp(ip, salt = '') {
|
|
201
|
+
const bytes = new TextEncoder().encode(`${salt}|${ip || ''}`);
|
|
202
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
|
|
203
|
+
return 'ip:' + [...new Uint8Array(digest)].slice(0, 12).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Close codes the server uses for refusals (documented in README.md). */
|
|
207
|
+
export const CLOSE_CODES = Object.freeze({
|
|
208
|
+
'bad-message': 4000,
|
|
209
|
+
'bad-version': 4000,
|
|
210
|
+
'bad-room': 4000,
|
|
211
|
+
'bad-id': 4000,
|
|
212
|
+
'id-taken': 4000,
|
|
213
|
+
'too-big': 4000,
|
|
214
|
+
'bad-key': 4001,
|
|
215
|
+
'origin-not-allowed': 4001,
|
|
216
|
+
full: 4003,
|
|
217
|
+
expired: 4004,
|
|
218
|
+
'rate-limited': 4029,
|
|
219
|
+
quota: 4030,
|
|
220
|
+
blocked: 4040,
|
|
221
|
+
});
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// signaling/room.mjs — the `dxr-signal/1` room logic, shared VERBATIM by the Cloudflare Worker's
|
|
2
|
+
// Durable Object (worker.mjs) and the Node dev server (dev-server.mjs). Runtime-agnostic: no
|
|
3
|
+
// sockets here, just "a connection said this; tell these connections that". The protocol is
|
|
4
|
+
// documented in ./README.md.
|
|
5
|
+
//
|
|
6
|
+
// What this server knows: a room id, random per-join peer ids, and the SDP/ICE blobs it relays.
|
|
7
|
+
// It never sees media (DTLS-SRTP is peer-to-peer) and holds nothing after the last peer leaves.
|
|
8
|
+
|
|
9
|
+
import { CLOSE_CODES } from './limits.mjs';
|
|
10
|
+
|
|
11
|
+
export const PROTOCOL = 'dxr-signal/1';
|
|
12
|
+
export const ROOM_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
13
|
+
export const PEER_ID_RE = /^[A-Za-z0-9_-]{8,32}$/;
|
|
14
|
+
/** Default room size, and the largest a server will allow (full mesh stops scaling at 4). */
|
|
15
|
+
export const DEFAULT_ROOM_CAP = 4;
|
|
16
|
+
export const MAX_ROOM_CAP = 8;
|
|
17
|
+
/** A single signalling message is a JSON SDP or candidate — 64 KiB is generous. */
|
|
18
|
+
export const MAX_MESSAGE_BYTES = 64 * 1024;
|
|
19
|
+
/** Per-connection flood guard: at most this many messages per window. */
|
|
20
|
+
export const RATE_LIMIT = { messages: 300, windowMs: 10_000 };
|
|
21
|
+
|
|
22
|
+
/** SHA-256 of the room id, hex — the only room-derived value that appears in a URL. */
|
|
23
|
+
export async function roomKey(room) {
|
|
24
|
+
const bytes = new TextEncoder().encode(String(room));
|
|
25
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
|
|
26
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One room. `conn` is anything with `send(obj)` and `close(code, reason)`; the host runtime owns
|
|
31
|
+
* the socket and calls `onMessage(conn, text)` / `onClose(conn)`.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} opts
|
|
34
|
+
* @param {string} opts.key the roomKey this room was addressed by (join must match it)
|
|
35
|
+
* @param {number} [opts.cap] server-side participant cap (clamped to MAX_ROOM_CAP)
|
|
36
|
+
* @param {(info: object|null) => Promise<object[]|null>} [opts.iceServers] mints short-lived
|
|
37
|
+
* TURN credentials per join (given the connection's admission session, see gate.mjs, whose
|
|
38
|
+
* `turn.ttl` is the TTL to mint); null/absent = the client uses its own defaults (public STUN)
|
|
39
|
+
* @param {{ onFirst?: (info: object|null) => void, onEmpty?: () => void }} [opts.hooks] the room
|
|
40
|
+
* went 0→1 peers (start its lifetime clock) / 1→0 (stop it)
|
|
41
|
+
* @param {() => number} [opts.now]
|
|
42
|
+
*/
|
|
43
|
+
export class Room {
|
|
44
|
+
constructor({ key, cap = DEFAULT_ROOM_CAP, iceServers = null, hooks = {}, now = () => Date.now() } = {}) {
|
|
45
|
+
this.key = key || null;
|
|
46
|
+
this.cap = Math.max(2, Math.min(MAX_ROOM_CAP, cap | 0 || DEFAULT_ROOM_CAP));
|
|
47
|
+
this.max = null; // set by the first joiner (<= cap)
|
|
48
|
+
this.peers = new Map(); // id -> conn
|
|
49
|
+
this.meta = new WeakMap(); // conn -> { id, count, windowAt, session }
|
|
50
|
+
this.iceServers = iceServers;
|
|
51
|
+
this.hooks = hooks || {};
|
|
52
|
+
this.now = now;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get size() {
|
|
56
|
+
return this.peers.size;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_meta(conn) {
|
|
60
|
+
let m = this.meta.get(conn);
|
|
61
|
+
if (!m) {
|
|
62
|
+
m = { id: null, count: 0, windowAt: this.now(), session: null, pending: null };
|
|
63
|
+
this.meta.set(conn, m);
|
|
64
|
+
}
|
|
65
|
+
return m;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Record what a connection was admitted as (gate.mjs `Session`: tier, key id, TURN decision,
|
|
70
|
+
* room lifetime). Call before its first message; absent = anonymous, TURN per server config.
|
|
71
|
+
*/
|
|
72
|
+
attach(conn, session) {
|
|
73
|
+
this._meta(conn).session = session || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Re-attach a connection that already joined (Durable Object wake from hibernation). */
|
|
77
|
+
restore(conn, id, session = null) {
|
|
78
|
+
if (!PEER_ID_RE.test(id)) return;
|
|
79
|
+
this.peers.set(id, conn);
|
|
80
|
+
this.meta.set(conn, { id, count: 0, windowAt: this.now(), session: session || null, pending: null });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Send to a peer, respecting the protocol's ordering rule: `welcome` is the FIRST message a
|
|
85
|
+
* joined peer receives. Between a peer's slot being reserved and its welcome going out there
|
|
86
|
+
* can be an await (the TURN mint is a network call), and in that window another peer's
|
|
87
|
+
* `peer-joined` / `signal` / `peer-left` may be addressed to it. Those are held here and
|
|
88
|
+
* flushed, in order, right behind the welcome.
|
|
89
|
+
*/
|
|
90
|
+
_deliver(conn, obj) {
|
|
91
|
+
const m = conn && this.meta.get(conn);
|
|
92
|
+
if (m && m.pending) m.pending.push(obj);
|
|
93
|
+
else safeSend(conn, obj);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async onMessage(conn, text) {
|
|
97
|
+
if (typeof text !== 'string') text = String(text);
|
|
98
|
+
if (text.length > MAX_MESSAGE_BYTES) return this._fail(conn, 'too-big', 'message exceeds 64 KiB');
|
|
99
|
+
let msg;
|
|
100
|
+
try {
|
|
101
|
+
msg = JSON.parse(text);
|
|
102
|
+
} catch {
|
|
103
|
+
return this._fail(conn, 'bad-message', 'not JSON');
|
|
104
|
+
}
|
|
105
|
+
if (!msg || typeof msg !== 'object') return this._fail(conn, 'bad-message', 'not an object');
|
|
106
|
+
const m = this._meta(conn);
|
|
107
|
+
const t = this.now();
|
|
108
|
+
if (t - m.windowAt > RATE_LIMIT.windowMs) {
|
|
109
|
+
m.windowAt = t;
|
|
110
|
+
m.count = 0;
|
|
111
|
+
}
|
|
112
|
+
if (++m.count > RATE_LIMIT.messages) return this._fail(conn, 'rate-limited', 'slow down', true);
|
|
113
|
+
|
|
114
|
+
switch (msg.t) {
|
|
115
|
+
case 'ping':
|
|
116
|
+
return this._deliver(conn, { t: 'pong' });
|
|
117
|
+
case 'join':
|
|
118
|
+
return this._join(conn, m, msg);
|
|
119
|
+
case 'signal':
|
|
120
|
+
// A peer is "joined" once its welcome is out; until then it has nothing to signal about.
|
|
121
|
+
if (!m.id || m.pending) return this._fail(conn, 'not-joined', 'join first');
|
|
122
|
+
if (typeof msg.to !== 'string' || !this.peers.has(msg.to) || msg.to === m.id) {
|
|
123
|
+
return safeSend(conn, { t: 'error', code: 'no-such-peer', to: msg.to ?? null });
|
|
124
|
+
}
|
|
125
|
+
return this._deliver(this.peers.get(msg.to), { t: 'signal', from: m.id, data: msg.data ?? null });
|
|
126
|
+
case 'leave':
|
|
127
|
+
this.onClose(conn);
|
|
128
|
+
try {
|
|
129
|
+
conn.close(1000, 'left');
|
|
130
|
+
} catch {
|
|
131
|
+
/* already closed */
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
default:
|
|
135
|
+
return this._fail(conn, 'bad-message', `unknown type "${msg.t}"`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async _join(conn, m, msg) {
|
|
140
|
+
if (m.id) return this._fail(conn, 'already-joined', 'one join per connection');
|
|
141
|
+
if (msg.v !== undefined && msg.v !== 1) return this._fail(conn, 'bad-version', `server speaks ${PROTOCOL}`, true);
|
|
142
|
+
if (!ROOM_ID_RE.test(msg.room || '')) return this._fail(conn, 'bad-room', 'room id must be 16-64 base64url chars', true);
|
|
143
|
+
if (this.key && (await roomKey(msg.room)) !== this.key) return this._fail(conn, 'bad-room', 'room does not match its key', true);
|
|
144
|
+
if (!PEER_ID_RE.test(msg.id || '')) return this._fail(conn, 'bad-id', 'peer id must be 8-32 base64url chars', true);
|
|
145
|
+
if (this.peers.has(msg.id)) return this._fail(conn, 'id-taken', 'that peer id is in the room', true);
|
|
146
|
+
if (this.max === null || this.peers.size === 0) {
|
|
147
|
+
const want = Number.isInteger(msg.max) ? msg.max : DEFAULT_ROOM_CAP;
|
|
148
|
+
this.max = Math.max(2, Math.min(this.cap, want));
|
|
149
|
+
}
|
|
150
|
+
if (this.peers.size >= this.max) {
|
|
151
|
+
safeSend(conn, { t: 'full', max: this.max });
|
|
152
|
+
try {
|
|
153
|
+
conn.close(4003, 'room full');
|
|
154
|
+
} catch {
|
|
155
|
+
/* ignore */
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Reserve the slot NOW (synchronously), so a burst cannot oversubscribe the room or mint TURN
|
|
160
|
+
// credentials for peers that will be turned away — and open the hold-back queue: nothing
|
|
161
|
+
// reaches this peer before its own welcome (see _deliver). Peers that are already welcomed do
|
|
162
|
+
// not know this one, so they are told here; peers still waiting for their welcome will find
|
|
163
|
+
// it in their roster, which is read at send time. Either way each pair hears of the other
|
|
164
|
+
// exactly once.
|
|
165
|
+
const first = this.peers.size === 0;
|
|
166
|
+
for (const [, other] of this.peers) {
|
|
167
|
+
const om = this.meta.get(other);
|
|
168
|
+
if (!om || !om.pending) safeSend(other, { t: 'peer-joined', id: msg.id });
|
|
169
|
+
}
|
|
170
|
+
m.id = msg.id;
|
|
171
|
+
m.pending = [];
|
|
172
|
+
this.peers.set(msg.id, conn);
|
|
173
|
+
const s = m.session;
|
|
174
|
+
// TURN: the admission decision (budget / tier / mint rate) says whether to mint at all and
|
|
175
|
+
// for how long; a server without TURN configured has no minter. The welcome always says
|
|
176
|
+
// which, so the SDK can tell "no relay this month" (`turn-cap`) from "self-hosted, no TURN".
|
|
177
|
+
let ice = null;
|
|
178
|
+
/** @type {{status: string, reason?: string, ttl?: number}} */
|
|
179
|
+
let turn = { status: 'off', reason: 'unconfigured' };
|
|
180
|
+
if (this.iceServers) {
|
|
181
|
+
const want = s && s.turn ? s.turn : { mint: true, ttl: 0, status: 'ok' };
|
|
182
|
+
if (!want.mint) turn = { status: want.status || 'off', reason: want.reason || 'budget' };
|
|
183
|
+
else {
|
|
184
|
+
try {
|
|
185
|
+
ice = await this.iceServers(s);
|
|
186
|
+
turn = ice ? { status: want.status || 'ok', ttl: want.ttl || undefined } : { status: 'off', reason: 'unconfigured' };
|
|
187
|
+
} catch {
|
|
188
|
+
ice = null; // TURN minting failed: the client falls back to STUN, the call may still work
|
|
189
|
+
turn = { status: want.status || 'ok', reason: 'mint-failed' };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// The peer may have gone (closed, expired) while the mint was in flight: then its slot is
|
|
194
|
+
// already released and the others already heard `peer-left`.
|
|
195
|
+
if (this.peers.get(msg.id) !== conn || m.id !== msg.id) return;
|
|
196
|
+
const held = m.pending || [];
|
|
197
|
+
m.pending = null;
|
|
198
|
+
// The roster is read HERE, not at reservation: it is everyone in the room as this welcome
|
|
199
|
+
// goes out, including peers that joined during the mint.
|
|
200
|
+
const others = [...this.peers.keys()].filter((id) => id !== msg.id);
|
|
201
|
+
const welcome = { t: 'welcome', v: 1, id: msg.id, peers: others, max: this.max, tier: s ? s.tier : 'anon', turn };
|
|
202
|
+
if (s && s.key) welcome.key = s.key;
|
|
203
|
+
if (ice) welcome.iceServers = ice;
|
|
204
|
+
safeSend(conn, welcome);
|
|
205
|
+
// What was held during the mint, minus what the roster already says: a `peer-joined` for
|
|
206
|
+
// someone listed in `peers` would announce them twice.
|
|
207
|
+
const listed = new Set(others);
|
|
208
|
+
for (const obj of held) {
|
|
209
|
+
if (obj && obj.t === 'peer-joined' && listed.has(obj.id)) continue;
|
|
210
|
+
safeSend(conn, obj);
|
|
211
|
+
}
|
|
212
|
+
if (first) this.hooks.onFirst?.(s);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The socket closed (or the peer left): tell the others. Idempotent. */
|
|
216
|
+
onClose(conn) {
|
|
217
|
+
const m = this.meta.get(conn);
|
|
218
|
+
if (!m || !m.id) return;
|
|
219
|
+
const id = m.id;
|
|
220
|
+
m.id = null;
|
|
221
|
+
if (this.peers.get(id) !== conn) return;
|
|
222
|
+
this.peers.delete(id);
|
|
223
|
+
for (const other of this.peers.values()) this._deliver(other, { t: 'peer-left', id });
|
|
224
|
+
if (this.peers.size === 0) {
|
|
225
|
+
this.max = null;
|
|
226
|
+
this.hooks.onEmpty?.();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The room's lifetime is up (RFC §5b: 2 h anonymous, 8 h keyed): every peer gets `expired`
|
|
232
|
+
* and is closed (4004). Media already flowing peer-to-peer is not touched by this; the peers
|
|
233
|
+
* simply have no signalling for that room any more.
|
|
234
|
+
*/
|
|
235
|
+
expire() {
|
|
236
|
+
const conns = [...this.peers.values()];
|
|
237
|
+
this.peers.clear();
|
|
238
|
+
this.max = null;
|
|
239
|
+
for (const conn of conns) {
|
|
240
|
+
const m = this.meta.get(conn);
|
|
241
|
+
if (m) m.id = null;
|
|
242
|
+
safeSend(conn, { t: 'expired' });
|
|
243
|
+
try {
|
|
244
|
+
conn.close(CLOSE_CODES.expired, 'expired');
|
|
245
|
+
} catch {
|
|
246
|
+
/* ignore */
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (conns.length) this.hooks.onEmpty?.();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
_fail(conn, code, message, close = false) {
|
|
253
|
+
safeSend(conn, { t: 'error', code, message });
|
|
254
|
+
if (close) {
|
|
255
|
+
this.onClose(conn);
|
|
256
|
+
try {
|
|
257
|
+
conn.close(CLOSE_CODES[code] || 4000, code);
|
|
258
|
+
} catch {
|
|
259
|
+
/* ignore */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Refuse a connection before it ever reaches a room (an admission failure, gate.mjs): one
|
|
267
|
+
* `error` message, then the close code for that error. The client learns why; nothing hangs.
|
|
268
|
+
*/
|
|
269
|
+
export function refuseConn(conn, { code, message, retryMs = 0, closeCode }) {
|
|
270
|
+
const err = { t: 'error', code, message };
|
|
271
|
+
if (retryMs > 0) err.retryMs = retryMs;
|
|
272
|
+
safeSend(conn, err);
|
|
273
|
+
try {
|
|
274
|
+
conn.close(closeCode || CLOSE_CODES[code] || 4000, code);
|
|
275
|
+
} catch {
|
|
276
|
+
/* ignore */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function safeSend(conn, obj) {
|
|
281
|
+
try {
|
|
282
|
+
conn.send(obj);
|
|
283
|
+
} catch {
|
|
284
|
+
/* the socket is going away; its close handler cleans up */
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Mint short-lived TURN credentials with Cloudflare Realtime TURN. Returns the `iceServers` array
|
|
290
|
+
* or null when not configured. The key id and API token live in the Worker's env (a secret), and
|
|
291
|
+
* ONLY there — clients receive credentials that expire after `ttl` seconds.
|
|
292
|
+
*
|
|
293
|
+
* `customIdentifier` tags the credential with the session's key id ('anon' otherwise), which is
|
|
294
|
+
* how Cloudflare's TURN analytics attribute relay bytes per key (turn-budget.mjs readTurnUsage).
|
|
295
|
+
* If the API ever rejects that field, the mint is retried once without it.
|
|
296
|
+
*
|
|
297
|
+
* @param {{TURN_KEY_ID?: string, TURN_KEY_API_TOKEN?: string, TURN_TTL?: string}} env
|
|
298
|
+
* @param {typeof fetch} [fetchImpl]
|
|
299
|
+
* @param {{ ttl?: number, customIdentifier?: string }} [o] `ttl` overrides env.TURN_TTL
|
|
300
|
+
*/
|
|
301
|
+
export async function mintTurnCredentials(env, fetchImpl = globalThis.fetch, o = {}) {
|
|
302
|
+
if (!env || !env.TURN_KEY_ID || !env.TURN_KEY_API_TOKEN) return null;
|
|
303
|
+
const ttl = Math.max(60, Math.min(86400, (o.ttl > 0 ? o.ttl | 0 : 0) || parseInt(env.TURN_TTL || '3600', 10) || 3600));
|
|
304
|
+
const url = `https://rtc.live.cloudflare.com/v1/turn/keys/${encodeURIComponent(env.TURN_KEY_ID)}/credentials/generate-ice-servers`;
|
|
305
|
+
const post = (body) =>
|
|
306
|
+
fetchImpl(url, {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers: { Authorization: `Bearer ${env.TURN_KEY_API_TOKEN}`, 'Content-Type': 'application/json' },
|
|
309
|
+
body: JSON.stringify(body),
|
|
310
|
+
});
|
|
311
|
+
const tag = typeof o.customIdentifier === 'string' && o.customIdentifier ? o.customIdentifier.slice(0, 64) : '';
|
|
312
|
+
let res = await post(tag ? { ttl, customIdentifier: tag } : { ttl });
|
|
313
|
+
if (!res.ok && tag && res.status === 400) res = await post({ ttl });
|
|
314
|
+
if (!res.ok) throw new Error(`TURN credential mint failed: HTTP ${res.status}`);
|
|
315
|
+
const body = await res.json();
|
|
316
|
+
// Current API: { iceServers: [ {urls}, {urls, username, credential} ] }. The older
|
|
317
|
+
// `/credentials/generate` answered { iceServers: { urls, username, credential } } — accept both.
|
|
318
|
+
const s = body && body.iceServers;
|
|
319
|
+
if (Array.isArray(s)) return s;
|
|
320
|
+
if (s && s.urls) return [s];
|
|
321
|
+
return null;
|
|
322
|
+
}
|