freehop 0.1.0-alpha.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/SDK.md ADDED
@@ -0,0 +1,113 @@
1
+ # Freehop SDK: integrating it into an application
2
+
3
+ Freehop is built to be consumed. Any application uses the same five pieces. Redline Wars is
4
+ a planned consumer and test environment, using the same public API. Nothing below is
5
+ game-specific.
6
+
7
+ ```
8
+ your backend your gate host every client hosts (optional)
9
+ ─────────── ───────────── ──────────── ────────────────
10
+ createAuthority() ───► freehop-gate (gate) ◄── connect(ticket) hostSession(ticket)
11
+ openRoom / ticket signalling only media P2P / via a desktop app or server
12
+ kick (rotate) never media session gateway that hosts the session
13
+ │ ▲
14
+ └──────── tickets over YOUR authenticated channel┘ desktop apps: installFreehopGateway()
15
+ ```
16
+
17
+ ## 1. Backend: decide who is in a room (`freehop/authority`)
18
+ ```js
19
+ import { createAuthority } from 'freehop/authority';
20
+ const authority = createAuthority({
21
+ app: 'my-app', // namespaces room keys
22
+ gates: ['wss://example.com/freehop'], // one or more gates (yours, community, bt+wss:// trackers)
23
+ gateTokenSecrets: { 'wss://example.com/freehop': process.env.FREEHOP_GATE_TOKEN_SECRET },
24
+ stun: ['stun:example.com:3478'] // application-approved discovery servers
25
+ });
26
+ await authority.openRoom(roomId); // creates the room secret (never leaves the backend except in tickets)
27
+ const ticket = await authority.ticket(roomId, memberId); // for members your app admitted
28
+ const { tickets } = await authority.kick(roomId, memberId); // rotates the secret; deliver new tickets to the rest
29
+ ```
30
+ A ticket (`{ v, app, roomId, epoch, gates, secret, auth?, stun?, expires }`) is a bearer credential for the
31
+ room. Deliver it only to that member, over the application's own authenticated channel.
32
+ `auth` maps exact gate URLs to audience-bound tokens; community gates without a configured signing key receive no token. `stun` lists approved STUN URLs: clients ignore gate-supplied STUN hints.
33
+ `encodeTicket`/`decodeTicket` (`freehop/ticket`) turn it into a compact string.
34
+
35
+ ## 2. Gate: run the signalling service (`bin/freehop-gate.mjs`)
36
+ ```sh
37
+ FREEHOP_GATE_PORT=8787 FREEHOP_GATE_PUBLIC_HOST=example.com FREEHOP_GATE_STUN='0.0.0.0:3478,[::]:3478' \
38
+ FREEHOP_GATE_TOKEN_SECRET=… FREEHOP_GATE_TOKEN_AUDIENCE=wss://example.com/freehop FREEHOP_GATE_TRUST_PROXY=1 node bin/freehop-gate.mjs
39
+ ```
40
+ Put it behind your TLS proxy (`deploy/Caddyfile.snippet`, `deploy/freehop-gate.service`). It
41
+ carries sealed signalling only: about 15–35 KB per peer pair at setup, roughly zero
42
+ afterwards. More gates mean more resilience. Clients use every gate in the ticket, and a call
43
+ survives all gates going down. Public trackers can replace your own gate; configure approved STUN separately. Keepalives, tracker discovery and recovery still contribute signalling traffic.
44
+
45
+ ## 3. Client: join with the ticket (`freehop/sdk`)
46
+
47
+ This runs in the browser or Electron renderer. Bundle the package import with your frontend build tool, fetch the ticket from your authenticated backend, and join from a user control on an HTTPS page (localhost works for development). The callbacks below are UI placeholders; the media, update and leave calls illustrate separate controls, not a startup sequence. Node.js 22 or newer is needed for the backend/gate/host helpers, not for browser execution.
48
+
49
+ ```js
50
+ import { connect } from 'freehop';
51
+ const session = await connect(ticket, { media: { audio: true, video: false }, adaptiveVideo: true });
52
+ session.on('peer', ({ id }) => …); // an authenticated member appeared
53
+ session.on('track', ({ peer, track }) => session.attach(track, elementFor(peer)));
54
+ session.on('path', ({ peer, kind, via }) => …); // direct | gateway | relay | bridged | unreachable
55
+ session.on('peer-left', ({ id, reason }) => …);
56
+ await session.setMicrophone(false); await session.setCamera(true);
57
+ const levels = await session.levels(); // speaking indicators
58
+ await session.update(newTicket, { dropped: [kickedPeerId] }); // after a kick
59
+ await session.switchDevice('audio', deviceId); // another microphone or camera, no renegotiation
60
+ await session.send({ type: 'chat', text: 'hi' }); // app data to everyone (or { to: peerId })
61
+ session.on('message', ({ from, data }) => …); // untrusted input: render as text
62
+ session.on('video-quality', ({ peer, direction, level, reason }) => …);
63
+ await session.setAdaptiveVideo(false); // disable at runtime
64
+ await session.refresh(reissuedTicket); // same epoch, fresh gate tokens for long calls
65
+ await session.leave(); // also releases the room on a desktop gateway
66
+ ```
67
+ `session.disconnectPeer(peerId)` removes only a local connection. It does not revoke membership. The former `session.kick()` throws a migration error; use `authority.kick()` and distribute replacement tickets for removal.
68
+
69
+ `adaptiveVideo` is opt-in and defaults to `false`. When enabled, Freehop uses per-link WebRTC statistics to lower video after sustained packet loss, dropped frames, encoder CPU limitation or a low outgoing bitrate estimate. It never changes audio. It can ask the other endpoint on that link to lower video too; peers that did not opt in ignore the request. Recovery requires 25 seconds of healthy samples and moves one level at a time. Severe sustained pressure can pause video; a minimal-quality probe checks for recovery before restoring it. A manual `setCamera(false)` remains authoritative.
70
+
71
+ The `video-quality` event reports `{ peer, direction: 'send'|'receive', level: 'normal'|'reduced'|'minimal'|'paused', reason }`. `reason` is `monitoring`, `cpu`, `bandwidth`, `peer-request`, `recovery-probe`, `recovery` or `disabled`. Browser support and stats availability vary; missing measurements leave the current quality unchanged.
72
+
73
+ Map your member ids to Freehop peer ids (`session.id`) in your backend, so that a kick can
74
+ name the peer the remaining clients must drop.
75
+
76
+ Audio packets arriving does not guarantee sound playback. `attach()` attempts playback, but browsers may block it. Check `element.play()` and offer a button that retries it directly from a click; preserve an existing audio attachment when only video changes. See the [browser playback example](https://jolynstudios.github.io/freehop/docs/sdk/client#audio-playback-in-the-browser).
77
+
78
+ ## 4. Desktop apps: become reachable (`freehop/electron`)
79
+ Main process:
80
+ ```js
81
+ import { installFreehopGateway } from 'freehop/electron';
82
+ const freehop = installFreehopGateway({ ipcMain, allowedOrigins: ['https://play.example.com'] });
83
+ app.on('will-quit', () => freehop.close());
84
+ new BrowserWindow({ webPreferences: { preload: require.resolve('freehop/electron/preload'),
85
+ additionalArguments: ['--freehop-origins=https://play.example.com'], contextIsolation: true } });
86
+ ```
87
+ `connect()` finds `window.freehopGateway` automatically. It starts the gateway (TURN plus a
88
+ PCP/NAT-PMP/UPnP router mapping) on first use, allows that room, and offers it to the room's
89
+ peers with per-peer credentials. Participants behind hard NATs or UDP-blocking networks can then
90
+ reach the desktop participant without any third party. The minting key stays in the main process; the renderer requests short-lived credentials through an origin-checked broker. HTTPS origins are required except for loopback development. A raw gateway integration supplies `credentialsFor(tag, peer)` alongside its public `info()` metadata, rather than sending the key to a page.
91
+
92
+ The gateway relays only between allocations on itself (`relayScope: 'internal'`, the default), so room members cannot use it to reach other internet hosts. A window's rooms are released when its page leaves, navigates away or closes.
93
+
94
+ ## 5. Hosts: let the session's own host relay for it (`freehop/host`)
95
+ Whoever hosts a session can make its machine the session's gateway: an app server, a
96
+ community server, or a desktop app that hosts. It needs a public address or a router mapping.
97
+ ```js
98
+ import { hostSession } from 'freehop/host';
99
+ const host = await hostSession(hostTicket); // host.available === false when unreachable
100
+ await host.update(nextTicket, { dropped: [peerId] });
101
+ await host.close();
102
+ ```
103
+ Never call this on infrastructure whose bandwidth you do not want to spend. The operator's own
104
+ servers should not host sessions' media.
105
+
106
+ ## Runnable reference
107
+ `examples/minimal/` is a complete consumer: backend, gate on the same origin, and a page with
108
+ join, mic/camera, per-peer path and level, and kick through the app's API. Run it with
109
+ `node examples/minimal/server.mjs` and open it in several windows. `test/browser/example-app.mjs`
110
+ drives it with three real browsers and asserts the mesh, the kick rotation and the removal.
111
+
112
+ ---
113
+ Documentation licensed under CC BY 4.0. Copyright 2026 Jolyn Studios. Provided as is, without warranty of any kind.
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Freehop gate service. Configuration by environment:
4
+ // FREEHOP_GATE_HOST=127.0.0.1 FREEHOP_GATE_PORT=8787 FREEHOP_GATE_PATH=/freehop
5
+ // FREEHOP_GATE_PUBLIC_HOST=gate.example.com (advertised in stun: URLs)
6
+ // FREEHOP_GATE_STUN=0.0.0.0:3478[,[::]:3478] (optional STUN Binding responders)
7
+ // FREEHOP_GATE_TOKEN_SECRET=... (32+ characters: only clients with valid tokens)
8
+ // FREEHOP_GATE_TOKEN_AUDIENCE=wss://gate.example.com/freehop (required with a token secret)
9
+ // FREEHOP_GATE_TRUST_PROXY=1 (behind a local reverse proxy: use X-Forwarded-For)
10
+ // FREEHOP_GATE_ALLOW_ANONYMOUS=1 (or FREEHOP_GATE_ANONYMOUS=1: open gate beyond loopback)
11
+ // TLS is terminated by the reverse proxy (Caddy) in front of it.
12
+ import { isIP } from 'node:net';
13
+ import { lookup } from 'node:dns/promises';
14
+ import { createGate } from '../src/gate/gate.mjs';
15
+
16
+ const env = process.env;
17
+ const log = (event, details) => console.error(JSON.stringify({ at: new Date().toISOString(), event, ...details }));
18
+ const fail = message => { console.error(`freehop-gate: ${message}`); process.exit(1); };
19
+ const flag = name => { const v = env[name] ?? ''; if (!['', '0', '1'].includes(v)) fail(`${name} must be 0 or 1`); return v === '1'; };
20
+ const portNumber = (value, name) => { const n = Number(value); if (!/^\d+$/.test(String(value)) || !Number.isInteger(n) || n < 1 || n > 65535) fail(`${name} must be a port from 1 to 65535`); return n; };
21
+
22
+ const host = env.FREEHOP_GATE_HOST ?? '127.0.0.1';
23
+ if (!host) fail('FREEHOP_GATE_HOST is empty; set 0.0.0.0 or :: to listen on every interface');
24
+ const port = portNumber(env.FREEHOP_GATE_PORT ?? 8787, 'FREEHOP_GATE_PORT');
25
+ const tokenSecret = env.FREEHOP_GATE_TOKEN_SECRET || undefined;
26
+ if (tokenSecret && tokenSecret.length < 32) fail('FREEHOP_GATE_TOKEN_SECRET must be at least 32 characters, for example from: openssl rand -base64 32');
27
+ if (tokenSecret && !/^wss?:\/\//.test(env.FREEHOP_GATE_TOKEN_AUDIENCE ?? '')) fail('Set FREEHOP_GATE_TOKEN_AUDIENCE to the exact public WebSocket gate URL');
28
+ const trustProxy = flag('FREEHOP_GATE_TRUST_PROXY');
29
+ const anonymous = [flag('FREEHOP_GATE_ALLOW_ANONYMOUS'), flag('FREEHOP_GATE_ANONYMOUS')].includes(true);
30
+ // Reachable beyond loopback: the listen address is (or resolves to) anything but loopback, or a
31
+ // proxy or public host name is configured. Such a gate needs tokens or an explicit anonymous mode.
32
+ const loopback = address => /^(127\.|::1$|::ffff:127\.)/i.test(address);
33
+ const addresses = isIP(host) ? [host] : await lookup(host, { all: true }).then(list => list.map(a => a.address), () => fail(`FREEHOP_GATE_HOST ${host} does not resolve`));
34
+ if ((trustProxy || env.FREEHOP_GATE_PUBLIC_HOST || !addresses.every(loopback)) && !tokenSecret && !anonymous)
35
+ fail('A gate reachable beyond loopback needs FREEHOP_GATE_TOKEN_SECRET, or FREEHOP_GATE_ALLOW_ANONYMOUS=1 for an open gate');
36
+ const stun = (env.FREEHOP_GATE_STUN ?? '').split(',').filter(Boolean).map(spec => {
37
+ const m = /^\[?([^\]]+?)\]?:(\d+)$/.exec(spec.trim());
38
+ if (!m) fail(`Bad FREEHOP_GATE_STUN entry: ${spec}`);
39
+ return { host: m[1], port: portNumber(m[2], 'FREEHOP_GATE_STUN') };
40
+ });
41
+ if (!tokenSecret) log('warning', { message: 'anonymous gate: no FREEHOP_GATE_TOKEN_SECRET, so any client can use it' });
42
+ const gate = await createGate({
43
+ host, port, path: env.FREEHOP_GATE_PATH ?? '/freehop',
44
+ publicHost: env.FREEHOP_GATE_PUBLIC_HOST, stun, tokenSecret,
45
+ tokenAudience: env.FREEHOP_GATE_TOKEN_AUDIENCE,
46
+ trustProxy,
47
+ log
48
+ });
49
+ console.log(JSON.stringify({ event: 'listening', url: gate.url(), stun: gate.stunUrls }));
50
+ const report = setInterval(() => console.log(JSON.stringify({ event: 'stats', ...gate.stats() })), 300000);
51
+ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, async () => { clearInterval(report); await gate.close(); process.exit(0); });
@@ -0,0 +1,6 @@
1
+ # Inside the existing site block for play.example.com: the gate shares the TLS origin.
2
+ # UDP 3478 (STUN) must be open in the host firewall; no TURN/relay ports are needed.
3
+ # Caddy replaces X-Forwarded-For with the client address, which FREEHOP_GATE_TRUST_PROXY=1 relies on.
4
+ handle /freehop {
5
+ reverse_proxy 127.0.0.1:8787
6
+ }
@@ -0,0 +1,46 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Freehop gate: signalling-only rendezvous. It never carries media; its traffic is a few
3
+ # KB per call setup. Runs unprivileged, loopback-only behind Caddy, plus a public STUN port.
4
+ [Unit]
5
+ Description=Freehop gate (signalling only)
6
+ After=network-online.target
7
+ Wants=network-online.target
8
+
9
+ [Service]
10
+ User=freehop
11
+ Group=freehop
12
+ WorkingDirectory=/opt/freehop
13
+ Environment=FREEHOP_GATE_HOST=127.0.0.1
14
+ Environment=FREEHOP_GATE_PORT=8787
15
+ Environment=FREEHOP_GATE_PATH=/freehop
16
+ Environment=FREEHOP_GATE_PUBLIC_HOST=play.example.com
17
+ Environment=FREEHOP_GATE_STUN=0.0.0.0:3478,[::]:3478
18
+ Environment=FREEHOP_GATE_TRUST_PROXY=1
19
+ Environment=FREEHOP_GATE_TOKEN_AUDIENCE=wss://play.example.com/freehop
20
+ EnvironmentFile=-/etc/freehop/gate.env
21
+ ExecStart=/usr/bin/node /opt/freehop/bin/freehop-gate.mjs
22
+ Restart=on-failure
23
+ RestartSec=2
24
+ NoNewPrivileges=true
25
+ ProtectSystem=strict
26
+ ProtectHome=true
27
+ PrivateTmp=true
28
+ PrivateDevices=true
29
+ ProtectKernelTunables=true
30
+ ProtectKernelModules=true
31
+ ProtectKernelLogs=true
32
+ ProtectControlGroups=true
33
+ ProtectClock=true
34
+ RestrictNamespaces=true
35
+ RestrictRealtime=true
36
+ RestrictSUIDSGID=true
37
+ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
38
+ CapabilityBoundingSet=
39
+ UMask=0077
40
+ # V8 uses JIT executable memory; do not enable MemoryDenyWriteExecute here.
41
+ # The gate's default limits need about 225 MiB at worst, plus garbage from bursts (src/gate/gate.mjs).
42
+ MemoryMax=512M
43
+ LimitNOFILE=8192
44
+
45
+ [Install]
46
+ WantedBy=multi-user.target
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "freehop",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Peer-to-peer voice, video and data where your servers never carry or pay for media: blind gates for signalling, session-owned gateways and forwarding instead of operator relays.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "exports": {
11
+ ".": "./src/sdk/client.mjs",
12
+ "./client": "./src/client/peerlane.mjs",
13
+ "./gate": "./src/gate/gate.mjs",
14
+ "./relay": "./src/relay/agent.mjs",
15
+ "./turn": "./src/relay/turn-server.mjs",
16
+ "./port-mapper": "./src/relay/port-mapper.mjs",
17
+ "./member": "./src/relay/member.mjs",
18
+ "./stun": "./src/shared/stun.mjs",
19
+ "./sdk": "./src/sdk/client.mjs",
20
+ "./authority": "./src/sdk/authority.mjs",
21
+ "./host": "./src/sdk/host.mjs",
22
+ "./ticket": "./src/sdk/ticket.mjs",
23
+ "./electron": "./src/electron/main.mjs",
24
+ "./electron/preload": "./src/electron/preload.cjs",
25
+ "./tokens": "./src/shared/tokens.mjs"
26
+ },
27
+ "scripts": {
28
+ "test": "node --test test/*.test.mjs",
29
+ "test:browsers": "node test/browser/smoke.mjs chromium,firefox,webkit --tls && node test/browser/multigate.mjs && node test/browser/rekey.mjs && node test/browser/example-app.mjs",
30
+ "gate": "node bin/freehop-gate.mjs",
31
+ "example": "node examples/minimal/server.mjs"
32
+ },
33
+ "author": "Jolyn Studios",
34
+ "homepage": "https://jolynstudios.github.io/freehop/",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/jolynstudios/freehop.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/jolynstudios/freehop/issues"
41
+ },
42
+ "keywords": [
43
+ "webrtc",
44
+ "p2p",
45
+ "peer-to-peer",
46
+ "voice-chat",
47
+ "video-chat",
48
+ "nat-traversal",
49
+ "turn",
50
+ "stun",
51
+ "upnp",
52
+ "pcp",
53
+ "nat-pmp",
54
+ "signalling",
55
+ "games",
56
+ "sdk"
57
+ ],
58
+ "bin": {
59
+ "freehop-gate": "bin/freehop-gate.mjs"
60
+ },
61
+ "files": [
62
+ "src/",
63
+ "bin/",
64
+ "deploy/",
65
+ "README.md",
66
+ "ARCHITECTURE.md",
67
+ "PROTOCOL.md",
68
+ "SDK.md",
69
+ "LICENSE",
70
+ "LICENSE-docs",
71
+ "NOTICE"
72
+ ],
73
+ "dependencies": {
74
+ "ws": "^8.21.3"
75
+ },
76
+ "devDependencies": {
77
+ "playwright": "1.62.0"
78
+ }
79
+ }
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The room secret never leaves the peers. HKDF derives an opaque room tag (the only room
3
+ // identifier a gate sees) and an AES-GCM key that seals every signalling envelope end to end.
4
+ const te = new TextEncoder(), td = new TextDecoder();
5
+ const subtle = globalThis.crypto.subtle;
6
+
7
+ export function toBase64Url(bytes) {
8
+ let s = ''; for (const b of bytes) s += String.fromCharCode(b);
9
+ return btoa(s).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
10
+ }
11
+ export function fromBase64Url(text) {
12
+ const s = atob(text.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - text.length % 4) % 4));
13
+ const out = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
14
+ return out;
15
+ }
16
+ export const randomId = (bytes = 16) => toBase64Url(globalThis.crypto.getRandomValues(new Uint8Array(bytes)));
17
+
18
+ /** deriveRoom(secret, app) -> { tag, key }. `secret` is a string or bytes with >=128 bits of entropy. */
19
+ export async function deriveRoom(secret, app = 'peerlane') {
20
+ const raw = typeof secret === 'string' ? te.encode(secret) : secret;
21
+ if (!(raw instanceof Uint8Array) || raw.length < 16) throw new TypeError('Room secret must carry at least 16 bytes.');
22
+ const base = await subtle.importKey('raw', raw, 'HKDF', false, ['deriveBits', 'deriveKey']);
23
+ const salt = te.encode('peerlane/v1/' + app);
24
+ const tag = toBase64Url(new Uint8Array(await subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: te.encode('room-tag') }, base, 256)));
25
+ const key = await subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt, info: te.encode('envelope-key') }, base,
26
+ { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
27
+ return { tag, key };
28
+ }
29
+
30
+ // The additional data binds room, sender and recipient: a gate that re-routes or relabels an
31
+ // envelope makes it fail authentication instead of reaching the wrong peer.
32
+ const aad = (room, from, to) => te.encode(`peerlane/v1|${room.tag}|${from}|${to}`);
33
+
34
+ export async function seal(room, from, to, payload) {
35
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
36
+ const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv, additionalData: aad(room, from, to) }, room.key, te.encode(JSON.stringify(payload))));
37
+ const out = new Uint8Array(12 + ct.length); out.set(iv); out.set(ct, 12);
38
+ return toBase64Url(out);
39
+ }
40
+
41
+ export async function open(room, from, to, box) {
42
+ try {
43
+ const bytes = fromBase64Url(box);
44
+ if (bytes.length < 29) return null;
45
+ const plain = await subtle.decrypt({ name: 'AES-GCM', iv: bytes.subarray(0, 12), additionalData: aad(room, from, to) }, room.key, bytes.subarray(12));
46
+ const payload = JSON.parse(td.decode(plain));
47
+ return payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : null;
48
+ } catch { return null; }
49
+ }
@@ -0,0 +1,95 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // One connection to one gate. Gates are interchangeable mailboxes: a room uses several at
3
+ // once, reconnects each independently and never depends on any single one.
4
+ export class Emitter {
5
+ #handlers = new Map();
6
+ on(type, fn) { (this.#handlers.get(type) ?? this.#handlers.set(type, new Set()).get(type)).add(fn); return () => this.off(type, fn); }
7
+ off(type, fn) { this.#handlers.get(type)?.delete(fn); }
8
+ emit(type, detail) { for (const fn of [...(this.#handlers.get(type) ?? [])]) { try { fn(detail); } catch (error) { console.error(error); } } }
9
+ }
10
+
11
+ export class GateClient extends Emitter {
12
+ constructor(url, { room, peer, auth, WebSocketImpl = globalThis.WebSocket, backoffMs = [500, 1000, 2000, 4000, 8000, 15000, 30000] } = {}) {
13
+ super();
14
+ Object.assign(this, { url, room, peer, auth, WebSocketImpl, backoffMs, pingMs: 30000 });
15
+ this.state = 'idle'; this.attempt = 0; this.ws = null; this.closed = false; this.stun = [];
16
+ this.counters = { sent: 0, received: 0, bytesOut: 0, bytesIn: 0, errors: 0, connects: 0 };
17
+ }
18
+ connect() {
19
+ if (this.closed || this.ws) return;
20
+ this.state = 'connecting';
21
+ let ws;
22
+ try { ws = new this.WebSocketImpl(this.url); } catch { this.#retry(); return; }
23
+ this.ws = ws;
24
+ ws.onopen = async () => {
25
+ let auth;
26
+ try { auth = typeof this.auth === 'function' ? await this.auth(this.url) : typeof this.auth === 'object' && this.auth ? this.auth[this.url] : this.auth; }
27
+ catch { if (ws === this.ws) { this.counters.errors++; ws.close(); } return; }
28
+ if (ws !== this.ws) return;
29
+ this.#raw({ t: 'hello', v: 1, ...(auth ? { auth } : {}) });
30
+ };
31
+ ws.onmessage = event => {
32
+ if (ws !== this.ws || typeof event.data !== 'string' || event.data.length > 65536) return;
33
+ this.counters.bytesIn += event.data.length;
34
+ let m; try { m = JSON.parse(event.data); } catch { return; }
35
+ switch (m?.t) {
36
+ case 'welcome':
37
+ // STUN belongs to application configuration, never to an untrusted mailbox.
38
+ this.stun = [];
39
+ this.#raw({ t: 'join', room: this.room, peer: this.peer });
40
+ break;
41
+ case 'peers':
42
+ if (m.room !== this.room || !Array.isArray(m.peers) || m.peers.length > 64) return;
43
+ this.state = 'joined'; this.attempt = 0; this.joinAttempts = 0; this.counters.connects++;
44
+ // Application-level keepalive: the gate drops sockets it has not heard from.
45
+ clearInterval(this.pinger);
46
+ this.pinger = setInterval(() => this.#raw({ t: 'ping' }), this.pingMs);
47
+ this.emit('joined', { gate: this, peers: m.peers.filter(p => typeof p === 'string'), stun: this.stun });
48
+ break;
49
+ case 'peer': if (m.room === this.room) this.emit('peer', { gate: this, peer: m.peer, on: !!m.on }); break;
50
+ case 'recv':
51
+ if (m.room === this.room && typeof m.from === 'string' && typeof m.box === 'string' && m.box.length <= 49152) {
52
+ this.counters.received++; this.emit('recv', { gate: this, from: m.from, box: m.box });
53
+ }
54
+ break;
55
+ case 'error':
56
+ this.counters.errors++; this.emit('gate-error', { gate: this, code: m.code });
57
+ // After a network change the gate may still hold our previous socket for a moment.
58
+ if (m.code === 'peer-taken' && this.state !== 'joined' && (this.joinAttempts = (this.joinAttempts ?? 0) + 1) <= 8) {
59
+ setTimeout(() => { if (ws === this.ws && this.state !== 'joined') this.#raw({ t: 'join', room: this.room, peer: this.peer }); }, 1000 * this.joinAttempts);
60
+ }
61
+ break;
62
+ }
63
+ };
64
+ ws.onclose = () => {
65
+ if (ws !== this.ws) return;
66
+ clearInterval(this.pinger);
67
+ this.ws = null; const was = this.state; this.state = 'idle';
68
+ if (was === 'joined') this.emit('left', { gate: this });
69
+ this.#retry();
70
+ };
71
+ ws.onerror = () => { this.counters.errors++; };
72
+ }
73
+ #retry() {
74
+ if (this.closed) return;
75
+ const delay = this.backoffMs[Math.min(this.attempt++, this.backoffMs.length - 1)];
76
+ this.timer = setTimeout(() => { this.timer = null; this.connect(); }, delay * (0.75 + Math.random() / 2));
77
+ }
78
+ #raw(message) {
79
+ if (this.ws?.readyState !== 1) return false;
80
+ const text = JSON.stringify(message);
81
+ this.ws.send(text); this.counters.bytesOut += text.length;
82
+ return true;
83
+ }
84
+ send(to, box) {
85
+ if (this.state !== 'joined') return false;
86
+ const ok = this.#raw({ t: 'send', room: this.room, to, box });
87
+ if (ok) this.counters.sent++;
88
+ return ok;
89
+ }
90
+ close() {
91
+ this.closed = true; clearTimeout(this.timer); clearInterval(this.pinger);
92
+ const ws = this.ws; this.ws = null; this.state = 'closed';
93
+ if (ws) { try { if (ws.readyState === 1) ws.send(JSON.stringify({ t: 'leave', room: this.room })); ws.close(1000); } catch {} }
94
+ }
95
+ }
@@ -0,0 +1,14 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Validate one ICE server URI, including host/port syntax; never accept lists in one URI.
3
+ export function validIceUrl(value, kind) {
4
+ if (typeof value !== 'string' || value.length > 240) return false;
5
+ const m = /^(stun|stuns|turn|turns):(\[[0-9a-fA-F:.]+\]|[a-zA-Z0-9.-]+)(?::([0-9]{1,5}))?(?:\?transport=(udp|tcp))?$/.exec(value);
6
+ if (!m || !m[1].startsWith(kind) || kind === 'stun' && m[4] || m[3] && (Number(m[3]) < 1 || Number(m[3]) > 65535)) return false;
7
+ // Hostnames: dot-separated labels of 1-63 letters, digits or inner hyphens (no empty or edge-hyphen labels).
8
+ if (!m[2].startsWith('[') && !/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.?$/.test(m[2])) return false;
9
+ try {
10
+ const url = new URL(`http://${m[2]}:${m[3] ?? 3478}`);
11
+ return !!url.hostname && !url.username && !url.password;
12
+ } catch { return false; }
13
+ }
14
+ export const validStunUrls = urls => Array.isArray(urls) && urls.length <= 4 && urls.every(url => validIceUrl(url, 'stun'));