@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,112 @@
|
|
|
1
|
+
// signaling/gate.mjs — admission for one `dxr-signal/1` connect: key + origin (keys.mjs), the
|
|
2
|
+
// blocklist, join rate + room leases (limits.mjs), and the TURN decision (turn-budget.mjs).
|
|
3
|
+
// Shared verbatim by the Cloudflare Worker (worker.mjs) and the Node dev server (dev-server.mjs);
|
|
4
|
+
// the two only differ in where the counters live (Durable Objects vs a Map), which they inject
|
|
5
|
+
// as `meters` / `budget`.
|
|
6
|
+
//
|
|
7
|
+
// Order of checks, and why: key/origin first (a wrong key is a configuration error, answered
|
|
8
|
+
// before any counter moves) → blocklist → join rate on the IP (a flood is refused before it can
|
|
9
|
+
// ask about rooms) → room lease on the IP (anonymous) or the key (keyed) → TURN policy from the
|
|
10
|
+
// budget, then the per-IP mint rate. Every refusal is a `{code, message, closeCode}` the caller
|
|
11
|
+
// turns into one `error` message + a close, so a client always learns WHY (never a silent hang).
|
|
12
|
+
|
|
13
|
+
import { limitsFor, CLOSE_CODES, HOUR_MS, subjectForIp } from './limits.mjs';
|
|
14
|
+
import { resolveKey } from './keys.mjs';
|
|
15
|
+
import { turnPolicy } from './turn-budget.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} Session what a connect is admitted AS (carried on the socket, never to the client verbatim)
|
|
19
|
+
* @property {'anon'|'key'} tier
|
|
20
|
+
* @property {string|null} key the key id (billing identity), null for anonymous
|
|
21
|
+
* @property {string} ipSubject salted IP hash subject (`ip:…`)
|
|
22
|
+
* @property {{ mint: boolean, ttl: number, status: string, reason?: string }} turn
|
|
23
|
+
* @property {number} roomTtlS room lifetime for a room this session opens
|
|
24
|
+
* @property {{ subject: string, roomKey: string }[]} leases what to release on close
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} o
|
|
29
|
+
* @param {{anon: object, key: object}} o.limits from limitsFromEnv()
|
|
30
|
+
* @param {{get(id: string): Promise<object|null>}|null} o.keys key store (null = keys unsupported → every key is 'bad-key')
|
|
31
|
+
* @param {{ snapshot(): Promise<{fraction: number, status: string}>|{fraction: number, status: string}, cfg: object }} o.budget
|
|
32
|
+
* @param {{ exec(subject: string, cmd: object): Promise<any>|any }} o.meters
|
|
33
|
+
* @param {{ has(ip: string): Promise<boolean>|boolean }|null} [o.blocklist]
|
|
34
|
+
* @param {string} [o.salt]
|
|
35
|
+
*/
|
|
36
|
+
export function createGate({ limits, keys, budget, meters, blocklist = null, salt = '' }) {
|
|
37
|
+
const refuse = (code, message, retryMs = 0) => ({ ok: false, code, message, closeCode: CLOSE_CODES[code] || 4000, retryMs });
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
/**
|
|
41
|
+
* @param {{ ip: string, origin: string, key: string|null, roomKey: string, wantTurn?: boolean }} req
|
|
42
|
+
* `wantTurn` false = the server has no TURN configured at all (skip the budget entirely)
|
|
43
|
+
* @returns {Promise<{ ok: true, session: Session } | { ok: false, code: string, message: string, closeCode: number, retryMs: number }>}
|
|
44
|
+
*/
|
|
45
|
+
async admit({ ip, origin, key: keyParam, roomKey, wantTurn = true }) {
|
|
46
|
+
const k = await resolveKey(keyParam || null, keys, origin || '');
|
|
47
|
+
if (!k.ok) return refuse(k.code, k.message);
|
|
48
|
+
if (blocklist && (await blocklist.has(ip || ''))) return refuse('blocked', 'this address is blocked (abuse@displayxr.org)');
|
|
49
|
+
const tier = k.tier;
|
|
50
|
+
const rec = tier === 'key' ? k.key : null;
|
|
51
|
+
const lim = limitsFor(tier, rec, limits);
|
|
52
|
+
const ipSubject = await subjectForIp(ip || '', salt);
|
|
53
|
+
const leaseTtlMs = lim.roomTtlS * 1000 + HOUR_MS; // a lease outlives the room it is for
|
|
54
|
+
/** @type {Session} */
|
|
55
|
+
const session = { tier, key: rec ? rec.id : null, ipSubject, turn: { mint: false, ttl: 0, status: 'off', reason: 'unconfigured' }, roomTtlS: lim.roomTtlS, leases: [] };
|
|
56
|
+
|
|
57
|
+
// Join rate is always per IP (the tier only picks the number); the room lease is per key
|
|
58
|
+
// for keyed sessions (a customer's quota is theirs, not their users' IPs') and per IP otherwise.
|
|
59
|
+
const anonRooms = tier === 'anon' ? lim.rooms : undefined;
|
|
60
|
+
const a = await meters.exec(ipSubject, { op: 'admit', roomKey, tierName: tier, joinsPerMin: lim.joinsPerMin, rooms: anonRooms, leaseTtlMs });
|
|
61
|
+
if (!a.ok) return refuse(a.code, a.message, a.retryMs);
|
|
62
|
+
if (anonRooms !== undefined) session.leases.push({ subject: ipSubject, roomKey });
|
|
63
|
+
if (tier === 'key') {
|
|
64
|
+
const keySubject = `key:${rec.id}`;
|
|
65
|
+
const l = await meters.exec(keySubject, { op: 'acquire', roomKey, limit: lim.rooms, ttlMs: leaseTtlMs });
|
|
66
|
+
if (!l.ok) return refuse('quota', `this key has too many open rooms (${l.limit})`);
|
|
67
|
+
session.leases.push({ subject: keySubject, roomKey });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (wantTurn) {
|
|
71
|
+
const snap = await budget.snapshot();
|
|
72
|
+
const pol = turnPolicy(snap, tier, lim, budget.cfg);
|
|
73
|
+
session.turn = { mint: pol.mint, ttl: pol.ttl, status: pol.status };
|
|
74
|
+
if (pol.reason) session.turn.reason = pol.reason;
|
|
75
|
+
if (pol.mint) {
|
|
76
|
+
// Mint rate per IP per hour (RFC §5b): over it, the session still joins — STUN-only.
|
|
77
|
+
const m = await meters.exec(ipSubject, { op: 'hit', name: `mints.${tier}`, windowMs: HOUR_MS, limit: pol.mintsPerHour });
|
|
78
|
+
if (!m.ok) session.turn = { mint: false, ttl: 0, status: pol.status, reason: 'rate' };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, session };
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/** Give every lease of a session back (socket closed). Safe to call twice. */
|
|
85
|
+
async release(session) {
|
|
86
|
+
if (!session || !Array.isArray(session.leases)) return;
|
|
87
|
+
const leases = session.leases;
|
|
88
|
+
session.leases = [];
|
|
89
|
+
for (const { subject, roomKey } of leases) {
|
|
90
|
+
try {
|
|
91
|
+
await meters.exec(subject, { op: 'release', roomKey });
|
|
92
|
+
} catch {
|
|
93
|
+
/* the lease expires on its own */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A blocklist over a KV namespace (`block:<ip>` present = blocked) — or null. */
|
|
101
|
+
export function kvBlocklist(kv) {
|
|
102
|
+
if (!kv) return null;
|
|
103
|
+
return {
|
|
104
|
+
async has(ip) {
|
|
105
|
+
try {
|
|
106
|
+
return !!(ip && (await kv.get(`block:${ip}`, { cacheTtl: 60 })));
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// signaling/keys.mjs — publishable keys for the hosted `dxr-signal/1` service (RFC 0003 §5a).
|
|
2
|
+
//
|
|
3
|
+
// A key is PUBLIC: it sits in page source, so it is attribution + a quota bucket + an origin
|
|
4
|
+
// binding that browsers enforce, never a secret. The record lives server-side (a KV namespace on
|
|
5
|
+
// Cloudflare: `key:<id>` → JSON; a Map on the dev server), is issued by hand (tools/signal-keys.mjs),
|
|
6
|
+
// and is checked on every connect against the request's `Origin`. Every session carries its key
|
|
7
|
+
// id so usage is attributable per key from day one (billing identity; billing itself is OFF).
|
|
8
|
+
|
|
9
|
+
/** `pk_` + 20–40 base62 chars. The id IS the key (there is no secret half). */
|
|
10
|
+
export const KEY_RE = /^pk_[A-Za-z0-9]{20,40}$/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} KeyRecord
|
|
14
|
+
* @property {string} id the key itself (`pk_…`)
|
|
15
|
+
* @property {string[]} origins allowed page origins; `*` suffix wildcards one label level
|
|
16
|
+
* (`https://*.example.com`), `http://localhost` matches any port
|
|
17
|
+
* @property {string} [note] who / what it was issued for (never shown to clients)
|
|
18
|
+
* @property {string} [createdAt] ISO date
|
|
19
|
+
* @property {boolean} [revoked] a revoked key is refused like an unknown one
|
|
20
|
+
* @property {Partial<import('./limits.mjs').TierLimits>} [limits] per-key overrides
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Generate a new key id. `random` = bytes → used in Node and in the Worker alike. */
|
|
24
|
+
export function newKeyId(bytes = globalThis.crypto.getRandomValues(new Uint8Array(24))) {
|
|
25
|
+
const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
26
|
+
let s = '';
|
|
27
|
+
for (const b of bytes) s += A[b % 62];
|
|
28
|
+
return 'pk_' + s.slice(0, 24);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Parse + validate a stored record; null if it is not a usable key record. */
|
|
32
|
+
export function parseKeyRecord(id, raw) {
|
|
33
|
+
if (!KEY_RE.test(id || '')) return null;
|
|
34
|
+
let rec = raw;
|
|
35
|
+
if (typeof raw === 'string') {
|
|
36
|
+
try {
|
|
37
|
+
rec = JSON.parse(raw);
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!rec || typeof rec !== 'object') return null;
|
|
43
|
+
const origins = Array.isArray(rec.origins) ? rec.origins.filter((o) => typeof o === 'string' && o).map(normalizeOrigin) : [];
|
|
44
|
+
return {
|
|
45
|
+
id,
|
|
46
|
+
origins,
|
|
47
|
+
note: typeof rec.note === 'string' ? rec.note : '',
|
|
48
|
+
createdAt: typeof rec.createdAt === 'string' ? rec.createdAt : '',
|
|
49
|
+
revoked: !!rec.revoked,
|
|
50
|
+
limits: rec.limits && typeof rec.limits === 'object' ? { ...rec.limits } : undefined,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Lowercase scheme+host, strip a trailing slash and a default port. */
|
|
55
|
+
export function normalizeOrigin(o) {
|
|
56
|
+
let s = String(o || '')
|
|
57
|
+
.trim()
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/\/+$/, '');
|
|
60
|
+
s = s.replace(/^(https):\/\/([^/:]+):443$/, '$1://$2').replace(/^(http):\/\/([^/:]+):80$/, '$1://$2');
|
|
61
|
+
return s;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Does `origin` (the request header) match one allowed pattern?
|
|
66
|
+
* - exact: `https://app.example.com`
|
|
67
|
+
* - one-label wildcard: `https://*.example.com` (matches `a.example.com`, not `example.com`, not `a.b.example.com`)
|
|
68
|
+
* - `http://localhost` / `https://localhost` / `http://127.0.0.1`: any port (dev pages)
|
|
69
|
+
* - `*`: any origin (a key used purely for attribution / headroom)
|
|
70
|
+
* An ABSENT Origin header (a non-browser client) never matches a key with an origin list.
|
|
71
|
+
*/
|
|
72
|
+
export function originAllowed(origin, patterns) {
|
|
73
|
+
const o = normalizeOrigin(origin);
|
|
74
|
+
if (!o || o === 'null') return patterns.includes('*');
|
|
75
|
+
for (const raw of patterns) {
|
|
76
|
+
const p = normalizeOrigin(raw);
|
|
77
|
+
if (p === '*' || p === o) return true;
|
|
78
|
+
if (/^https?:\/\/(localhost|127\.0\.0\.1)$/.test(p) && (o === p || o.startsWith(p + ':'))) return true;
|
|
79
|
+
const m = /^(https?):\/\/\*\.(.+)$/.exec(p);
|
|
80
|
+
if (m) {
|
|
81
|
+
const n = new RegExp(`^${m[1]}:\\/\\/[a-z0-9-]+\\.${m[2].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(:\\d+)?$`);
|
|
82
|
+
if (n.test(o)) return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the `key` query parameter against the key store for one connect.
|
|
90
|
+
* @param {string|null} param the raw `?key=` value ('' / null = anonymous)
|
|
91
|
+
* @param {{ get(id: string): Promise<KeyRecord|null> }} store
|
|
92
|
+
* @param {string} origin the request's Origin header
|
|
93
|
+
* @returns {Promise<{ ok: true, tier: 'anon' } | { ok: true, tier: 'key', key: KeyRecord } | { ok: false, code: 'bad-key'|'origin-not-allowed', message: string }>}
|
|
94
|
+
*/
|
|
95
|
+
export async function resolveKey(param, store, origin) {
|
|
96
|
+
if (!param) return { ok: true, tier: 'anon' };
|
|
97
|
+
if (!KEY_RE.test(param)) return { ok: false, code: 'bad-key', message: 'malformed key (pk_…)' };
|
|
98
|
+
const rec = store ? await store.get(param) : null;
|
|
99
|
+
if (!rec || rec.revoked) return { ok: false, code: 'bad-key', message: 'unknown or revoked key' };
|
|
100
|
+
if (!originAllowed(origin, rec.origins)) return { ok: false, code: 'origin-not-allowed', message: `key is not allowed from origin "${origin || '(none)'}"` };
|
|
101
|
+
return { ok: true, tier: 'key', key: rec };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A key store over a Cloudflare KV namespace (`key:<id>`), with the edge cache KV offers. */
|
|
105
|
+
export function kvKeyStore(kv, { cacheTtl = 60 } = {}) {
|
|
106
|
+
if (!kv) return null;
|
|
107
|
+
return {
|
|
108
|
+
async get(id) {
|
|
109
|
+
try {
|
|
110
|
+
const raw = await kv.get(`key:${id}`, { cacheTtl });
|
|
111
|
+
return raw ? parseKeyRecord(id, raw) : null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null; // a KV hiccup refuses the key (fail closed) rather than crashing the connect
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A key store over a plain Map / object (dev server, tests). */
|
|
120
|
+
export function mapKeyStore(map) {
|
|
121
|
+
const get = (id) => (map instanceof Map ? map.get(id) : map && map[id]);
|
|
122
|
+
return { async get(id) { const r = get(id); return r ? parseKeyRecord(id, r) : null; } };
|
|
123
|
+
}
|
|
@@ -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
|
+
});
|