@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.
@@ -431,7 +431,13 @@ class StereoCamera {
431
431
  * promise rejects only when NO camera opens, with `code` `'camera-busy'` (every device is held),
432
432
  * `'permission-denied'`, or `'no-camera'`, and `skipped` listing every device tried.
433
433
  *
434
- * @param {object|string|MediaStream} [opts] `{ prefer, format, calibration, rectify, debug }` (or just `prefer`)
434
+ * A probe never opens a device whose label marks it as a sensor rather than a camera (a hand-tracking
435
+ * camera, an eye tracker's or depth camera's stream — capture.js CAMERA_EXCLUDE_BUILTIN), nor one
436
+ * matching `exclude` (strings: a label substring, case-insensitive, or an exact deviceId; RegExps:
437
+ * the label). `exclude: false` turns the built-in list off too. Left-out devices are in `skipped`
438
+ * with `excluded: true`. An explicit `deviceId` is the page's choice and is opened regardless.
439
+ *
440
+ * @param {object|string|MediaStream} [opts] `{ prefer, format, calibration, rectify, exclude, debug }` (or just `prefer`)
435
441
  */
436
442
  export async function openCamera(opts = {}) {
437
443
  const o = typeof opts === 'string' || (opts && typeof opts.getVideoTracks === 'function') ? { prefer: opts } : opts || {};
@@ -441,7 +447,7 @@ export async function openCamera(opts = {}) {
441
447
  // `horizontalFovDeg` is the public spelling (RFC 0003 §4); the hello wire field is `hfovDeg`.
442
448
  if (cal.horizontalFovDeg > 0 && !(cal.hfovDeg > 0)) cal.hfovDeg = cal.horizontalFovDeg;
443
449
  delete cal.horizontalFovDeg;
444
- const r = await openDevice(prefer, { format: o.format, calibration: cal, mediaDevices: o.mediaDevices, log: log || undefined });
450
+ const r = await openDevice(prefer, { format: o.format, calibration: cal, mediaDevices: o.mediaDevices, log: log || undefined, exclude: o.exclude });
445
451
  if (r.format === 'sbs' && typeof o.rectify === 'function') {
446
452
  // A calibrated rectification step (plug-in or runtime supplied): its output is a rectified
447
453
  // SBS stream, and the record then says so. A throwing hook keeps the raw pair.
@@ -498,7 +504,12 @@ function sizeFlat(canvas) {
498
504
  * pair — with `fallbackReason` saying why, and `onRouteChange(route, state)` tells the page (a
499
505
  * badge reads `route`). A refused layer is retried a few times; `_reroute(true, wall)` recovers.
500
506
  *
501
- * @param {{ mirror?: boolean, autoConverge?: boolean, depth?: number, aspect?: number, onRouteChange?: Function }} [opts]
507
+ * `aspect: 'fill'` crops each eye to the canvas's own CSS box (read every frame) instead of a fixed
508
+ * aspect, for a page that sizes the tile itself. `rectCover: 'auto'` is the core's rect cover
509
+ * (`addImage`'s `rectCover`) on the woven registration: the tile is covered across a later resize
510
+ * or move until it is woven again.
511
+ *
512
+ * @param {{ mirror?: boolean, autoConverge?: boolean, depth?: number, aspect?: number|'fill', rectCover?: 'auto'|'off'|object, onRouteChange?: Function }} [opts]
502
513
  */
503
514
  export function addCameraView(wall, canvas, cam, opts = {}) {
504
515
  if (!canvas || typeof canvas.getContext !== 'function') throw new TypeError(`${TAG} addCameraView(wall, canvas, cam, opts) needs a canvas`);
@@ -516,7 +527,10 @@ class CameraView {
516
527
  // A self view mirrors; a rear camera shows the world as it is. Unset: mirror unless the track
517
528
  // says it faces the environment (an unknown facing is a webcam pointed at the viewer).
518
529
  this.mirror = o.mirror === undefined ? cam.facing !== 'environment' : !!o.mirror;
519
- this.aspect = typeof o.aspect === 'number' && o.aspect > 0.3 && o.aspect < 4 ? o.aspect : 16 / 9;
530
+ // 'fill': the canvas box's own aspect, read at paint (clamped like a number) — `_eyeAspect()`.
531
+ this.aspect = o.aspect === 'fill' ? 'fill' : typeof o.aspect === 'number' && o.aspect > 0.3 && o.aspect < 4 ? o.aspect : 16 / 9;
532
+ // The core's rect cover on the woven registration (undefined = the core's default, off).
533
+ this.rectCover = o.rectCover;
520
534
  this.autoConverge = !!o.autoConverge;
521
535
  this.conv = createConvergence();
522
536
  this.conv.depth = 0;
@@ -638,7 +652,7 @@ class CameraView {
638
652
  _registerWoven() {
639
653
  if (!this.buffer) this.buffer = document.createElement('canvas');
640
654
  this._paintWoven(); // the first frame exists before the layer does
641
- const handle = this.wall.addImage(this.canvas, this.buffer);
655
+ const handle = this.rectCover === undefined ? this.wall.addImage(this.canvas, this.buffer) : this.wall.addImage(this.canvas, this.buffer, { rectCover: this.rectCover });
642
656
  this.handle = handle;
643
657
  this._fw = null;
644
658
  const fw = handle && handle.firstWoven;
@@ -749,7 +763,7 @@ class CameraView {
749
763
  const H = v.videoHeight;
750
764
  if (!W || !H || (v.readyState || 0) < 2 || !this.buffer) return;
751
765
  const eyeW = W / 2;
752
- const A = this.aspect;
766
+ const A = this._eyeAspect();
753
767
  const { w: outW, h: outH } = eyeOutputSize(eyeW, H, A);
754
768
  const c = this.buffer;
755
769
  if (c.width !== 2 * outW || c.height !== outH) {
@@ -783,6 +797,15 @@ class CameraView {
783
797
  }
784
798
  }
785
799
 
800
+ /** The per-eye aspect the pair is cropped to: the number given, or (`'fill'`) the canvas box's. */
801
+ _eyeAspect() {
802
+ if (this.aspect !== 'fill') return this.aspect;
803
+ const w = this.canvas.clientWidth || 0;
804
+ const h = this.canvas.clientHeight || 0;
805
+ if (w > 0 && h > 0) this._fillAspect = Math.max(0.3, Math.min(4, w / h));
806
+ return this._fillAspect || 16 / 9;
807
+ }
808
+
786
809
  /**
787
810
  * A small grayscale copy of the SOURCE frame (before our shift): ~5 Hz for auto-convergence,
788
811
  * ~15 Hz while a depth cursor hovers (it measures the same copy under the pointer).
@@ -658,10 +658,14 @@ function attachFlatPaint(canvas, video, { mode, getPoster, dissolve, fit = null,
658
658
  // pair: two squished halves, no 3D. That is why the "scrim" here is a bottom band with a
659
659
  // capped height, and NOT the full-height gradient a 2D player would reach for. Same reason
660
660
  // the centre badge is a small circle rather than a full-tile click-catcher.
661
- // 2. NO `backdrop-filter`, anywhere. Exclusion needs the element as an isolated composited
662
- // resource; `backdrop-filter` is defined as a function of what is behind it, so there is
663
- // nothing to hand the compositor and the element either weaves anyway or drops out. The
664
- // near-solid background below is the documented substitute for frosted glass.
661
+ // 2. NO `backdrop-filter` in this chrome. On a pre-draw-order browser exclusion needs the
662
+ // element as an isolated composited resource, and `backdrop-filter` is a function of what is
663
+ // behind it, so the element either weaves anyway or drops out; the near-solid background
664
+ // below is the substitute, and the player keeps it so one build works on every generation.
665
+ // On DisplayXR Browser 0.1.16+ a frosted element OVER a tile works (browser#120: the
666
+ // compositor draws the tile mono inside each frost rect; samples/composition is the
667
+ // validated case) — never a frost over the WHOLE visible tile, and never an effect on the
668
+ // canvas or an ancestor (woven-canvas rule 7).
665
669
  // 3. Promotion is `will-change: transform`, never a CSS `filter` — a filter's render surface is
666
670
  // flattened away in the weave path.
667
671
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.47.6",
3
+ "version": "1.48.0",
4
4
  "description": "Turn any HTML <canvas> into a 3D window on a spatial display, inside an ordinary web page, for the DisplayXR Browser. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
@@ -120,6 +120,13 @@
120
120
  "js/camera/",
121
121
  "js/call/",
122
122
  "js/lift/",
123
+ "signaling/README.md",
124
+ "signaling/dev-server.mjs",
125
+ "signaling/room.mjs",
126
+ "signaling/limits.mjs",
127
+ "signaling/turn-budget.mjs",
128
+ "signaling/keys.mjs",
129
+ "signaling/gate.mjs",
123
130
  "dist/",
124
131
  "index.d.ts",
125
132
  "three.d.ts",
@@ -0,0 +1,289 @@
1
+ # `dxr-signal/1` — signalling for `@displayxr/inline3d/call`
2
+
3
+ A small JSON-over-WebSocket protocol that gets up to four browsers into the same call and relays
4
+ their WebRTC offers, answers and ICE candidates. It never carries media (that is peer-to-peer,
5
+ DTLS-SRTP) and keeps nothing once a room is empty.
6
+
7
+ | File | What |
8
+ |---|---|
9
+ | [`room.mjs`](room.mjs) | The room logic. Shared as-is by both servers below, so they cannot drift. |
10
+ | [`gate.mjs`](gate.mjs) | Admission for one connect: key + origin ([`keys.mjs`](keys.mjs)), blocklist, join rate + room leases ([`limits.mjs`](limits.mjs)), the TURN budget ([`turn-budget.mjs`](turn-budget.mjs)). Shared too. |
11
+ | [`worker.mjs`](worker.mjs) + [`wrangler.toml`](wrangler.toml) | Reference server: a Cloudflare Worker + Durable Objects (one per room, hibernating WebSockets; one per rate-limit subject; one budget) + a KV namespace for keys. The template is for self-hosting; [`deploy/displayxr.toml`](deploy/displayxr.toml) is the hosted DisplayXR instance and [`deploy/staging.toml`](deploy/staging.toml) its staging twin. |
12
+ | [`dev-server.mjs`](dev-server.mjs) | A zero-dependency Node server with the same protocol **and the same admission** (limits, keys, budget — in memory), for local work and the test suite. Not for production. |
13
+ | [`../tools/signal-keys.mjs`](../tools/signal-keys.mjs) | Issue / revoke / list publishable keys and block IPs, by hand, through `wrangler kv`. |
14
+
15
+ Client side: `dxrSignaling(url, { key })` in [`js/call/signaling.js`](../js/call/signaling.js). Any
16
+ other transport can be plugged into `addCall` by implementing the `SignalingAdapter` interface
17
+ (`call.d.ts`); this protocol is just the one the SDK ships.
18
+
19
+ **Hosted instance:** `wss://signal.displayxr.org` (the SDK's default since the release after
20
+ 1.32.0), with `wss://dxr-signal.displayxr.workers.dev` kept as an alias through the transition
21
+ ([`docs/signaling-domain.md`](../docs/signaling-domain.md)); the same Worker answers on every host
22
+ and the SDK fails over between them (`DXR_SIGNAL_ALIASES` in
23
+ `js/call/signaling.js`, internal). What it sees and
24
+ keeps: [`docs/privacy-call.md`](../docs/privacy-call.md).
25
+
26
+ ## Local development
27
+
28
+ ```sh
29
+ node signaling/dev-server.mjs # ws://localhost:8787 (flags: --port, --host, --cap, --keys keys.json)
30
+ python3 -m http.server 8000 # from the repo root
31
+ # open http://localhost:8000/samples/call/ (the sample defaults to ws://localhost:8787 on localhost)
32
+ ```
33
+
34
+ ### From the npm package
35
+
36
+ The dev server ships in `@displayxr/inline3d` (since 1.48) as `signaling/dev-server.mjs` plus the
37
+ five modules it imports (`room`, `limits`, `turn-budget`, `keys`, `gate`). It is **not** an
38
+ `exports` entry: run it by path, or copy the six files as they are.
39
+
40
+ ```sh
41
+ node node_modules/@displayxr/inline3d/signaling/dev-server.mjs --port 8787
42
+ ```
43
+
44
+ ```js
45
+ // programmatic (an app's dev script, a test harness)
46
+ // By FILE path: `exports` does not list it, so Node refuses the bare
47
+ // '@displayxr/inline3d/signaling/…' specifier (ERR_PACKAGE_PATH_NOT_EXPORTED).
48
+ import { startDevServer } from './node_modules/@displayxr/inline3d/signaling/dev-server.mjs';
49
+ const server = await startDevServer({ port: 8787, host: '127.0.0.1' }); // resolves once listening
50
+ console.log(server.url); // ws://127.0.0.1:8787 — pass it to dxrSignaling()
51
+ await server.close();
52
+ ```
53
+
54
+ **Never bundle it.** The file starts the CLI when it is the script Node was started with
55
+ (`import.meta.url === argv[1]`); a bundler inlining it into another script breaks that check and
56
+ its relative imports. Keep it as separate, unbundled `.mjs` files (mark it external, or copy the
57
+ six files), and import `startDevServer` from it by file path.
58
+
59
+ `getUserMedia` needs a secure context: `localhost` counts; a LAN IP over plain `http` does not.
60
+ The dev server applies the same limits as the hosted server (env vars below); for a flood test
61
+ set e.g. `ANON_JOINS_PER_MIN=3`.
62
+
63
+ ## Protocol
64
+
65
+ **Connect** to `<base>/v1/connect?k=<key>[&key=<pk_…>]`, where `k` is the lowercase hex SHA-256 of
66
+ the room id and `key` an optional publishable key (§ *Access*). The room itself never appears in a
67
+ URL (and so never in an access log): it travels in the `join` message, and the server checks it
68
+ against the hash it was addressed by. The client's invite link carries the room in its `#room=`
69
+ fragment, which browsers never send to any server.
70
+
71
+ Every message is one JSON object with a `t` field.
72
+
73
+ ### Client → server
74
+
75
+ | `t` | Fields | Meaning |
76
+ |---|---|---|
77
+ | `join` | `v: 1`, `room`, `id`, `max?` | Join `room` (16–64 base64url chars; generated rooms are 22 chars = 128 random bits) as peer `id` (8–32 base64url chars, chosen by the client, stable across reconnects). `max` = the room size the first joiner asks for, clamped to the server's cap. One join per connection. |
78
+ | `signal` | `to`, `data` | Relay `data` (an opaque object: offer / answer / candidate / restart / bye) to peer `to` in the same room. |
79
+ | `leave` | — | Leave; the server closes the socket. Closing the socket is equivalent. |
80
+ | `ping` | — | Keep-alive (the client sends one every 20 s). |
81
+
82
+ ### Server → client
83
+
84
+ **Ordering.** `welcome` is the first message a joined peer receives. The server may need a network
85
+ round-trip between accepting a join and answering it (minting TURN credentials), and other peers can
86
+ join or signal in that window; anything addressed to the joining peer is held and delivered, in
87
+ order, right behind its welcome. `welcome.peers` is the room as the welcome is sent, so it includes
88
+ peers that joined during that window. A client may therefore treat the first message on a socket as
89
+ the outcome of its join (`welcome`, `full`, or `error`).
90
+
91
+ | `t` | Fields | Meaning |
92
+ |---|---|---|
93
+ | `welcome` | `v: 1`, `id`, `peers: string[]`, `max`, `iceServers?`, `tier`, `key?`, `turn` | Joined. `peers` are the ids already in the room. `iceServers` = short-lived TURN credentials, when the server minted some for this session. `tier` = `'anon'` or `'key'`; `key` = the key id this session is attributed to; `turn` = `{ status, reason?, ttl? }` — the service-wide relay-budget state (`ok` / `degraded` / `off`) and, when there are no credentials, why: `unconfigured` (no TURN on this server), `budget` (anonymous session shed near the budget), `rate` (too many mints from this address), `cap` (the monthly cap — everyone), `mint-failed`. |
94
+ | `full` | `max` | The room already has `max` peers. The server closes the socket (4003). |
95
+ | `expired` | — | The room's lifetime is up (2 h anonymous / 8 h keyed, from its first join). The server closes every peer's socket (4004). Media already flowing is not touched. |
96
+ | `peer-joined` | `id` | Someone joined after you — i.e. someone who is **not** in your `welcome.peers`. Each pair hears of the other exactly once: through one side's roster or the other side's `peer-joined`, never both. |
97
+ | `peer-left` | `id` | Someone left (or their socket dropped). |
98
+ | `signal` | `from`, `data` | Relayed from `from`. |
99
+ | `pong` | — | Reply to `ping`. |
100
+ | `error` | `code`, `message`, `retryMs?` | See the table below. |
101
+
102
+ | `error.code` | When | Closes the socket with |
103
+ |---|---|---|
104
+ | `bad-message`, `bad-version`, `bad-room`, `bad-id`, `id-taken`, `too-big` | the join or a message is malformed / cannot be honoured | 4000 (`bad-message`, `too-big`, `not-joined`, `already-joined`, `no-such-peer` do not close) |
105
+ | `rate-limited` | more than 300 messages in 10 s on one connection (4000); **or**, at connect, more joins per minute from this address than the tier allows — `retryMs` says when the window reopens | 4029 |
106
+ | `quota` | at connect: this address (anonymous) or this key already has its maximum number of open rooms | 4030 |
107
+ | `bad-key` | `?key=` is malformed, unknown or revoked | 4001 |
108
+ | `origin-not-allowed` | the key exists but the page's `Origin` is not on its list (a non-browser client with no `Origin` never matches a key with a list) | 4001 |
109
+ | `blocked` | the address is on the blocklist | 4040 |
110
+
111
+ A refused connect is still a real WebSocket for a moment: the server accepts it, sends the one
112
+ `error`, and closes with the code above — so a client always learns why, never hangs.
113
+
114
+ ### What the SDK puts in `signal.data`
115
+
116
+ Informational — the server does not look inside. `{ kind: 'offer' | 'answer', sdp, gen }`,
117
+ `{ kind: 'candidate', candidate, gen }`, `{ kind: 'restart', gen }` (the answerer asks the offerer
118
+ to rebuild), `{ kind: 'bye', gen }`. Of each pair, the lexically smaller peer id is the offerer;
119
+ `gen` is the connection generation, so a late candidate from a rebuilt connection is dropped.
120
+
121
+ ### Reconnects
122
+
123
+ If the signalling socket drops mid-call, the client reconnects with backoff and re-joins with the
124
+ **same** `id`. Media already flowing peer-to-peer is not interrupted: a pair whose connection is
125
+ still up survives a `peer-left` from the server, and a pair that was also cut is rebuilt once
126
+ both sides are back. A `rate-limited` / `quota` refusal on a reconnect is retried no sooner than
127
+ its `retryMs`; `bad-key`, `origin-not-allowed`, `blocked` and `expired` end the session.
128
+
129
+ ## Access: anonymous tier and publishable keys (RFC 0003 §5a)
130
+
131
+ | | Anonymous (no key) | Publishable key `pk_…` |
132
+ |---|---|---|
133
+ | Who | demos, localhost, the playground, first five minutes | a page that ships |
134
+ | Origin check | none | the key's origin list, checked on `Origin` at connect |
135
+ | TURN | yes, 600 s credentials, first to be shed under budget pressure | yes, 3600 s |
136
+ | Limits | tight (below) | 10× the anonymous limits; adjustable per key |
137
+ | Obtain | nothing | issued by hand (`tools/signal-keys.mjs`), revocable |
138
+
139
+ A key is **public** (it sits in page source): attribution + a quota bucket + an origin binding
140
+ that browsers enforce. A non-browser client can forge `Origin`, which is why quotas, not the key,
141
+ are the actual protection. No secret ever goes in a client. Every session carries its key id, so
142
+ usage is attributable per key from day one (billing identity; billing is off).
143
+
144
+ Key records live in the Worker's `KEYS` KV namespace as `key:<pk_…>` → JSON:
145
+
146
+ ```json
147
+ { "id": "pk_…", "origins": ["https://app.example", "https://*.example.com", "http://localhost"],
148
+ "note": "Acme storefront", "createdAt": "2026-10-03T…", "revoked": false,
149
+ "limits": { "rooms": 100, "joinsPerMin": 400, "mintsPerHour": 600, "turnTtl": 7200, "roomTtlS": 43200 } }
150
+ ```
151
+
152
+ Origins: exact; one-label wildcard (`https://*.example.com`); `http://localhost` / `http://127.0.0.1`
153
+ (any port); `*` (any origin — attribution only). `limits` overrides the keyed tier's numbers for
154
+ that key alone. KV is eventually consistent (~60 s).
155
+
156
+ **Admin recipe (by hand):**
157
+
158
+ ```sh
159
+ node tools/signal-keys.mjs issue --origins https://app.example,https://*.example.com --note "Acme" # prints pk_…
160
+ node tools/signal-keys.mjs revoke pk_…
161
+ node tools/signal-keys.mjs list
162
+ node tools/signal-keys.mjs block 203.0.113.9 # IP blocklist (abuse)
163
+ # --env staging for the staging Worker; needs a logged-in wrangler, nothing else
164
+ ```
165
+
166
+ The same records are reachable over HTTPS when the Worker has an `ADMIN_TOKEN` secret:
167
+ `GET|PUT|DELETE /admin/keys/<pk_…>` with `Authorization: Bearer <token>`.
168
+
169
+ ## Limits (RFC 0003 §5b)
170
+
171
+ | Limit | Anonymous | Keyed | Where it is counted | Env var |
172
+ |---|---|---|---|---|
173
+ | Joins per IP per minute | 20 | 200 | per-IP meter (separate windows per tier) | `ANON_JOINS_PER_MIN` / `KEY_JOINS_PER_MIN` |
174
+ | Concurrent rooms | 5 per IP | 50 per key | per-IP / per-key meter (leases, expire by themselves) | `ANON_ROOMS` / `KEY_ROOMS` |
175
+ | Room size | 4 (`MAX_PEERS`) | 4 | room | `MAX_PEERS` |
176
+ | Room lifetime | 2 h → `expired` | 8 h | room (alarm, from the first joiner's tier) | `ANON_ROOM_TTL` / `KEY_ROOM_TTL` (seconds) |
177
+ | TURN credential mints per IP per hour | 30 | 300 | per-IP meter (over it: the session joins STUN-only, `turn.reason = 'rate'`) | `ANON_MINTS_PER_HOUR` / `KEY_MINTS_PER_HOUR` |
178
+ | TURN credential TTL | 600 s | 3600 s | — | `ANON_TURN_TTL` / `TURN_TTL` |
179
+ | Messages per connection | 300 / 10 s | same | room | — |
180
+ | Message size | 64 KiB | same | room | — |
181
+
182
+ IPs are never stored: rate-limit state is keyed by a salted SHA-256 prefix of the address
183
+ (`RATE_SALT`). The Node server also drops a socket idle for 60 s.
184
+
185
+ ## TURN budget and the monthly cap (RFC 0003 §5c)
186
+
187
+ Only the ~10–20 % of calls that cannot go peer-to-peer use TURN, and TURN is the only real cost.
188
+ The service meters it and enforces an org-wide monthly ceiling: the free tier
189
+ (`TURN_FREE_GB`, 1000 GB) plus a **hard cap of `TURN_CAP_USD` = $100** beyond it at
190
+ `TURN_PRICE_PER_GB` ($0.05) — **3000 GB a month** by default. Against that budget:
191
+
192
+ | Month's usage | Status | Anonymous sessions | Keyed sessions |
193
+ |---|---|---|---|
194
+ | < 70 % | `ok` | TURN, 600 s | TURN, 3600 s |
195
+ | 70 – 90 % | `degraded` | TURN, 300 s, 5 mints / IP / h | TURN, 3600 s |
196
+ | 90 – 100 % | `degraded` | **no TURN** (`turn.reason = 'budget'`) → SDK `warning` `turn-shed` | TURN, 3600 s |
197
+ | ≥ 100 % | `off` | **no TURN** (`turn.reason = 'cap'`) → SDK `error` `turn-cap` | **no TURN** (`cap`) → `turn-cap` |
198
+
199
+ Direct P2P keeps working throughout — the welcome simply carries no `iceServers` — and relays
200
+ already allocated run until their credentials expire. The health endpoint reports
201
+ `turn: "ok" | "degraded" | "off" | "unconfigured"`.
202
+
203
+ **How the usage figure is read.** Two figures exist, and the service uses the better one it has:
204
+
205
+ - **Estimated** (always): every mint records `(key, ttl)`; `estimatedGB = Σ ttl-hours ×
206
+ TURN_EST_GB_PER_TTL_HOUR` (default 0.3 GB, ≈ 15 % of sessions relaying ~3.5 Mbps both ways).
207
+ Crude on purpose: a credential may relay nothing, or for its whole TTL.
208
+ - **Actual** (when configured): Cloudflare exposes relay bytes per TURN key and per
209
+ `customIdentifier` through the GraphQL analytics dataset `callsTurnUsageAdaptiveGroups`
210
+ (`sum { egressBytes ingressBytes }`, filter `keyId`, `date_geq/date_leq`). Every credential is
211
+ minted with `customIdentifier = <key id | anon>`, so the figure is per key. The Worker's hourly
212
+ cron (`17 * * * *`) polls it and stores month-to-date GB in the Budget object; a figure younger
213
+ than 3 h is what counts, else the estimate. Needs two secrets: `CF_ANALYTICS_API_TOKEN` (an API
214
+ token with *Account Analytics: Read*) and `CF_ACCOUNT_ID`. `POST /admin/analytics` polls now.
215
+ - **Override** (operator / tests): `POST /admin/usage {"overrideGB": 2850}` (or the
216
+ `TURN_FORCE_USAGE_GB` var) beats both; `{"overrideGB": null}` clears it. `GET /admin/usage`
217
+ shows everything: `usedGB`, `source`, `estimatedGB`, `actualGB`, `fraction`, `status`, and the
218
+ per-key month (`joins`, `mints`, `ttlSeconds`, `actualGB`).
219
+
220
+ Usage is aggregated **per key and per month** — the business model's billing identity — while
221
+ billing itself is off. Nothing here charges anyone.
222
+
223
+ ## Cloudflare deployment (template)
224
+
225
+ 1. Copy `wrangler.toml`, set `name` (and routes / a custom domain) for your account. Create the
226
+ KV namespace (`wrangler kv namespace create dxr-signal-keys`) and paste its id, or drop the
227
+ `[[kv_namespaces]]` block for an anonymous-only server.
228
+ 2. Optional: set `ALLOWED_ORIGINS` to the origins of your pages (comma-separated) — a
229
+ deployment-wide allowlist on top of per-key origins.
230
+ 3. Optional TURN (≈10–20 % of networks need a relay), with [Cloudflare Realtime TURN](https://developers.cloudflare.com/realtime/turn/):
231
+ - `TURN_KEY_ID` — the TURN key's id (a var or a secret).
232
+ - `TURN_KEY_API_TOKEN` — its API token. **A secret:** `wrangler secret put TURN_KEY_API_TOKEN`.
233
+ Never in `wrangler.toml`, never in a client.
234
+ - `TURN_TTL` / `ANON_TURN_TTL` — credential lifetimes (60–86400 s).
235
+
236
+ Each admitted `join` makes the Worker mint credentials
237
+ (`POST /v1/turn/keys/<id>/credentials/generate-ice-servers`, with `customIdentifier` = the key
238
+ id) and return them in `welcome`. The same variables work for `dev-server.mjs`.
239
+ 4. Optional: `wrangler secret put ADMIN_TOKEN` (the `/admin/*` surface), `RATE_SALT`,
240
+ `CF_ANALYTICS_API_TOKEN` + `CF_ACCOUNT_ID` (real usage).
241
+ 5. `wrangler deploy`, then `dxrSignaling('wss://<your-worker-host>')`.
242
+
243
+ Health: `GET /` → `{ ok, protocol, turn, keys, limits }`.
244
+
245
+ ## Self-hosting
246
+
247
+ Self-hosting is first-class and always free (RFC 0003 §5f):
248
+
249
+ - **Cloudflare**, as above — your account, your TURN key, your limits (every var in
250
+ `wrangler.toml`), your keys. The $100 cap is just a number (`TURN_CAP_USD`).
251
+ - **Node**, `node signaling/dev-server.mjs` — the same protocol and limits in one process, rooms
252
+ and counters in memory, no TLS (put it behind a TLS-terminating proxy: `wss://` is required from
253
+ an `https://` page). Fine for a small deployment; not multi-process.
254
+ - **No TURN at all** (`TURN_KEY_ID` unset): everything works for the ~80–90 % of pairs that can
255
+ connect directly; the rest show as `unreachable` after ~10 s. The welcome says
256
+ `turn: { status: 'off', reason: 'unconfigured' }` and the SDK emits nothing for it.
257
+ - **Bring your own TURN (coturn)** — hand the credentials to the widget instead of the server:
258
+ ```sh
259
+ # coturn, static-auth-secret mode (TURN REST API): the server mints time-limited credentials.
260
+ apt install coturn
261
+ cat >/etc/turnserver.conf <<'EOF'
262
+ listening-port=3478
263
+ tls-listening-port=5349
264
+ realm=turn.example.com
265
+ use-auth-secret
266
+ static-auth-secret=<a long random secret>
267
+ cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
268
+ pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
269
+ fingerprint
270
+ no-multicast-peers
271
+ EOF
272
+ ```
273
+ Mint credentials in your backend (never in the page): `username = <expiry unix ts>:<anything>`,
274
+ `credential = base64(HMAC-SHA1(static-auth-secret, username))`, and pass them to the widget as
275
+ `iceServers: [{ urls: ['turn:turn.example.com:3478', 'turns:turn.example.com:5349'], username, credential }]`
276
+ (`mountCall(el, { iceServers })`; they override whatever the signalling server hands out). Or
277
+ plug the same minting into `room.mjs`'s `iceServers` hook if you run the Node server.
278
+
279
+ **`dxr-signal/1` freezes at the `/call` stable gate** (RFC 0003 §7): self-hosters depend on it
280
+ independently of the SDK version. Until then, additions are new optional fields only.
281
+
282
+ ## Staging and the C3 gate
283
+
284
+ `deploy/staging.toml` is the hosted server's twin (`wss://dxr-signal-staging.displayxr.workers.dev`):
285
+ own Durable Objects, own KV, and `TURN_FAKE = "1"` — dummy TURN credentials without any token, so
286
+ the whole mint / meter / budget path runs. `test/e2e/signal-staging.e2e.mjs` is the RFC 0003 §7
287
+ C3 gate against it (join flood → `rate-limited`, forced 90 % → anonymous shed only, forced cap →
288
+ `turn-cap` with a direct headless call still connecting, optional forced relay). It needs the
289
+ staging `ADMIN_TOKEN`.