@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
package/js/inline3d-camera.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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).
|
package/js/inline3d-player.js
CHANGED
|
@@ -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
|
|
662
|
-
// resource
|
|
663
|
-
//
|
|
664
|
-
//
|
|
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.
|
|
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`.
|