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