@displayxr/inline3d 1.47.5 → 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 +143 -1
- package/README.md +7 -1
- package/call.d.ts +98 -8
- package/camera.d.ts +34 -5
- package/dist/call.js +8 -6
- package/docs/content-lifecycle-rules.md +270 -0
- 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-model-entry.js +3 -0
- package/js/inline3d-model-playcanvas.js +36 -2
- package/js/inline3d-pc-auto-release.js +191 -0
- package/js/inline3d-pc-release.js +107 -0
- package/js/inline3d-pc-shader-cache.js +172 -0
- package/js/inline3d-playcanvas-engine.js +2 -1
- package/js/inline3d-player.js +8 -4
- package/js/inline3d-splat-deferred.js +2 -0
- package/js/inline3d-splat-diag.js +125 -4
- package/js/inline3d-splat-playcanvas.js +40 -3
- package/js/inline3d-splat.js +2 -0
- package/llms.txt +1 -0
- package/model.d.ts +12 -0
- package/package.json +12 -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
- package/splat.d.ts +12 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// signaling/turn-budget.mjs — TURN metering and the monthly cost ceiling (RFC 0003 §5c).
|
|
2
|
+
//
|
|
3
|
+
// TURN relay is the only real cost of the hosted service. This module answers one question per
|
|
4
|
+
// join: "may this session get TURN credentials, and for how long?" — from an org-wide monthly
|
|
5
|
+
// budget with graduated shedding:
|
|
6
|
+
//
|
|
7
|
+
// usage/budget < 70% ok everyone gets TURN at the tier's TTL
|
|
8
|
+
// 70% … 90% degraded anonymous mints get a 300 s TTL and a per-IP hourly cap of 5
|
|
9
|
+
// 90% … 100% degraded anonymous mints STOP (reason 'budget'); keyed sessions keep TURN
|
|
10
|
+
// >= 100% off nobody gets new credentials (reason 'cap') → the SDK's `turn-cap`
|
|
11
|
+
//
|
|
12
|
+
// Direct P2P keeps working throughout (the welcome simply carries no `iceServers`), and relays
|
|
13
|
+
// already allocated run until their credentials expire.
|
|
14
|
+
//
|
|
15
|
+
// Two usage figures, and which one counts:
|
|
16
|
+
// - ESTIMATED: every mint records (key, ttl). estimatedGB = Σ ttl-hours × EST_GB_PER_TTL_HOUR.
|
|
17
|
+
// Always available; crude on purpose (a credential may never relay a byte, or may relay for
|
|
18
|
+
// its whole TTL). The default 0.3 GB per TTL-hour assumes ~15% of sessions relay at ~3.5 Mbps
|
|
19
|
+
// both ways.
|
|
20
|
+
// - ACTUAL: Cloudflare exposes relay bytes per TURN key and per `customIdentifier` through the
|
|
21
|
+
// GraphQL analytics dataset `callsTurnUsageAdaptiveGroups`. A scheduled (cron) handler polls
|
|
22
|
+
// it and stores month-to-date GB (total and per key). When an ACTUAL figure is fresh (< 3 h),
|
|
23
|
+
// it is what counts; otherwise the estimate does. A test / operator OVERRIDE (admin endpoint
|
|
24
|
+
// or the TURN_FORCE_USAGE_GB var) beats both.
|
|
25
|
+
//
|
|
26
|
+
// Usage is aggregated PER KEY (plus 'anon') every month — the billing identity the business
|
|
27
|
+
// model needs — while billing itself stays off: nothing here charges anyone.
|
|
28
|
+
|
|
29
|
+
export const GB = 1e9;
|
|
30
|
+
|
|
31
|
+
/** @typedef {{ freeGB: number, capUSD: number, pricePerGB: number, shedFrac: number, stopAnonFrac: number, estGBPerTtlHour: number, actualMaxAgeMs: number, forceUsageGB: number|null }} BudgetConfig */
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_BUDGET = Object.freeze({
|
|
34
|
+
freeGB: 1000, // Cloudflare Realtime TURN free tier, per month
|
|
35
|
+
capUSD: 100, // RFC 0003 Decision 2: the hard cap beyond the free tier
|
|
36
|
+
pricePerGB: 0.05, // verify against current Cloudflare pricing when it changes
|
|
37
|
+
shedFrac: 0.7,
|
|
38
|
+
stopAnonFrac: 0.9,
|
|
39
|
+
estGBPerTtlHour: 0.3,
|
|
40
|
+
actualMaxAgeMs: 3 * 3600_000,
|
|
41
|
+
forceUsageGB: null,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const num = (v, dflt) => {
|
|
45
|
+
if (v === undefined || v === null || v === '') return dflt;
|
|
46
|
+
const n = Number(v);
|
|
47
|
+
return Number.isFinite(n) && n >= 0 ? n : dflt;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Read the budget knobs from env (TURN_FREE_GB, TURN_CAP_USD, TURN_PRICE_PER_GB, TURN_SHED_FRAC, TURN_STOP_ANON_FRAC, TURN_EST_GB_PER_TTL_HOUR, TURN_FORCE_USAGE_GB). */
|
|
51
|
+
export function budgetFromEnv(env = {}) {
|
|
52
|
+
return {
|
|
53
|
+
freeGB: num(env.TURN_FREE_GB, DEFAULT_BUDGET.freeGB),
|
|
54
|
+
capUSD: num(env.TURN_CAP_USD, DEFAULT_BUDGET.capUSD),
|
|
55
|
+
pricePerGB: num(env.TURN_PRICE_PER_GB, DEFAULT_BUDGET.pricePerGB) || DEFAULT_BUDGET.pricePerGB,
|
|
56
|
+
shedFrac: num(env.TURN_SHED_FRAC, DEFAULT_BUDGET.shedFrac),
|
|
57
|
+
stopAnonFrac: num(env.TURN_STOP_ANON_FRAC, DEFAULT_BUDGET.stopAnonFrac),
|
|
58
|
+
estGBPerTtlHour: num(env.TURN_EST_GB_PER_TTL_HOUR, DEFAULT_BUDGET.estGBPerTtlHour),
|
|
59
|
+
actualMaxAgeMs: DEFAULT_BUDGET.actualMaxAgeMs,
|
|
60
|
+
forceUsageGB: env.TURN_FORCE_USAGE_GB === undefined || env.TURN_FORCE_USAGE_GB === '' ? null : num(env.TURN_FORCE_USAGE_GB, null),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Total GB the month may use before the cap: free tier + what $cap buys. */
|
|
65
|
+
export function budgetGB(cfg) {
|
|
66
|
+
return cfg.freeGB + cfg.capUSD / cfg.pricePerGB;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function monthOf(ms) {
|
|
70
|
+
const d = new Date(ms);
|
|
71
|
+
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Month-to-date usage state. Serialisable; one instance per deployment (a 'budget' Durable
|
|
76
|
+
* Object on Cloudflare, a field of the dev server).
|
|
77
|
+
*/
|
|
78
|
+
export class TurnBudget {
|
|
79
|
+
/** @param {{ cfg?: Partial<BudgetConfig>, now?: () => number, state?: any }} [o] */
|
|
80
|
+
constructor({ cfg = {}, now = () => Date.now(), state = null } = {}) {
|
|
81
|
+
this.cfg = { ...DEFAULT_BUDGET, ...cfg };
|
|
82
|
+
this.now = now;
|
|
83
|
+
this.month = monthOf(now());
|
|
84
|
+
/** per key id (or 'anon'): { joins, mints, ttlSeconds, actualGB? } */
|
|
85
|
+
this.byKey = {};
|
|
86
|
+
this.actualGB = null;
|
|
87
|
+
this.actualAt = 0;
|
|
88
|
+
this.overrideGB = null;
|
|
89
|
+
if (state) this._load(state);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_load(s) {
|
|
93
|
+
if (!s || typeof s !== 'object') return;
|
|
94
|
+
if (s.month === this.month) {
|
|
95
|
+
if (s.byKey && typeof s.byKey === 'object') this.byKey = JSON.parse(JSON.stringify(s.byKey));
|
|
96
|
+
if (Number.isFinite(s.actualGB)) this.actualGB = s.actualGB;
|
|
97
|
+
if (Number.isFinite(s.actualAt)) this.actualAt = s.actualAt;
|
|
98
|
+
}
|
|
99
|
+
// An override survives a month rollover on purpose (it is an operator's switch, not usage).
|
|
100
|
+
if (Number.isFinite(s.overrideGB)) this.overrideGB = s.overrideGB;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
toJSON() {
|
|
104
|
+
return { month: this.month, byKey: this.byKey, actualGB: this.actualGB, actualAt: this.actualAt, overrideGB: this.overrideGB };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A new UTC month resets the counters (called before every read/write). */
|
|
108
|
+
_roll() {
|
|
109
|
+
const m = monthOf(this.now());
|
|
110
|
+
if (m !== this.month) {
|
|
111
|
+
this.month = m;
|
|
112
|
+
this.byKey = {};
|
|
113
|
+
this.actualGB = null;
|
|
114
|
+
this.actualAt = 0;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_bucket(keyId) {
|
|
119
|
+
const id = keyId || 'anon';
|
|
120
|
+
return (this.byKey[id] ??= { joins: 0, mints: 0, ttlSeconds: 0 });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
recordJoin(keyId) {
|
|
124
|
+
this._roll();
|
|
125
|
+
this._bucket(keyId).joins++;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
recordMint(keyId, ttlSeconds) {
|
|
129
|
+
this._roll();
|
|
130
|
+
const b = this._bucket(keyId);
|
|
131
|
+
b.mints++;
|
|
132
|
+
b.ttlSeconds += Math.max(0, ttlSeconds | 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The analytics poll result: total month-to-date relay GB, and per key if known. */
|
|
136
|
+
setActual(totalGB, { byKey = null, at = this.now() } = {}) {
|
|
137
|
+
this._roll();
|
|
138
|
+
this.actualGB = Number.isFinite(totalGB) ? totalGB : null;
|
|
139
|
+
this.actualAt = this.actualGB === null ? 0 : at;
|
|
140
|
+
if (byKey && typeof byKey === 'object') for (const [k, gb] of Object.entries(byKey)) if (Number.isFinite(gb)) this._bucket(k).actualGB = gb;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Operator / test override of the usage figure (null clears it). */
|
|
144
|
+
setOverride(gb) {
|
|
145
|
+
this.overrideGB = Number.isFinite(gb) ? gb : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
get estimatedGB() {
|
|
149
|
+
let h = 0;
|
|
150
|
+
for (const b of Object.values(this.byKey)) h += (b.ttlSeconds || 0) / 3600;
|
|
151
|
+
return h * this.cfg.estGBPerTtlHour;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The figure that counts right now, and where it came from. */
|
|
155
|
+
usedGB() {
|
|
156
|
+
this._roll();
|
|
157
|
+
if (Number.isFinite(this.cfg.forceUsageGB)) return { gb: this.cfg.forceUsageGB, source: 'forced' };
|
|
158
|
+
if (this.overrideGB !== null) return { gb: this.overrideGB, source: 'override' };
|
|
159
|
+
if (this.actualGB !== null && this.now() - this.actualAt < this.cfg.actualMaxAgeMs) return { gb: this.actualGB, source: 'actual' };
|
|
160
|
+
return { gb: this.estimatedGB, source: 'estimate' };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Everything an operator wants to see (admin endpoint) and what `policy()` decides on. */
|
|
164
|
+
snapshot() {
|
|
165
|
+
const { gb, source } = this.usedGB();
|
|
166
|
+
const total = budgetGB(this.cfg);
|
|
167
|
+
const fraction = total > 0 ? gb / total : 1;
|
|
168
|
+
return {
|
|
169
|
+
month: this.month,
|
|
170
|
+
usedGB: round(gb),
|
|
171
|
+
source,
|
|
172
|
+
estimatedGB: round(this.estimatedGB),
|
|
173
|
+
actualGB: this.actualGB === null ? null : round(this.actualGB),
|
|
174
|
+
actualAt: this.actualAt || null,
|
|
175
|
+
overrideGB: this.overrideGB,
|
|
176
|
+
budgetGB: total,
|
|
177
|
+
freeGB: this.cfg.freeGB,
|
|
178
|
+
capUSD: this.cfg.capUSD,
|
|
179
|
+
fraction: Math.round(fraction * 1000) / 1000,
|
|
180
|
+
status: statusFor(fraction, this.cfg),
|
|
181
|
+
byKey: this.byKey,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function round(x) {
|
|
187
|
+
return Math.round(x * 1000) / 1000;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 'ok' | 'degraded' | 'off' for a usage fraction. */
|
|
191
|
+
export function statusFor(fraction, cfg = DEFAULT_BUDGET) {
|
|
192
|
+
if (fraction >= 1) return 'off';
|
|
193
|
+
if (fraction >= cfg.shedFrac) return 'degraded';
|
|
194
|
+
return 'ok';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The per-session TURN decision. Pure: from a budget snapshot (`fraction`, or a `status` plus
|
|
199
|
+
* `fraction`) and the session's tier + limits.
|
|
200
|
+
* @param {{ fraction: number }} snap
|
|
201
|
+
* @param {'anon'|'key'} tier
|
|
202
|
+
* @param {{ turnTtl: number, mintsPerHour: number }} limits
|
|
203
|
+
* @param {BudgetConfig} [cfg]
|
|
204
|
+
* @returns {{ mint: boolean, ttl: number, mintsPerHour: number, status: 'ok'|'degraded'|'off', reason?: 'budget'|'cap' }}
|
|
205
|
+
*/
|
|
206
|
+
export function turnPolicy(snap, tier, limits, cfg = DEFAULT_BUDGET) {
|
|
207
|
+
const f = snap && Number.isFinite(snap.fraction) ? snap.fraction : 0;
|
|
208
|
+
const status = statusFor(f, cfg);
|
|
209
|
+
if (status === 'off') return { mint: false, ttl: 0, mintsPerHour: 0, status, reason: 'cap' };
|
|
210
|
+
if (tier !== 'key' && status === 'degraded') {
|
|
211
|
+
if (f >= cfg.stopAnonFrac) return { mint: false, ttl: 0, mintsPerHour: 0, status, reason: 'budget' };
|
|
212
|
+
return { mint: true, ttl: Math.min(300, limits.turnTtl), mintsPerHour: Math.min(5, limits.mintsPerHour), status };
|
|
213
|
+
}
|
|
214
|
+
return { mint: true, ttl: limits.turnTtl, mintsPerHour: limits.mintsPerHour, status };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read month-to-date relay bytes from Cloudflare's GraphQL analytics (dataset
|
|
219
|
+
* `callsTurnUsageAdaptiveGroups`, filtered to this TURN key, grouped by `customIdentifier` — which
|
|
220
|
+
* `mintTurnCredentials` sets to the session's key id). Needs an API token with the account's
|
|
221
|
+
* "Account Analytics: Read" permission (`CF_ANALYTICS_API_TOKEN`, a Worker secret) and the
|
|
222
|
+
* account id (`CF_ACCOUNT_ID`). Returns null when not configured.
|
|
223
|
+
* @returns {Promise<null | { totalGB: number, byKey: Record<string, number>, from: string, to: string }>}
|
|
224
|
+
*/
|
|
225
|
+
export async function readTurnUsage(env, { fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
|
|
226
|
+
if (!env || !env.CF_ANALYTICS_API_TOKEN || !env.CF_ACCOUNT_ID || !env.TURN_KEY_ID) return null;
|
|
227
|
+
const d = new Date(now);
|
|
228
|
+
const from = `${monthOf(now)}-01`;
|
|
229
|
+
const to = d.toISOString().slice(0, 10);
|
|
230
|
+
const query = `query ($account: string!, $key: string!, $from: Date!, $to: Date!) {
|
|
231
|
+
viewer { accounts(filter: { accountTag: $account }) {
|
|
232
|
+
callsTurnUsageAdaptiveGroups(limit: 1000, filter: { date_geq: $from, date_leq: $to, keyId: $key }) {
|
|
233
|
+
dimensions { customIdentifier }
|
|
234
|
+
sum { egressBytes ingressBytes }
|
|
235
|
+
} } } }`;
|
|
236
|
+
const res = await fetchImpl('https://api.cloudflare.com/client/v4/graphql', {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: { Authorization: `Bearer ${env.CF_ANALYTICS_API_TOKEN}`, 'Content-Type': 'application/json' },
|
|
239
|
+
body: JSON.stringify({ query, variables: { account: env.CF_ACCOUNT_ID, key: env.TURN_KEY_ID, from, to } }),
|
|
240
|
+
});
|
|
241
|
+
if (!res.ok) throw new Error(`TURN analytics: HTTP ${res.status}`);
|
|
242
|
+
const body = await res.json();
|
|
243
|
+
if (body.errors && body.errors.length) throw new Error(`TURN analytics: ${body.errors[0].message || 'GraphQL error'}`);
|
|
244
|
+
const groups = body?.data?.viewer?.accounts?.[0]?.callsTurnUsageAdaptiveGroups || [];
|
|
245
|
+
const byKey = {};
|
|
246
|
+
let total = 0;
|
|
247
|
+
for (const g of groups) {
|
|
248
|
+
const bytes = (g.sum?.egressBytes || 0) + (g.sum?.ingressBytes || 0);
|
|
249
|
+
const id = g.dimensions?.customIdentifier || 'anon';
|
|
250
|
+
byKey[id] = (byKey[id] || 0) + bytes / GB;
|
|
251
|
+
total += bytes / GB;
|
|
252
|
+
}
|
|
253
|
+
return { totalGB: total, byKey, from, to };
|
|
254
|
+
}
|
package/splat.d.ts
CHANGED
|
@@ -1135,6 +1135,18 @@ export interface SplatHandle {
|
|
|
1135
1135
|
* it after this resolves. Resolves the number of programs warmed. docs/playcanvas-adapter.md §warmEntity.
|
|
1136
1136
|
*/
|
|
1137
1137
|
warmEntity(entity: unknown, opts?: { timeoutMs?: number }): Promise<number>;
|
|
1138
|
+
/**
|
|
1139
|
+
* `engine: 'playcanvas'` only (a no-op returning `{ programs: 0, container: null }` on Spark).
|
|
1140
|
+
* Destroy `entity` — content you added under `handle.engine.root` — and release what it held on
|
|
1141
|
+
* the GPU: the shader programs its materials linked (PlayCanvas never frees a program by itself:
|
|
1142
|
+
* its ProgramLibrary caches every variant for the device's life, and `Material.destroy()` does
|
|
1143
|
+
* not destroy them), and, with `asset`, the glTF container it was instantiated from (unloaded
|
|
1144
|
+
* and taken out of the registry unless something else in the tile still draws from it). A
|
|
1145
|
+
* program another material still uses is kept. Call it for whatever a content swap replaces.
|
|
1146
|
+
* Returns the number of programs destroyed and what happened to the container.
|
|
1147
|
+
* docs/content-lifecycle-rules.md.
|
|
1148
|
+
*/
|
|
1149
|
+
releaseEntity(entity: unknown, opts?: { asset?: unknown }): { programs: number; container: 'unloaded' | 'in-use' | null };
|
|
1138
1150
|
setStereo(opts: SplatStereoOptions | null): SplatHandle;
|
|
1139
1151
|
/**
|
|
1140
1152
|
* The `depth` option's comfort factor in force, in (0, 1]: what the display rig's ipdFactor and
|