@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,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
|
+
}
|