@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,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`.
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// signaling/dev-server.mjs — a zero-dependency Node server for the `dxr-signal/1` protocol, for
|
|
3
|
+
// local development and the test suite. Same room logic (./room.mjs) and the same admission —
|
|
4
|
+
// keys, rate limits, room leases, the TURN budget (./gate.mjs) — as the Cloudflare Worker; only
|
|
5
|
+
// the socket plumbing and where the counters live differ. NOT for production: one process, rooms
|
|
6
|
+
// and counters in memory, no TLS.
|
|
7
|
+
//
|
|
8
|
+
// node signaling/dev-server.mjs [--port 8787] [--cap 4] [--keys keys.json]
|
|
9
|
+
// → ws://localhost:8787/v1/connect?k=<sha256(room)>[&key=pk_…]
|
|
10
|
+
//
|
|
11
|
+
// Optional TURN (same variables as the Worker): TURN_KEY_ID, TURN_KEY_API_TOKEN, TURN_TTL. The
|
|
12
|
+
// limit and budget variables (README.md) are read from the environment too; `--keys` is a JSON
|
|
13
|
+
// object `{ "pk_…": { "origins": [...] } }` for keyed sessions.
|
|
14
|
+
//
|
|
15
|
+
// The WebSocket framing below is the minimal RFC 6455 server subset: the upgrade handshake,
|
|
16
|
+
// masked client frames (text, continuation, ping/pong, close), unmasked server text frames. That
|
|
17
|
+
// is all a JSON signalling channel needs, and it keeps `npm install` out of a dev loop.
|
|
18
|
+
|
|
19
|
+
import { createServer } from 'node:http';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { pathToFileURL } from 'node:url';
|
|
23
|
+
import { Room, DEFAULT_ROOM_CAP, MAX_MESSAGE_BYTES, mintTurnCredentials, refuseConn } from './room.mjs';
|
|
24
|
+
import { Meter, limitsFromEnv } from './limits.mjs';
|
|
25
|
+
import { TurnBudget, budgetFromEnv } from './turn-budget.mjs';
|
|
26
|
+
import { mapKeyStore } from './keys.mjs';
|
|
27
|
+
import { createGate } from './gate.mjs';
|
|
28
|
+
|
|
29
|
+
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
30
|
+
const KEY_RE = /^[0-9a-f]{64}$/;
|
|
31
|
+
const IDLE_MS = 60_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Start a dev server. Resolves once listening.
|
|
35
|
+
* @param {{port?: number, host?: string, cap?: number, env?: object, keys?: Map<string, object>|object,
|
|
36
|
+
* turn?: ((session: object|null) => Promise<object[]|null>)|null, blocklist?: Set<string>,
|
|
37
|
+
* log?: (s: string) => void}} [opts]
|
|
38
|
+
* `turn`: a minter to use instead of Cloudflare (tests: fake TURN without a token).
|
|
39
|
+
* @returns {Promise<{port: number, url: string, rooms: Map<string, Room>, budget: TurnBudget, meters: Map<string, Meter>, close(): Promise<void>}>}
|
|
40
|
+
*/
|
|
41
|
+
export function startDevServer({ port = 8787, host = '127.0.0.1', cap = DEFAULT_ROOM_CAP, env = process.env, keys = null, turn = undefined, blocklist = null, log = () => {} } = {}) {
|
|
42
|
+
/** @type {Map<string, Room>} */
|
|
43
|
+
const rooms = new Map();
|
|
44
|
+
const timers = new Map(); // room key -> lifetime timer
|
|
45
|
+
const sockets = new Set();
|
|
46
|
+
const raw = new Set(); // every upgraded TCP socket, destroyed on shutdown
|
|
47
|
+
const budget = new TurnBudget({ cfg: budgetFromEnv(env) });
|
|
48
|
+
/** @type {Map<string, Meter>} */
|
|
49
|
+
const meters = new Map();
|
|
50
|
+
const meterOf = (subject) => {
|
|
51
|
+
let m = meters.get(subject);
|
|
52
|
+
if (!m) meters.set(subject, (m = new Meter()));
|
|
53
|
+
return m;
|
|
54
|
+
};
|
|
55
|
+
const gate = createGate({
|
|
56
|
+
limits: limitsFromEnv(env),
|
|
57
|
+
keys: keys ? mapKeyStore(keys) : null,
|
|
58
|
+
budget: { snapshot: () => budget.snapshot(), cfg: budget.cfg },
|
|
59
|
+
meters: { exec: (subject, cmd) => meterOf(subject).exec(cmd) },
|
|
60
|
+
blocklist: blocklist ? { has: (ip) => blocklist.has(ip) } : null,
|
|
61
|
+
salt: env.RATE_SALT || '',
|
|
62
|
+
});
|
|
63
|
+
const cfTurn = env.TURN_KEY_ID && env.TURN_KEY_API_TOKEN;
|
|
64
|
+
const minter =
|
|
65
|
+
turn !== undefined
|
|
66
|
+
? turn
|
|
67
|
+
: cfTurn
|
|
68
|
+
? (s) => mintTurnCredentials(env, undefined, { ttl: s && s.turn && s.turn.ttl > 0 ? s.turn.ttl : undefined, customIdentifier: (s && s.key) || 'anon' })
|
|
69
|
+
: null;
|
|
70
|
+
const ice = minter
|
|
71
|
+
? async (s) => {
|
|
72
|
+
const r = await minter(s);
|
|
73
|
+
if (r) budget.recordMint(s ? s.key : null, s && s.turn && s.turn.ttl > 0 ? s.turn.ttl : parseInt(env.TURN_TTL || '3600', 10) || 3600);
|
|
74
|
+
return r;
|
|
75
|
+
}
|
|
76
|
+
: null;
|
|
77
|
+
|
|
78
|
+
const server = createServer((req, res) => {
|
|
79
|
+
const u = new URL(req.url, 'http://x');
|
|
80
|
+
if (u.pathname === '/' || u.pathname === '/health') {
|
|
81
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
82
|
+
res.end(JSON.stringify({ ok: true, protocol: 'dxr-signal/1', rooms: rooms.size, turn: ice ? budget.snapshot().status : 'unconfigured', keys: !!keys }));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
res.writeHead(426, { 'Content-Type': 'text/plain' });
|
|
86
|
+
res.end('WebSocket endpoint: /v1/connect?k=<sha256(room) hex>\n');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
server.on('upgrade', (req, socket) => {
|
|
90
|
+
const u = new URL(req.url, 'http://x');
|
|
91
|
+
const key = u.searchParams.get('k') || '';
|
|
92
|
+
const wsKey = req.headers['sec-websocket-key'];
|
|
93
|
+
if (u.pathname !== '/v1/connect' || !KEY_RE.test(key) || !wsKey || String(req.headers.upgrade).toLowerCase() !== 'websocket') {
|
|
94
|
+
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const accept = createHash('sha1').update(wsKey + WS_GUID).digest('base64');
|
|
98
|
+
socket.write(
|
|
99
|
+
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
|
|
100
|
+
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`
|
|
101
|
+
);
|
|
102
|
+
socket.setNoDelay(true);
|
|
103
|
+
raw.add(socket);
|
|
104
|
+
socket.on('close', () => raw.delete(socket));
|
|
105
|
+
let session = null;
|
|
106
|
+
let room = null;
|
|
107
|
+
let early = []; // messages that arrive while admission is still deciding (a join sent on open)
|
|
108
|
+
const conn = wrapSocket(socket, {
|
|
109
|
+
onText: (text) => (room ? room.onMessage(conn, text).catch(() => {}) : early && early.push(text)),
|
|
110
|
+
onClose: () => {
|
|
111
|
+
sockets.delete(conn);
|
|
112
|
+
if (room) {
|
|
113
|
+
room.onClose(conn);
|
|
114
|
+
if (room.size === 0 && rooms.get(key) === room) rooms.delete(key);
|
|
115
|
+
}
|
|
116
|
+
if (session) gate.release(session).catch(() => {});
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
sockets.add(conn);
|
|
120
|
+
gate
|
|
121
|
+
.admit({ ip: socket.remoteAddress || '', origin: String(req.headers.origin || ''), key: u.searchParams.get('key') || null, roomKey: key, wantTurn: !!ice })
|
|
122
|
+
.then((a) => {
|
|
123
|
+
if (!a.ok) {
|
|
124
|
+
log(`refuse ${a.code} k=${key.slice(0, 8)}…`);
|
|
125
|
+
return refuseConn(conn, a);
|
|
126
|
+
}
|
|
127
|
+
session = a.session;
|
|
128
|
+
budget.recordJoin(session.key);
|
|
129
|
+
room = rooms.get(key);
|
|
130
|
+
if (!room) {
|
|
131
|
+
const r = new Room({
|
|
132
|
+
key,
|
|
133
|
+
cap,
|
|
134
|
+
iceServers: ice,
|
|
135
|
+
hooks: {
|
|
136
|
+
onFirst: (s) => {
|
|
137
|
+
clearTimeout(timers.get(key));
|
|
138
|
+
const ttlS = s && s.roomTtlS > 0 ? s.roomTtlS : limitsFromEnv(env).anon.roomTtlS;
|
|
139
|
+
const t = setTimeout(() => r.expire(), ttlS * 1000);
|
|
140
|
+
t.unref();
|
|
141
|
+
timers.set(key, t);
|
|
142
|
+
},
|
|
143
|
+
onEmpty: () => {
|
|
144
|
+
clearTimeout(timers.get(key));
|
|
145
|
+
timers.delete(key);
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
rooms.set(key, r);
|
|
150
|
+
room = r;
|
|
151
|
+
}
|
|
152
|
+
room.attach(conn, session);
|
|
153
|
+
log(`connect k=${key.slice(0, 8)}… ${session.tier}${session.key ? ' ' + session.key : ''} (${rooms.size} rooms)`);
|
|
154
|
+
const replay = early;
|
|
155
|
+
early = null;
|
|
156
|
+
for (const text of replay) room.onMessage(conn, text).catch(() => {});
|
|
157
|
+
})
|
|
158
|
+
.catch(() => {
|
|
159
|
+
early = null;
|
|
160
|
+
refuseConn(conn, { code: 'bad-message', message: 'admission failed', closeCode: 4000 });
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
server.once('error', reject);
|
|
166
|
+
server.listen(port, host, () => {
|
|
167
|
+
const addr = server.address();
|
|
168
|
+
const p = typeof addr === 'object' && addr ? addr.port : port;
|
|
169
|
+
resolve({
|
|
170
|
+
port: p,
|
|
171
|
+
url: `ws://${host === '0.0.0.0' ? 'localhost' : host}:${p}`,
|
|
172
|
+
rooms,
|
|
173
|
+
budget,
|
|
174
|
+
meters,
|
|
175
|
+
close: () =>
|
|
176
|
+
new Promise((done) => {
|
|
177
|
+
for (const t of timers.values()) clearTimeout(t);
|
|
178
|
+
for (const c of sockets) c.close(1001, 'server shutting down');
|
|
179
|
+
for (const s of raw) s.destroy();
|
|
180
|
+
server.close(() => done());
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Wrap a raw upgraded socket as a `{send(obj), close(code, reason)}` connection. */
|
|
188
|
+
function wrapSocket(socket, { onText, onClose }) {
|
|
189
|
+
let buf = Buffer.alloc(0);
|
|
190
|
+
let frag = null; // continuation assembly: { chunks: Buffer[] }
|
|
191
|
+
let closed = false;
|
|
192
|
+
let idle = setTimeout(() => conn.close(1001, 'idle'), IDLE_MS);
|
|
193
|
+
const touch = () => {
|
|
194
|
+
clearTimeout(idle);
|
|
195
|
+
// Never re-armed after close: the client's close-frame reply still arrives as data.
|
|
196
|
+
if (!closed) idle = setTimeout(() => conn.close(1001, 'idle'), IDLE_MS);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const conn = {
|
|
200
|
+
send(obj) {
|
|
201
|
+
if (closed) return;
|
|
202
|
+
socket.write(frame(0x1, Buffer.from(JSON.stringify(obj))));
|
|
203
|
+
},
|
|
204
|
+
close(code = 1000, reason = '') {
|
|
205
|
+
if (closed) return;
|
|
206
|
+
closed = true;
|
|
207
|
+
clearTimeout(idle);
|
|
208
|
+
const r = Buffer.from(String(reason).slice(0, 100));
|
|
209
|
+
const p = Buffer.alloc(2 + r.length);
|
|
210
|
+
p.writeUInt16BE(code, 0);
|
|
211
|
+
r.copy(p, 2);
|
|
212
|
+
try {
|
|
213
|
+
socket.end(frame(0x8, p));
|
|
214
|
+
} catch {
|
|
215
|
+
/* ignore */
|
|
216
|
+
}
|
|
217
|
+
// Give the client a moment to answer the close frame, then drop the TCP socket regardless.
|
|
218
|
+
setTimeout(() => socket.destroy(), 1000).unref();
|
|
219
|
+
onClose();
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
socket.on('data', (chunk) => {
|
|
224
|
+
if (closed) return;
|
|
225
|
+
touch();
|
|
226
|
+
buf = Buffer.concat([buf, chunk]);
|
|
227
|
+
for (;;) {
|
|
228
|
+
if (buf.length < 2) return;
|
|
229
|
+
const fin = (buf[0] & 0x80) !== 0;
|
|
230
|
+
const op = buf[0] & 0x0f;
|
|
231
|
+
const masked = (buf[1] & 0x80) !== 0;
|
|
232
|
+
let len = buf[1] & 0x7f;
|
|
233
|
+
let off = 2;
|
|
234
|
+
if (len === 126) {
|
|
235
|
+
if (buf.length < 4) return;
|
|
236
|
+
len = buf.readUInt16BE(2);
|
|
237
|
+
off = 4;
|
|
238
|
+
} else if (len === 127) {
|
|
239
|
+
if (buf.length < 10) return;
|
|
240
|
+
const big = buf.readBigUInt64BE(2);
|
|
241
|
+
if (big > BigInt(MAX_MESSAGE_BYTES * 2)) return conn.close(1009, 'too big');
|
|
242
|
+
len = Number(big);
|
|
243
|
+
off = 10;
|
|
244
|
+
}
|
|
245
|
+
if (!masked) return conn.close(1002, 'client frames must be masked');
|
|
246
|
+
if (buf.length < off + 4 + len) return;
|
|
247
|
+
const mask = buf.subarray(off, off + 4);
|
|
248
|
+
const payload = Buffer.from(buf.subarray(off + 4, off + 4 + len));
|
|
249
|
+
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
|
|
250
|
+
buf = buf.subarray(off + 4 + len);
|
|
251
|
+
|
|
252
|
+
if (op === 0x8) return conn.close(1000, '');
|
|
253
|
+
if (op === 0x9) {
|
|
254
|
+
socket.write(frame(0xa, payload));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (op === 0xa) continue;
|
|
258
|
+
if (op === 0x1 || op === 0x0) {
|
|
259
|
+
if (op === 0x1) frag = { chunks: [] };
|
|
260
|
+
if (!frag) return conn.close(1002, 'unexpected continuation');
|
|
261
|
+
frag.chunks.push(payload);
|
|
262
|
+
const total = frag.chunks.reduce((n, c) => n + c.length, 0);
|
|
263
|
+
if (total > MAX_MESSAGE_BYTES * 2) return conn.close(1009, 'too big');
|
|
264
|
+
if (fin) {
|
|
265
|
+
const text = Buffer.concat(frag.chunks).toString('utf8');
|
|
266
|
+
frag = null;
|
|
267
|
+
onText(text);
|
|
268
|
+
}
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
return conn.close(1003, 'unsupported frame');
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
socket.on('close', () => {
|
|
275
|
+
clearTimeout(idle);
|
|
276
|
+
if (!closed) {
|
|
277
|
+
closed = true;
|
|
278
|
+
clearTimeout(idle);
|
|
279
|
+
onClose();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
socket.on('error', () => socket.destroy());
|
|
283
|
+
return conn;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function frame(op, payload) {
|
|
287
|
+
const len = payload.length;
|
|
288
|
+
let head;
|
|
289
|
+
if (len < 126) {
|
|
290
|
+
head = Buffer.from([0x80 | op, len]);
|
|
291
|
+
} else if (len < 65536) {
|
|
292
|
+
head = Buffer.alloc(4);
|
|
293
|
+
head[0] = 0x80 | op;
|
|
294
|
+
head[1] = 126;
|
|
295
|
+
head.writeUInt16BE(len, 2);
|
|
296
|
+
} else {
|
|
297
|
+
head = Buffer.alloc(10);
|
|
298
|
+
head[0] = 0x80 | op;
|
|
299
|
+
head[1] = 127;
|
|
300
|
+
head.writeBigUInt64BE(BigInt(len), 2);
|
|
301
|
+
}
|
|
302
|
+
return Buffer.concat([head, payload]);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// CLI
|
|
306
|
+
// pathToFileURL: a Windows argv path (C:\…) never string-matches file:///C:/…
|
|
307
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
308
|
+
const arg = (name, dflt) => {
|
|
309
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
310
|
+
return i > 0 ? process.argv[i + 1] : dflt;
|
|
311
|
+
};
|
|
312
|
+
const port = parseInt(arg('port', process.env.PORT || '8787'), 10);
|
|
313
|
+
const cap = parseInt(arg('cap', '4'), 10);
|
|
314
|
+
const host = arg('host', '127.0.0.1');
|
|
315
|
+
const keysFile = arg('keys', '');
|
|
316
|
+
const keys = keysFile ? JSON.parse(readFileSync(keysFile, 'utf8')) : null;
|
|
317
|
+
startDevServer({ port, cap, host, keys, log: (s) => console.log(`[dxr-signal] ${s}`) }).then((s) => {
|
|
318
|
+
console.log(`[dxr-signal] dev server on ${s.url}/v1/connect (cap ${cap}${process.env.TURN_KEY_ID ? ', TURN on' : ''}${keys ? `, ${Object.keys(keys).length} keys` : ''})`);
|
|
319
|
+
});
|
|
320
|
+
}
|