freehop 0.1.0-alpha.2 → 0.1.0-alpha.4
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/README.md +62 -26
- package/SDK.md +13 -0
- package/package.json +72 -19
- package/src/client/peerlane.d.mts +185 -0
- package/src/client/room.mjs +25 -2
- package/src/electron/main.d.mts +27 -0
- package/src/electron/preload.d.cts +3 -0
- package/src/gate/gate.d.mts +60 -0
- package/src/relay/agent.d.mts +40 -0
- package/src/relay/member.d.mts +23 -0
- package/src/relay/port-mapper.d.mts +77 -0
- package/src/relay/turn-server.d.mts +62 -0
- package/src/sdk/authority.d.mts +32 -0
- package/src/sdk/client.d.mts +23 -0
- package/src/sdk/host.d.mts +30 -0
- package/src/sdk/ticket.d.mts +18 -0
- package/src/shared/stun.d.mts +87 -0
- package/src/shared/tokens.d.mts +14 -0
- package/src/shared/types.d.mts +41 -0
package/README.md
CHANGED
|
@@ -1,23 +1,59 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img src="website/static/img/logo.svg" alt="Freehop" width="76" />
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
# freehop
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
**[Documentation](https://jolynstudios.github.io/freehop/)** ·
|
|
9
|
-
**[Quickstart](#quickstart)** ·
|
|
10
|
-
**[Architecture](ARCHITECTURE.md)** ·
|
|
11
|
-
**[Protocol](PROTOCOL.md)**
|
|
7
|
+
### Voice and video, inside your app.
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
** · **[Documentation](https://jolynstudios.github.io/freehop/docs)** · **[Quickstart](#quickstart)** · **[Protocol](PROTOCOL.md)**
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+

|
|
15
|
+

|
|
18
16
|
|
|
19
17
|
</div>
|
|
20
18
|
|
|
19
|
+
**Typed SDK:** declarations for all 15 public entry points are included in **0.1.0-alpha.4**.
|
|
20
|
+
Install the current alpha from [npm](https://www.npmjs.com/package/freehop), or download the
|
|
21
|
+
[GitHub release](https://github.com/jolynstudios/freehop/releases/tag/v0.1.0-alpha.4).
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install freehop@alpha
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
See the [TypeScript guide](https://jolynstudios.github.io/freehop/docs/typescript) for typed sessions,
|
|
28
|
+
events and backend APIs. JavaScript usage is unchanged.
|
|
29
|
+
|
|
30
|
+
## Add a call to what you’re building
|
|
31
|
+
|
|
32
|
+
Build a small meeting app, add a support call to an existing tool, or put voice beside a shared workspace. Freehop provides audio, optional video and small session messages inside your own interface. The same SDK also fits community rooms and games.
|
|
33
|
+
|
|
34
|
+
**Build with AI. Build without it. Own the result.** Use an AI coding agent to scaffold an integration or write it yourself. Freehop has an inspectable JavaScript API and source; bring your own models, application logic, authentication and backend. It does not include an AI model, transcription service, or agent runtime.
|
|
35
|
+
|
|
36
|
+
| Start here | What you can try |
|
|
37
|
+
|---|---|
|
|
38
|
+
| [Hopper](https://jolynstudios.github.io/freehop/hopper) | Small rooms with device preview, video, voice and chat. |
|
|
39
|
+
| [Live demo](https://jolynstudios.github.io/freehop/demo) | A real browser call with connection-path and playback inspection. |
|
|
40
|
+
|
|
41
|
+
Open Hopper or the live demo in two browsers, or share a room link with someone. Both run the real Freehop client and demonstrate voice, video and session connections.
|
|
42
|
+
|
|
43
|
+
### Where it fits
|
|
44
|
+
|
|
45
|
+
| Build | Freehop provides | Your app provides |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| Small meeting rooms | Voice, video, controls and chat messages | Room UI, invitations and admission |
|
|
48
|
+
| Calls inside a portal or tool | A call attached to the current task | Identity, access rules and customer/project data |
|
|
49
|
+
| Shared workspaces | Media plus small selection or ready events | Editor, document sync and persistence |
|
|
50
|
+
| Electron collaboration apps | Renderer client and optional desktop gateway | Desktop UI, packaging and updates |
|
|
51
|
+
| Community rooms and games | Voice-first rooms, optional video, small events | Community rules, activity or game logic |
|
|
52
|
+
|
|
53
|
+
These are integration ideas; Hopper and the live demo are the working examples. See the [use-case recipes](https://jolynstudios.github.io/freehop/docs/use-cases).
|
|
54
|
+
|
|
55
|
+
Read the [Three.js integration guide](https://jolynstudios.github.io/freehop/docs/games), [Electron reference](https://jolynstudios.github.io/freehop/docs/sdk/desktop), or [AI build brief](https://jolynstudios.github.io/freehop/docs/build-with-ai).
|
|
56
|
+
|
|
21
57
|
---
|
|
22
58
|
|
|
23
59
|
WebRTC first tries to connect people directly. Strict NATs and firewalls can block that path, so
|
|
@@ -35,23 +71,23 @@ works, Freehop reports `unreachable` instead of sending media through an operato
|
|
|
35
71
|
|
|
36
72
|
| | |
|
|
37
73
|
|---|---|
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
74
|
+
| **No media through your gate** | Gates carry sealed signalling only. In every home-lab network test, gate traffic for an entire scenario stayed between 30 and 210 KB. A session gateway you run can still carry media on your bill. |
|
|
75
|
+
| **Routes through the session** | Two strict (symmetric) NATs, or a network that blocks UDP, can't connect directly. Freehop routes them through a session member's gateway or the session host, still with no operator relay. |
|
|
76
|
+
| **Multiple signalling gates** | Run one gate or many, operated by you, your community, or public WebTorrent trackers. Peers on different gates still find each other. **Calls keep running when every gate is down.** |
|
|
77
|
+
| **Private by construction** | Signalling is sealed (HKDF + AES-256-GCM), so gates can't read or forge envelopes. Direct and gateway paths preserve end-to-end DTLS-SRTP; a forwarding participant decodes and re-encodes media. |
|
|
78
|
+
| **Automatic** | No manual room link is required in a ticket-based integration. Your backend issues a ticket, and the SDK takes the cheapest path that works: direct, then gateway, then relay, then bridge. |
|
|
79
|
+
| **Small and open** | Zero-dependency browser client; a gate with one dependency (`ws`); TURN gateway and PCP / NAT-PMP / UPnP port mapping in plain Node. Apache-2.0. |
|
|
44
80
|
|
|
45
81
|
## How it works
|
|
46
82
|
|
|
47
83
|
```mermaid
|
|
48
84
|
flowchart LR
|
|
49
85
|
subgraph session["One session (a call)"]
|
|
50
|
-
A["
|
|
51
|
-
B["
|
|
52
|
-
H["
|
|
86
|
+
A["Participant A<br/>(browser)"]
|
|
87
|
+
B["Participant B<br/>(phone, strict NAT)"]
|
|
88
|
+
H["Host node or<br/>desktop gateway"]
|
|
53
89
|
end
|
|
54
|
-
G["
|
|
90
|
+
G["Gate<br/>(your server)"]
|
|
55
91
|
A -. "sealed envelopes (KB)" .-> G
|
|
56
92
|
B -. "sealed envelopes (KB)" .-> G
|
|
57
93
|
A == "media: direct when possible" ==> B
|
|
@@ -64,7 +100,7 @@ flowchart LR
|
|
|
64
100
|
3. **The path ladder finds a route.** Freehop tries direct first (LAN, IPv6, STUN). If that fails it uses an endpoint's own gateway, then a gateway of another session member, then forwarding through a participant. A failure is reported honestly as `unreachable`.
|
|
65
101
|
4. **Kicks rotate keys.** `authority.kick()` issues a new room secret; remaining members `update()` and drop the kicked peer.
|
|
66
102
|
|
|
67
|
-
##
|
|
103
|
+
## Published network evidence
|
|
68
104
|
|
|
69
105
|
These results come from Freehop's own home-built, isolated Linux network test environment—not
|
|
70
106
|
an independent testing laboratory or tests on live ISP, mobile or corporate networks. Real
|
|
@@ -88,8 +124,8 @@ arrive.
|
|
|
88
124
|
|
|
89
125
|
**40/40 home-lab network test runs passed** on 2 October 2026 after security hardening,
|
|
90
126
|
including gateways that relay only inside their session. That matrix has not been rerun for
|
|
91
|
-
|
|
92
|
-
- **
|
|
127
|
+
this revision. These are repeatable simulated-network results, not field reliability evidence. Other checks:
|
|
128
|
+
- **173/173 unit tests**, including the RFC 5769 STUN vectors and the security regressions.
|
|
93
129
|
- **coturn's own test client** against Freehop's TURN server: 800/800 messages over UDP and 800/800 over TCP, 0 lost.
|
|
94
130
|
- **Browser suites:** multi-gate with every gate shut down mid-call, kick/rekey, a public WebTorrent tracker as the only gate, and the SDK example app.
|
|
95
131
|
|
|
@@ -111,7 +147,7 @@ Freehop is an independent project; any app can use the same public SDK.
|
|
|
111
147
|
## Quickstart
|
|
112
148
|
|
|
113
149
|
```bash
|
|
114
|
-
npm install freehop@alpha # current alpha
|
|
150
|
+
npm install freehop@alpha # install the current alpha release
|
|
115
151
|
```
|
|
116
152
|
|
|
117
153
|
**Backend:** decide who is in a room.
|
package/SDK.md
CHANGED
|
@@ -4,6 +4,18 @@ Freehop is built to be consumed. Any application uses the same five pieces. Redl
|
|
|
4
4
|
a planned consumer and test environment, using the same public API. Nothing below is
|
|
5
5
|
game-specific.
|
|
6
6
|
|
|
7
|
+
## TypeScript support
|
|
8
|
+
|
|
9
|
+
Version 0.1.0-alpha.4 includes declarations for every public entry point. The runtime remains
|
|
10
|
+
JavaScript; options, event callbacks, tickets, stats and backend APIs are typed for consumers.
|
|
11
|
+
Import `type Session`, `type Ticket` or `type ConnectOptions` from `freehop` as needed.
|
|
12
|
+
Incoming message data is `unknown` and must be narrowed. Outgoing messages are checked for
|
|
13
|
+
JSON compatibility. Backend-only imports do not require DOM types.
|
|
14
|
+
|
|
15
|
+
Install the typed release with `npm install freehop@alpha`. It is also available on
|
|
16
|
+
[GitHub](https://github.com/jolynstudios/freehop/releases/tag/v0.1.0-alpha.4). See the [TypeScript setup guide](https://jolynstudios.github.io/freehop/docs/typescript).
|
|
17
|
+
|
|
18
|
+
|
|
7
19
|
```
|
|
8
20
|
your backend your gate host every client hosts (optional)
|
|
9
21
|
─────────── ───────────── ──────────── ────────────────
|
|
@@ -55,6 +67,7 @@ session.on('path', ({ peer, kind, via }) => …); // direct | gateway |
|
|
|
55
67
|
session.on('peer-left', ({ id, reason }) => …);
|
|
56
68
|
await session.setMicrophone(false); await session.setCamera(true);
|
|
57
69
|
const levels = await session.levels(); // speaking indicators
|
|
70
|
+
await session.setPeerMuted(peerId, true); // silence one member locally
|
|
58
71
|
await session.update(newTicket, { dropped: [kickedPeerId] }); // after a kick
|
|
59
72
|
await session.switchDevice('audio', deviceId); // another microphone or camera, no renegotiation
|
|
60
73
|
await session.send({ type: 'chat', text: 'hi' }); // app data to everyone (or { to: peerId })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freehop",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.4",
|
|
4
4
|
"description": "Open-source peer-to-peer voice, video and data SDK for browser and desktop apps, with signalling gates and session-member media gateways.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -8,27 +8,77 @@
|
|
|
8
8
|
"node": ">=22"
|
|
9
9
|
},
|
|
10
10
|
"exports": {
|
|
11
|
-
".":
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"./
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"./
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"./
|
|
24
|
-
|
|
25
|
-
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./src/sdk/client.d.mts",
|
|
13
|
+
"default": "./src/sdk/client.mjs"
|
|
14
|
+
},
|
|
15
|
+
"./client": {
|
|
16
|
+
"types": "./src/client/peerlane.d.mts",
|
|
17
|
+
"default": "./src/client/peerlane.mjs"
|
|
18
|
+
},
|
|
19
|
+
"./gate": {
|
|
20
|
+
"types": "./src/gate/gate.d.mts",
|
|
21
|
+
"default": "./src/gate/gate.mjs"
|
|
22
|
+
},
|
|
23
|
+
"./relay": {
|
|
24
|
+
"types": "./src/relay/agent.d.mts",
|
|
25
|
+
"default": "./src/relay/agent.mjs"
|
|
26
|
+
},
|
|
27
|
+
"./turn": {
|
|
28
|
+
"types": "./src/relay/turn-server.d.mts",
|
|
29
|
+
"default": "./src/relay/turn-server.mjs"
|
|
30
|
+
},
|
|
31
|
+
"./port-mapper": {
|
|
32
|
+
"types": "./src/relay/port-mapper.d.mts",
|
|
33
|
+
"default": "./src/relay/port-mapper.mjs"
|
|
34
|
+
},
|
|
35
|
+
"./member": {
|
|
36
|
+
"types": "./src/relay/member.d.mts",
|
|
37
|
+
"default": "./src/relay/member.mjs"
|
|
38
|
+
},
|
|
39
|
+
"./stun": {
|
|
40
|
+
"types": "./src/shared/stun.d.mts",
|
|
41
|
+
"default": "./src/shared/stun.mjs"
|
|
42
|
+
},
|
|
43
|
+
"./sdk": {
|
|
44
|
+
"types": "./src/sdk/client.d.mts",
|
|
45
|
+
"default": "./src/sdk/client.mjs"
|
|
46
|
+
},
|
|
47
|
+
"./authority": {
|
|
48
|
+
"types": "./src/sdk/authority.d.mts",
|
|
49
|
+
"default": "./src/sdk/authority.mjs"
|
|
50
|
+
},
|
|
51
|
+
"./host": {
|
|
52
|
+
"types": "./src/sdk/host.d.mts",
|
|
53
|
+
"default": "./src/sdk/host.mjs"
|
|
54
|
+
},
|
|
55
|
+
"./ticket": {
|
|
56
|
+
"types": "./src/sdk/ticket.d.mts",
|
|
57
|
+
"default": "./src/sdk/ticket.mjs"
|
|
58
|
+
},
|
|
59
|
+
"./electron": {
|
|
60
|
+
"types": "./src/electron/main.d.mts",
|
|
61
|
+
"default": "./src/electron/main.mjs"
|
|
62
|
+
},
|
|
63
|
+
"./electron/preload": {
|
|
64
|
+
"types": "./src/electron/preload.d.cts",
|
|
65
|
+
"default": "./src/electron/preload.cjs"
|
|
66
|
+
},
|
|
67
|
+
"./tokens": {
|
|
68
|
+
"types": "./src/shared/tokens.d.mts",
|
|
69
|
+
"default": "./src/shared/tokens.mjs"
|
|
70
|
+
}
|
|
26
71
|
},
|
|
27
72
|
"scripts": {
|
|
28
73
|
"test": "node --test test/*.test.mjs",
|
|
29
74
|
"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",
|
|
75
|
+
"unitgate": "node tools/unitgate.mjs",
|
|
76
|
+
"exportsgate": "node tools/exportsgate.mjs",
|
|
77
|
+
"docsyncgate": "node tools/docsyncgate.mjs",
|
|
78
|
+
"ship": "node tools/ship.mjs",
|
|
30
79
|
"gate": "node bin/freehop-gate.mjs",
|
|
31
|
-
"example": "node examples/minimal/server.mjs"
|
|
80
|
+
"example": "node examples/minimal/server.mjs",
|
|
81
|
+
"typesgate": "node tools/typesgate.mjs"
|
|
32
82
|
},
|
|
33
83
|
"author": "Jolyn Studios",
|
|
34
84
|
"homepage": "https://jolynstudios.github.io/freehop/",
|
|
@@ -71,9 +121,12 @@
|
|
|
71
121
|
"NOTICE"
|
|
72
122
|
],
|
|
73
123
|
"dependencies": {
|
|
124
|
+
"@types/node": "22.20.5",
|
|
74
125
|
"ws": "^8.21.3"
|
|
75
126
|
},
|
|
76
127
|
"devDependencies": {
|
|
77
|
-
"playwright": "1.62.0"
|
|
78
|
-
|
|
128
|
+
"playwright": "1.62.0",
|
|
129
|
+
"typescript": "7.0.2"
|
|
130
|
+
},
|
|
131
|
+
"types": "./src/sdk/client.d.mts"
|
|
79
132
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import type {ClientGateway, GateAuth, JsonValue, JsonCompatible, Log} from '../shared/types.mjs';
|
|
3
|
+
export type {ClientGateway, GateAuth, JsonValue, JsonCompatible, Log} from '../shared/types.mjs';
|
|
4
|
+
export type PathKind = 'connecting' | 'direct' | 'gateway' | 'relay' | 'bridged' | 'unreachable';
|
|
5
|
+
export type VideoQualityLevel = 'normal' | 'reduced' | 'minimal' | 'paused';
|
|
6
|
+
export interface ConnectionPath {
|
|
7
|
+
kind: PathKind;
|
|
8
|
+
via?: string | null;
|
|
9
|
+
phase?: 0 | 1 | 2;
|
|
10
|
+
local?: RTCIceCandidateType;
|
|
11
|
+
remote?: RTCIceCandidateType;
|
|
12
|
+
protocol?: string;
|
|
13
|
+
relayProtocol?: string | null;
|
|
14
|
+
localAddress?: string | null;
|
|
15
|
+
remoteAddress?: string | null;
|
|
16
|
+
relayUrl?: string | null;
|
|
17
|
+
rtt?: number | null;
|
|
18
|
+
bytesSent?: number;
|
|
19
|
+
bytesReceived?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface RoomEvents {
|
|
22
|
+
peer: {id: string};
|
|
23
|
+
'peer-left': {id: string; reason: 'bye' | 'dropped' | 'rekey' | 'gone'};
|
|
24
|
+
/** Untrusted data. Narrow/validate before rendering or acting on it. */
|
|
25
|
+
message: {from: string; data: unknown};
|
|
26
|
+
track: {peer: string; via: string | null; track: MediaStreamTrack; stream: MediaStream};
|
|
27
|
+
'track-ended': {peer: string; via: string; stream: string};
|
|
28
|
+
path: ConnectionPath & {peer: string};
|
|
29
|
+
'video-quality': {peer: string; direction: 'send' | 'receive'; level: VideoQualityLevel; reason: string};
|
|
30
|
+
link: {peer: string; connected: boolean};
|
|
31
|
+
gate: {url: string; state: 'joined' | 'reconnecting' | 'error'; code?: string};
|
|
32
|
+
gateway: {id: string; available: boolean};
|
|
33
|
+
closed: Record<string, never>;
|
|
34
|
+
}
|
|
35
|
+
export interface RoomTiming {
|
|
36
|
+
capsWaitMs: number;
|
|
37
|
+
politeWaitMs: number;
|
|
38
|
+
endpointMs: number;
|
|
39
|
+
sessionMs: number;
|
|
40
|
+
bridgedRetryMs: number;
|
|
41
|
+
maxRetryMs: number;
|
|
42
|
+
restartFallbackMs: number;
|
|
43
|
+
mediaWatchMs: number;
|
|
44
|
+
recoveryMs: number;
|
|
45
|
+
bridgeWaitMs: number;
|
|
46
|
+
departGraceMs: number;
|
|
47
|
+
offerTimeoutMs: number;
|
|
48
|
+
greetMs: number;
|
|
49
|
+
outboxMs: number;
|
|
50
|
+
sweepMs: number;
|
|
51
|
+
}
|
|
52
|
+
export interface RoomLimits {
|
|
53
|
+
audioBitrate: number;
|
|
54
|
+
videoBitrate: number;
|
|
55
|
+
forwardVideoBitrate: number;
|
|
56
|
+
maxPeers: number;
|
|
57
|
+
maxGatewayMembers: number;
|
|
58
|
+
maxHints: number;
|
|
59
|
+
maxBridges: number;
|
|
60
|
+
meshForwardPerMinute: number;
|
|
61
|
+
outboxPerPeer: number;
|
|
62
|
+
}
|
|
63
|
+
export interface MediaOptions {
|
|
64
|
+
audio?: boolean;
|
|
65
|
+
video?: boolean;
|
|
66
|
+
}
|
|
67
|
+
export interface MediaControls {
|
|
68
|
+
media?: MediaOptions | MediaStream | null;
|
|
69
|
+
devices?: {audio?: string | null; video?: string | null};
|
|
70
|
+
gateway?: ClientGateway | null;
|
|
71
|
+
stun?: string[];
|
|
72
|
+
forward?: boolean;
|
|
73
|
+
adaptiveVideo?: boolean;
|
|
74
|
+
timing?: Partial<RoomTiming>;
|
|
75
|
+
limits?: Partial<RoomLimits>;
|
|
76
|
+
log?: Log;
|
|
77
|
+
getUserMedia?: (constraints: MediaStreamConstraints) => Promise<MediaStream>;
|
|
78
|
+
RTCPeerConnection?: typeof RTCPeerConnection;
|
|
79
|
+
WebSocket?: typeof WebSocket;
|
|
80
|
+
}
|
|
81
|
+
export interface JoinOptions extends MediaControls {
|
|
82
|
+
gates: string[];
|
|
83
|
+
secret: string | Uint8Array;
|
|
84
|
+
app?: string;
|
|
85
|
+
auth?: GateAuth;
|
|
86
|
+
}
|
|
87
|
+
export interface InboundMediaStats {
|
|
88
|
+
packets: number;
|
|
89
|
+
bytes: number;
|
|
90
|
+
frames: number;
|
|
91
|
+
samples: number;
|
|
92
|
+
lost: number;
|
|
93
|
+
jitter: number;
|
|
94
|
+
streams: number;
|
|
95
|
+
width?: number;
|
|
96
|
+
height?: number;
|
|
97
|
+
}
|
|
98
|
+
export interface OutboundMediaStats {
|
|
99
|
+
packets: number;
|
|
100
|
+
bytes: number;
|
|
101
|
+
frames: number;
|
|
102
|
+
streams: number;
|
|
103
|
+
}
|
|
104
|
+
export interface LinkStats {
|
|
105
|
+
peer: string;
|
|
106
|
+
connected: boolean;
|
|
107
|
+
phase: 0 | 1 | 2;
|
|
108
|
+
path: ConnectionPath;
|
|
109
|
+
restarts: number;
|
|
110
|
+
connectMs: number | null;
|
|
111
|
+
forwardedOrigins: string[];
|
|
112
|
+
forwarding: string[];
|
|
113
|
+
media: {
|
|
114
|
+
inbound: Partial<Record<'audio' | 'video', InboundMediaStats>>;
|
|
115
|
+
outbound: Partial<Record<'audio' | 'video', OutboundMediaStats>>;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export interface GateStats {
|
|
119
|
+
url: string;
|
|
120
|
+
state: string;
|
|
121
|
+
sent: number;
|
|
122
|
+
received: number;
|
|
123
|
+
bytesOut: number;
|
|
124
|
+
bytesIn: number;
|
|
125
|
+
errors: number;
|
|
126
|
+
connects: number;
|
|
127
|
+
}
|
|
128
|
+
export interface RoomStats {
|
|
129
|
+
id: string;
|
|
130
|
+
tag: string;
|
|
131
|
+
counters: Record<string, number>;
|
|
132
|
+
gates: GateStats[];
|
|
133
|
+
links: LinkStats[];
|
|
134
|
+
bridges: Array<{peer: string; via: string | null; state: string; requestedAt: number}>;
|
|
135
|
+
relaying: Array<{a: string; b: string}>;
|
|
136
|
+
}
|
|
137
|
+
export interface PeerConnectionView {
|
|
138
|
+
id: string;
|
|
139
|
+
pc: RTCPeerConnection;
|
|
140
|
+
connected: boolean;
|
|
141
|
+
closed: boolean;
|
|
142
|
+
phase: 0 | 1 | 2;
|
|
143
|
+
path: ConnectionPath;
|
|
144
|
+
}
|
|
145
|
+
export class Room {
|
|
146
|
+
constructor(options: JoinOptions);
|
|
147
|
+
readonly id: string;
|
|
148
|
+
/** Available after start(). Prefer join() for an initialized room. */
|
|
149
|
+
readonly tag: string | undefined;
|
|
150
|
+
readonly tag8: string;
|
|
151
|
+
readonly app: string;
|
|
152
|
+
readonly closed: boolean;
|
|
153
|
+
readonly localStream: MediaStream | null;
|
|
154
|
+
readonly links: Map<string, PeerConnectionView>;
|
|
155
|
+
readonly known: Set<string>;
|
|
156
|
+
readonly options: JoinOptions;
|
|
157
|
+
readonly timing: RoomTiming;
|
|
158
|
+
readonly limits: RoomLimits;
|
|
159
|
+
readonly adaptiveVideoEnabled: boolean;
|
|
160
|
+
readonly devices: {audio: string | null; video: string | null};
|
|
161
|
+
on<E extends keyof RoomEvents>(event: E, handler: (detail: RoomEvents[E]) => void): () => void;
|
|
162
|
+
off<E extends keyof RoomEvents>(event: E, handler: (detail: RoomEvents[E]) => void): void;
|
|
163
|
+
start(): Promise<void>;
|
|
164
|
+
rekey(secret: string | Uint8Array, options?: {auth?: GateAuth; gates?: string[]; stun?: string[]}): Promise<void>;
|
|
165
|
+
drop(peer: string): void;
|
|
166
|
+
send<T>(data: T & JsonCompatible<T>, options?: {to?: string}): Promise<number>;
|
|
167
|
+
setGateway(gateway: ClientGateway | null): Promise<void>;
|
|
168
|
+
setMedia(media: MediaOptions | MediaStream | null): Promise<void>;
|
|
169
|
+
setMicrophone(enabled: boolean): Promise<void>;
|
|
170
|
+
setCamera(enabled: boolean): Promise<void>;
|
|
171
|
+
setPeerMuted(peer: string, muted: boolean): void;
|
|
172
|
+
switchDevice(kind: 'audio' | 'video', deviceId: string | null): Promise<boolean>;
|
|
173
|
+
setAdaptiveVideo(enabled: boolean): Promise<boolean>;
|
|
174
|
+
stats(): Promise<RoomStats>;
|
|
175
|
+
leave(): Promise<void>;
|
|
176
|
+
}
|
|
177
|
+
export interface StartedRoom extends Room {
|
|
178
|
+
readonly tag: string;
|
|
179
|
+
}
|
|
180
|
+
export function join(options: JoinOptions): Promise<StartedRoom>;
|
|
181
|
+
export const DEFAULT_TIMING: Readonly<RoomTiming>;
|
|
182
|
+
export const DEFAULT_LIMITS: Readonly<RoomLimits>;
|
|
183
|
+
export const PHASE: Readonly<{ENDPOINT: 0; SESSION: 1; BRIDGED: 2}>;
|
|
184
|
+
export function deriveRoom(secret: string | Uint8Array, app?: string): Promise<{tag: string; key: CryptoKey}>;
|
|
185
|
+
export function randomId(bytes?: number): string;
|
package/src/client/room.mjs
CHANGED
|
@@ -121,6 +121,8 @@ export class Room extends Emitter {
|
|
|
121
121
|
offerTimeouts: 0, mediaStalls: 0, appSent: 0, appReceived: 0, appDropped: 0 };
|
|
122
122
|
this.appBudget = new Map(); // sender -> { tokens, at }
|
|
123
123
|
this.videoQuality = new Map(); // peer -> per-link send/receive controller state
|
|
124
|
+
this.mutedPeers = new Set(); // peers muted locally (setPeerMuted)
|
|
125
|
+
this.remoteAudio = new Map(); // peer -> Set of live remote audio tracks, for flipping enabled
|
|
124
126
|
// Preferred capture devices (options.devices, switchDevice); every later capture uses them.
|
|
125
127
|
if (!deviceId(options.devices?.audio) || !deviceId(options.devices?.video)) throw new TypeError('Invalid capture device id');
|
|
126
128
|
this.devices = { audio: options.devices?.audio ?? null, video: options.devices?.video ?? null };
|
|
@@ -346,8 +348,10 @@ export class Room extends Emitter {
|
|
|
346
348
|
this.known.delete(peer); this.hintedAt.delete(peer); this.caps.delete(peer); this.presence.delete(peer); this.early.delete(peer); this.outbox.delete(peer);
|
|
347
349
|
this.sendProgress.delete(peer); this.meshBudget.delete(peer); this.appBudget.delete(peer); this.videoQuality.delete(peer);
|
|
348
350
|
this.lastContact.delete(peer); this.gatewayCreds.delete(`${this.tag8}:${peer}`);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
+
this.remoteAudio.delete(peer);
|
|
352
|
+
// A softly departed ('gone') peer may return with the same id: its replay window and local
|
|
353
|
+
// mute survive. Terminal departures ('bye', 'dropped', 'rekey') clear both.
|
|
354
|
+
if (reason !== 'gone') { this.sequences.delete(peer); this.mutedPeers.delete(peer); }
|
|
351
355
|
this.departed.set(peer, { at: Date.now(), reason });
|
|
352
356
|
if (this.departed.size > 256) this.departed.delete(this.departed.keys().next().value);
|
|
353
357
|
for (const [key, b] of [...this.bridgePending]) if (b.a === peer || b.b === peer) {
|
|
@@ -900,6 +904,18 @@ export class Room extends Emitter {
|
|
|
900
904
|
setMicrophone(enabled) { return this.#enableTrack('audio', enabled, MIC); }
|
|
901
905
|
setCamera(enabled) { return this.#enableTrack('video', enabled, CAM); }
|
|
902
906
|
|
|
907
|
+
/**
|
|
908
|
+
* Silences one member locally: enabled = false on their current and future remote audio
|
|
909
|
+
* tracks. Media still arrives and nothing is signalled, so the peer cannot tell. Muting
|
|
910
|
+
* an unknown peer is allowed and applies when their audio arrives. Video is untouched.
|
|
911
|
+
*/
|
|
912
|
+
setPeerMuted(peer, muted) {
|
|
913
|
+
if (typeof peer !== 'string' || !PEER_ID.test(peer)) throw new TypeError('Invalid peer id');
|
|
914
|
+
if (typeof muted !== 'boolean') throw new TypeError('muted must be a boolean');
|
|
915
|
+
if (muted) this.mutedPeers.add(peer); else this.mutedPeers.delete(peer);
|
|
916
|
+
for (const track of this.remoteAudio.get(peer) ?? []) track.enabled = !muted;
|
|
917
|
+
}
|
|
918
|
+
|
|
903
919
|
constraintsFor(kind, base) { const id = this.devices[kind]; return id ? { ...base, deviceId: { exact: id } } : base; }
|
|
904
920
|
|
|
905
921
|
/**
|
|
@@ -1193,6 +1209,13 @@ export class Room extends Emitter {
|
|
|
1193
1209
|
}
|
|
1194
1210
|
|
|
1195
1211
|
onRemoteTrack(link, origin, track, stream) {
|
|
1212
|
+
if (track.kind === 'audio') {
|
|
1213
|
+
let set = this.remoteAudio.get(origin);
|
|
1214
|
+
if (!set) this.remoteAudio.set(origin, set = new Set());
|
|
1215
|
+
set.add(track);
|
|
1216
|
+
track.addEventListener('ended', () => set.delete(track));
|
|
1217
|
+
if (this.mutedPeers.has(origin)) track.enabled = false;
|
|
1218
|
+
}
|
|
1196
1219
|
this.emit('track', { peer: origin, via: origin === link.id ? null : link.id, track, stream });
|
|
1197
1220
|
if (origin !== link.id) return;
|
|
1198
1221
|
// A forwarder keeps forwarding tracks the origin adds later (e.g. camera switched on).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import type {Gateway, GatewayOptions} from '../relay/agent.mjs';
|
|
3
|
+
import type {Log} from '../shared/types.mjs';
|
|
4
|
+
/** Structural subset, so browser users do not need Electron installed for SDK types. */
|
|
5
|
+
export interface GatewayIpcEvent {
|
|
6
|
+
senderFrame: {url: string} | null;
|
|
7
|
+
sender: {
|
|
8
|
+
mainFrame: {url: string};
|
|
9
|
+
on(event: string, listener: () => void): unknown;
|
|
10
|
+
once(event: string, listener: () => void): unknown;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export interface GatewayIpcMain {
|
|
14
|
+
handle(channel: string, listener: (event: GatewayIpcEvent, ...args: unknown[]) => unknown): void;
|
|
15
|
+
removeHandler(channel: string): void;
|
|
16
|
+
}
|
|
17
|
+
export interface ElectronGatewayOptions {
|
|
18
|
+
ipcMain: GatewayIpcMain;
|
|
19
|
+
allowedOrigins: string[];
|
|
20
|
+
options?: GatewayOptions;
|
|
21
|
+
log?: Log;
|
|
22
|
+
}
|
|
23
|
+
export interface ElectronGateway {
|
|
24
|
+
readonly gateway: Gateway | null;
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export function installFreehopGateway(options: ElectronGatewayOptions): ElectronGateway;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import type {Server, IncomingMessage} from 'node:http';
|
|
3
|
+
import type {Log, MaybePromise} from '../shared/types.mjs';
|
|
4
|
+
export {mintGateToken, verifyGateToken} from '../shared/tokens.mjs';
|
|
5
|
+
export const GATE_PROTOCOL: 1;
|
|
6
|
+
export interface GateLimits {
|
|
7
|
+
frame: number;
|
|
8
|
+
box: number;
|
|
9
|
+
roomsPerSocket: number;
|
|
10
|
+
peersPerRoom: number;
|
|
11
|
+
socketsPerAddress: number;
|
|
12
|
+
maxSockets: number;
|
|
13
|
+
maxPendingSockets: number;
|
|
14
|
+
pendingPerAddress: number;
|
|
15
|
+
helloMs: number;
|
|
16
|
+
closeMs: number;
|
|
17
|
+
idleMs: number;
|
|
18
|
+
pingMs: number;
|
|
19
|
+
burstBytes: number;
|
|
20
|
+
refillBytesPerSec: number;
|
|
21
|
+
messagesPerSec: number;
|
|
22
|
+
messageBurst: number;
|
|
23
|
+
maxBufferedBytes: number;
|
|
24
|
+
maxBufferedTotal: number;
|
|
25
|
+
maxPendingFrames: number;
|
|
26
|
+
maxPendingBytes: number;
|
|
27
|
+
}
|
|
28
|
+
export const GATE_LIMITS: Readonly<GateLimits>;
|
|
29
|
+
export interface GateHello {
|
|
30
|
+
t: 'hello';
|
|
31
|
+
v: 1;
|
|
32
|
+
auth?: unknown;
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
export interface GateOptions {
|
|
36
|
+
server?: Server;
|
|
37
|
+
host?: string;
|
|
38
|
+
port?: number;
|
|
39
|
+
path?: string;
|
|
40
|
+
publicHost?: string;
|
|
41
|
+
stun?: Array<{host?: string; port?: number; ratePerSec?: number; burst?: number; totalRatePerSec?: number; totalBurst?: number; software?: string}>;
|
|
42
|
+
stunUrls?: string[];
|
|
43
|
+
tokenSecret?: string;
|
|
44
|
+
tokenAudience?: string;
|
|
45
|
+
trustProxy?: boolean;
|
|
46
|
+
authorize?: (hello: GateHello, request: IncomingMessage) => MaybePromise<boolean>;
|
|
47
|
+
limits?: Partial<GateLimits>;
|
|
48
|
+
log?: Log;
|
|
49
|
+
}
|
|
50
|
+
export interface Gate {
|
|
51
|
+
readonly server: Server;
|
|
52
|
+
readonly path: string;
|
|
53
|
+
readonly stunUrls: string[];
|
|
54
|
+
url(host?: string): string;
|
|
55
|
+
stats(): {[counter: string]: number | Array<Record<string, number>>; stun: Array<Record<string, number>>};
|
|
56
|
+
rooms(): number;
|
|
57
|
+
close(): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
export function createGate(options?: GateOptions): Promise<Gate>;
|
|
60
|
+
export function randomTag(bytes?: number): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import type {GatewayInfo, GatewayCredentials, Log} from '../shared/types.mjs';
|
|
3
|
+
import type {TurnLimits, TurnServer} from './turn-server.mjs';
|
|
4
|
+
import type {PortMapper, PortMapperOptions, PortMapping} from './port-mapper.mjs';
|
|
5
|
+
export interface GatewayOptions {
|
|
6
|
+
host?: string;
|
|
7
|
+
port?: number;
|
|
8
|
+
relayPortRange?: [number, number];
|
|
9
|
+
portMapping?: boolean | PortMapperOptions;
|
|
10
|
+
mapper?: PortMapper | null;
|
|
11
|
+
externalAddress?: string;
|
|
12
|
+
rooms?: string[];
|
|
13
|
+
credentialTtlSeconds?: number;
|
|
14
|
+
relayScope?: 'internal' | 'public';
|
|
15
|
+
limits?: Partial<TurnLimits>;
|
|
16
|
+
log?: Log;
|
|
17
|
+
}
|
|
18
|
+
export interface GatewayStats {
|
|
19
|
+
external: string | null;
|
|
20
|
+
relayScope: 'internal' | 'public';
|
|
21
|
+
rooms: number;
|
|
22
|
+
revocations: number;
|
|
23
|
+
floors: number;
|
|
24
|
+
mappings: Array<Pick<PortMapping, 'method' | 'protocol' | 'internalPort' | 'externalPort'>>;
|
|
25
|
+
turn: Record<string, number>;
|
|
26
|
+
}
|
|
27
|
+
export interface Gateway {
|
|
28
|
+
readonly turn: TurnServer;
|
|
29
|
+
readonly mapper: PortMapper | null;
|
|
30
|
+
info(): GatewayInfo | null;
|
|
31
|
+
credentialsFor(roomTag: string, peer: string): GatewayCredentials | null;
|
|
32
|
+
allowRoom(roomTag: string): void;
|
|
33
|
+
revokeRoom(roomTag: string): number;
|
|
34
|
+
revokePeer(roomTag: string, peer: string): number;
|
|
35
|
+
rotate(): GatewayInfo | null;
|
|
36
|
+
stats(): GatewayStats;
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export function startGateway(options?: GatewayOptions): Promise<Gateway>;
|
|
40
|
+
export function isPublicAddress(address: string): boolean;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import type {Gateway} from './agent.mjs';
|
|
3
|
+
import type {GateAuth, Log} from '../shared/types.mjs';
|
|
4
|
+
export const GATEWAY_ID_PREFIX: 'gw_';
|
|
5
|
+
export interface GatewayMemberOptions {
|
|
6
|
+
gates: string[];
|
|
7
|
+
secret: string | Uint8Array;
|
|
8
|
+
app?: string;
|
|
9
|
+
gateway: Gateway;
|
|
10
|
+
auth?: GateAuth;
|
|
11
|
+
WebSocketImpl?: typeof WebSocket;
|
|
12
|
+
departGraceMs?: number;
|
|
13
|
+
log?: Log;
|
|
14
|
+
}
|
|
15
|
+
export interface GatewayMember {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
stats(): Record<string, number>;
|
|
18
|
+
drop(peer: string): void;
|
|
19
|
+
rekey(secret: string | Uint8Array, options?: {auth?: GateAuth; dropped?: string[]; gates?: string[]}): Promise<void>;
|
|
20
|
+
setAuth(auth?: GateAuth): void;
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export function joinAsGateway(options: GatewayMemberOptions): Promise<GatewayMember>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import type {EventEmitter} from 'node:events';
|
|
3
|
+
import type {Log} from '../shared/types.mjs';
|
|
4
|
+
export type MappingMethod = 'pcp' | 'natpmp' | 'upnp';
|
|
5
|
+
export interface PortMapperOptions {
|
|
6
|
+
gateway?: string;
|
|
7
|
+
localAddress?: string;
|
|
8
|
+
methods?: MappingMethod | MappingMethod[];
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
pcpPort?: number;
|
|
11
|
+
natpmpPort?: number;
|
|
12
|
+
ssdpPort?: number;
|
|
13
|
+
ssdpAddress?: string;
|
|
14
|
+
log?: Log;
|
|
15
|
+
}
|
|
16
|
+
export interface MappingRequest {
|
|
17
|
+
protocol: 'udp' | 'tcp';
|
|
18
|
+
internalPort: number;
|
|
19
|
+
suggestedExternalPort?: number;
|
|
20
|
+
lifetimeSeconds?: number;
|
|
21
|
+
description?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface PortMapping {
|
|
24
|
+
method: MappingMethod;
|
|
25
|
+
protocol: 'udp' | 'tcp';
|
|
26
|
+
internalAddress: string;
|
|
27
|
+
internalPort: number;
|
|
28
|
+
externalAddress: string | null;
|
|
29
|
+
externalPort: number;
|
|
30
|
+
lifetimeSeconds: number;
|
|
31
|
+
expiresAt: number | null;
|
|
32
|
+
externalAddressIsPrivate: boolean | null;
|
|
33
|
+
gateway: string;
|
|
34
|
+
description: string;
|
|
35
|
+
}
|
|
36
|
+
export interface MappingProbe {
|
|
37
|
+
gateway: string | null;
|
|
38
|
+
localAddress: string | null;
|
|
39
|
+
available: Record<MappingMethod, boolean>;
|
|
40
|
+
externalAddress: string | null;
|
|
41
|
+
externalAddressIsPrivate: boolean | null;
|
|
42
|
+
details: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
export interface PortMapper extends EventEmitter {
|
|
45
|
+
readonly gateway: string | null;
|
|
46
|
+
readonly localAddress: string | null;
|
|
47
|
+
readonly closed: boolean;
|
|
48
|
+
readonly mappings: PortMapping[];
|
|
49
|
+
probe(): Promise<MappingProbe>;
|
|
50
|
+
map(request: MappingRequest): Promise<PortMapping>;
|
|
51
|
+
unmap(mapping: PortMapping): Promise<boolean>;
|
|
52
|
+
close(): Promise<void>;
|
|
53
|
+
on(event: 'renewed', listener: (mapping: PortMapping) => void): this;
|
|
54
|
+
on(event: 'lost', listener: (mapping: PortMapping, error: PortMapperError) => void): this;
|
|
55
|
+
on(event: string | symbol, listener: (...args: unknown[]) => void): this;
|
|
56
|
+
}
|
|
57
|
+
export class PortMapperError extends Error {
|
|
58
|
+
constructor(message: string, code: string, details?: {cause?: unknown; [key: string]: unknown});
|
|
59
|
+
code: string;
|
|
60
|
+
method?: MappingMethod;
|
|
61
|
+
transient?: boolean;
|
|
62
|
+
unavailable?: boolean;
|
|
63
|
+
resultCode?: number;
|
|
64
|
+
upnpErrorCode?: number;
|
|
65
|
+
}
|
|
66
|
+
export function createPortMapper(options?: PortMapperOptions): Promise<PortMapper>;
|
|
67
|
+
export function isPrivateIPv4(ip: string): boolean;
|
|
68
|
+
export interface DefaultGateway {
|
|
69
|
+
gateway: string;
|
|
70
|
+
interface: string | null;
|
|
71
|
+
}
|
|
72
|
+
export function parseDefaultGateway(platform: string, text: string): DefaultGateway | null;
|
|
73
|
+
export function detectGateway(options?: {timeoutMs?: number; signal?: AbortSignal}): Promise<DefaultGateway | null>;
|
|
74
|
+
export function parseIgdDescription(xml: string, location: string): Array<{serviceType: string; version: number; rank: number; controlURL: string}>;
|
|
75
|
+
export function parseSoapFault(
|
|
76
|
+
xml: string,
|
|
77
|
+
): {errorCode: number | null; errorDescription: string | null; faultCode: string | null; faultString: string | null} | null;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import type {TlsOptions} from 'node:tls';
|
|
3
|
+
import type {Log, MaybePromise} from '../shared/types.mjs';
|
|
4
|
+
export interface TurnLimits {
|
|
5
|
+
maxAllocations: number;
|
|
6
|
+
maxAllocationsPerUsername: number;
|
|
7
|
+
allocationBitrate: number;
|
|
8
|
+
totalBitrate: number;
|
|
9
|
+
defaultLifetime: number;
|
|
10
|
+
maxLifetime: number;
|
|
11
|
+
permissionLifetime: number;
|
|
12
|
+
channelLifetime: number;
|
|
13
|
+
maxPermissions: number;
|
|
14
|
+
maxChannels: number;
|
|
15
|
+
nonceLifetime: number;
|
|
16
|
+
maxTcpConnections: number;
|
|
17
|
+
tcpIdleMs: number;
|
|
18
|
+
hookTimeoutMs: number;
|
|
19
|
+
maxTcpPerAddress: number;
|
|
20
|
+
tcpPreAuthMs: number;
|
|
21
|
+
errorRate: number;
|
|
22
|
+
errorBurst: number;
|
|
23
|
+
udpResponseRate: number;
|
|
24
|
+
udpResponseBurst: number;
|
|
25
|
+
maxAllocationsPerScope?: number;
|
|
26
|
+
scopeBitrate?: number;
|
|
27
|
+
}
|
|
28
|
+
export const DEFAULT_LIMITS: Readonly<Omit<TurnLimits, 'maxAllocationsPerScope' | 'scopeBitrate'>>;
|
|
29
|
+
export type Transport = 'udp' | 'tcp' | 'tls';
|
|
30
|
+
export type ListenOptions = {host?: string; port?: number} & (
|
|
31
|
+
{transport?: 'udp' | 'tcp'} | {transport: 'tls'; key: NonNullable<TlsOptions['key']>; cert: NonNullable<TlsOptions['cert']>; passphrase?: string}
|
|
32
|
+
);
|
|
33
|
+
export interface PeerAddress {
|
|
34
|
+
address: string;
|
|
35
|
+
port: number;
|
|
36
|
+
family: 4 | 6;
|
|
37
|
+
}
|
|
38
|
+
export interface TurnOptions {
|
|
39
|
+
authenticate(username: string, realm: string): MaybePromise<string | null>;
|
|
40
|
+
realm?: string;
|
|
41
|
+
software?: string | false | null;
|
|
42
|
+
listen?: ListenOptions[];
|
|
43
|
+
relayHost?: string | string[];
|
|
44
|
+
externalAddress?: string | string[];
|
|
45
|
+
relayPortRange?: [number, number];
|
|
46
|
+
peerScope?: 'public' | 'internal';
|
|
47
|
+
allowPrivatePeers?: boolean;
|
|
48
|
+
allowPeer?: (peer: PeerAddress, context: {username: string}) => boolean;
|
|
49
|
+
allocationScope?: (username: string) => string;
|
|
50
|
+
mapRelayPort?: (localPort: number, family: 4 | 6, signal: AbortSignal) => MaybePromise<number | null>;
|
|
51
|
+
limits?: Partial<TurnLimits>;
|
|
52
|
+
log?: Log;
|
|
53
|
+
}
|
|
54
|
+
export interface TurnServer {
|
|
55
|
+
addresses(): Array<{transport: Transport; address: string; port: number}>;
|
|
56
|
+
relayedAddresses(): Array<{internal: {address: string; port: number}; external: {address: string; port: number}; username: string}>;
|
|
57
|
+
stats(): Record<string, number>;
|
|
58
|
+
revoke(predicate: (username: string) => boolean): number;
|
|
59
|
+
close(): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
export function createTurnServer(options: TurnOptions): Promise<TurnServer>;
|
|
62
|
+
export function classifyPeerAddress(address: string | Uint8Array): 'deny' | 'private' | 'public';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import type {Ticket} from './ticket.mjs';
|
|
3
|
+
export interface AuthorityOptions {
|
|
4
|
+
app: string;
|
|
5
|
+
gates: string[];
|
|
6
|
+
gateTokenSecret?: string;
|
|
7
|
+
gateTokenSecrets?: Record<string, string>;
|
|
8
|
+
stun?: string[];
|
|
9
|
+
ticketTtlSeconds?: number;
|
|
10
|
+
maxRooms?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface RoomDescription {
|
|
13
|
+
roomId: string;
|
|
14
|
+
tag: string;
|
|
15
|
+
epoch: number;
|
|
16
|
+
members: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface TicketRotation {
|
|
19
|
+
epoch: number;
|
|
20
|
+
previousTags: string[];
|
|
21
|
+
tickets: Map<string, Ticket>;
|
|
22
|
+
hostTicket: Ticket;
|
|
23
|
+
}
|
|
24
|
+
export interface Authority {
|
|
25
|
+
openRoom(roomId: string): Promise<Omit<RoomDescription, 'members'> & {members: number}>;
|
|
26
|
+
ticket(roomId: string, member?: string): Promise<Ticket>;
|
|
27
|
+
kick(roomId: string, member: string): Promise<TicketRotation>;
|
|
28
|
+
leave(roomId: string, member: string): Promise<TicketRotation>;
|
|
29
|
+
closeRoom(roomId: string): Promise<boolean>;
|
|
30
|
+
describe(roomId: string): RoomDescription | null;
|
|
31
|
+
}
|
|
32
|
+
export function createAuthority(options: AuthorityOptions): Authority;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import type {StartedRoom, MediaControls} from '../client/peerlane.mjs';
|
|
3
|
+
import type {DesktopGateway, RotationOptions} from '../shared/types.mjs';
|
|
4
|
+
import type {Ticket} from './ticket.mjs';
|
|
5
|
+
export {encodeTicket, decodeTicket, validTicket} from './ticket.mjs';
|
|
6
|
+
export type {Ticket} from './ticket.mjs';
|
|
7
|
+
export type {RoomEvents, RoomStats, LinkStats, ConnectionPath, PathKind, VideoQualityLevel, RoomTiming, RoomLimits, MediaOptions} from '../client/peerlane.mjs';
|
|
8
|
+
export type {DesktopGateway, GatewayInfo, GatewayCredentials, JsonValue, Log} from '../shared/types.mjs';
|
|
9
|
+
export interface ConnectOptions extends MediaControls {
|
|
10
|
+
/** null disables automatic discovery of window.freehopGateway. */
|
|
11
|
+
desktopGateway?: DesktopGateway | null;
|
|
12
|
+
}
|
|
13
|
+
export interface Session extends StartedRoom {
|
|
14
|
+
ticket(): Ticket;
|
|
15
|
+
update(ticket: Ticket | string, options?: RotationOptions): Promise<boolean>;
|
|
16
|
+
refresh(ticket: Ticket | string): Promise<boolean>;
|
|
17
|
+
disconnectPeer(peer: string): Promise<void>;
|
|
18
|
+
/** Always rejects. Use authority.kick() and distribute replacement tickets. */
|
|
19
|
+
kick(): Promise<never>;
|
|
20
|
+
attach<T extends HTMLMediaElement>(track: MediaStreamTrack, element: T): T;
|
|
21
|
+
levels(): Promise<Record<string, number>>;
|
|
22
|
+
}
|
|
23
|
+
export function connect(ticket: Ticket | string, options?: ConnectOptions): Promise<Session>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import type {Ticket} from './ticket.mjs';
|
|
3
|
+
import type {Gateway, GatewayOptions, GatewayStats} from '../relay/agent.mjs';
|
|
4
|
+
import type {Log, RotationOptions} from '../shared/types.mjs';
|
|
5
|
+
export interface HostOptions {
|
|
6
|
+
gateway?: 'auto' | Gateway | null | false;
|
|
7
|
+
gatewayOptions?: GatewayOptions;
|
|
8
|
+
log?: Log;
|
|
9
|
+
}
|
|
10
|
+
export interface AvailableHost {
|
|
11
|
+
available: true;
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly gateway: Gateway;
|
|
14
|
+
update(ticket: Ticket | string, options?: RotationOptions): Promise<boolean>;
|
|
15
|
+
refresh(ticket: Ticket | string): Promise<boolean>;
|
|
16
|
+
drop(peer: string): void;
|
|
17
|
+
stats(): {available: true; epoch: number; member: Record<string, number>; gateway: GatewayStats};
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface UnavailableHost {
|
|
21
|
+
available: false;
|
|
22
|
+
update(ticket: Ticket | string, options?: RotationOptions): Promise<void>;
|
|
23
|
+
refresh(ticket: Ticket | string): Promise<false>;
|
|
24
|
+
stats(): {available: false};
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export type HostedSession = AvailableHost | UnavailableHost;
|
|
28
|
+
export function sharedGateway(options?: GatewayOptions): Promise<Gateway>;
|
|
29
|
+
export function closeSharedGateway(): Promise<void>;
|
|
30
|
+
export function hostSession(ticket: Ticket | string, options?: HostOptions): Promise<HostedSession>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import type {GateAuth} from '../shared/types.mjs';
|
|
3
|
+
export interface Ticket {
|
|
4
|
+
v: 1;
|
|
5
|
+
app: string;
|
|
6
|
+
roomId: string;
|
|
7
|
+
epoch: number;
|
|
8
|
+
gates: string[];
|
|
9
|
+
secret: string;
|
|
10
|
+
stun?: string[];
|
|
11
|
+
auth?: GateAuth;
|
|
12
|
+
/** Unix expiry in seconds. */
|
|
13
|
+
expires: number;
|
|
14
|
+
}
|
|
15
|
+
export function validTicket(value: unknown): value is Ticket;
|
|
16
|
+
export function encodeTicket(ticket: Ticket): string;
|
|
17
|
+
/** Throws TypeError for malformed, invalid or expired tickets. */
|
|
18
|
+
export function decodeTicket(text: string): Ticket;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import type {Buffer} from 'node:buffer';
|
|
3
|
+
export const MAGIC_COOKIE: 0x2112a442;
|
|
4
|
+
export const METHOD: {BINDING: number; ALLOCATE: number; REFRESH: number; SEND: number; DATA: number; CREATE_PERMISSION: number; CHANNEL_BIND: number};
|
|
5
|
+
export const CLASS: {REQUEST: 0; INDICATION: 1; SUCCESS: 2; ERROR: 3};
|
|
6
|
+
export const ATTR: {
|
|
7
|
+
MAPPED_ADDRESS: number;
|
|
8
|
+
USERNAME: number;
|
|
9
|
+
MESSAGE_INTEGRITY: number;
|
|
10
|
+
ERROR_CODE: number;
|
|
11
|
+
UNKNOWN_ATTRIBUTES: number;
|
|
12
|
+
CHANNEL_NUMBER: number;
|
|
13
|
+
LIFETIME: number;
|
|
14
|
+
XOR_PEER_ADDRESS: number;
|
|
15
|
+
DATA: number;
|
|
16
|
+
REALM: number;
|
|
17
|
+
NONCE: number;
|
|
18
|
+
XOR_RELAYED_ADDRESS: number;
|
|
19
|
+
REQUESTED_ADDRESS_FAMILY: number;
|
|
20
|
+
EVEN_PORT: number;
|
|
21
|
+
REQUESTED_TRANSPORT: number;
|
|
22
|
+
DONT_FRAGMENT: number;
|
|
23
|
+
MESSAGE_INTEGRITY_SHA256: number;
|
|
24
|
+
PASSWORD_ALGORITHM: number;
|
|
25
|
+
USERHASH: number;
|
|
26
|
+
XOR_MAPPED_ADDRESS: number;
|
|
27
|
+
RESERVATION_TOKEN: number;
|
|
28
|
+
PRIORITY: number;
|
|
29
|
+
USE_CANDIDATE: number;
|
|
30
|
+
ADDITIONAL_ADDRESS_FAMILY: number;
|
|
31
|
+
PASSWORD_ALGORITHMS: number;
|
|
32
|
+
ALTERNATE_DOMAIN: number;
|
|
33
|
+
SOFTWARE: number;
|
|
34
|
+
ALTERNATE_SERVER: number;
|
|
35
|
+
FINGERPRINT: number;
|
|
36
|
+
ICE_CONTROLLED: number;
|
|
37
|
+
ICE_CONTROLLING: number;
|
|
38
|
+
};
|
|
39
|
+
export interface Attribute {
|
|
40
|
+
type: number;
|
|
41
|
+
value: Buffer;
|
|
42
|
+
offset: number;
|
|
43
|
+
}
|
|
44
|
+
export interface Message {
|
|
45
|
+
type: number;
|
|
46
|
+
method: number;
|
|
47
|
+
cls: number;
|
|
48
|
+
transactionId: Buffer;
|
|
49
|
+
attributes: Attribute[];
|
|
50
|
+
length: number;
|
|
51
|
+
raw: Buffer;
|
|
52
|
+
}
|
|
53
|
+
export interface Address {
|
|
54
|
+
address: string;
|
|
55
|
+
port: number;
|
|
56
|
+
family?: 4 | 6 | 'IPv4' | 'IPv6';
|
|
57
|
+
}
|
|
58
|
+
export interface DecodedAddress {
|
|
59
|
+
address: string;
|
|
60
|
+
port: number;
|
|
61
|
+
family: 4 | 6;
|
|
62
|
+
}
|
|
63
|
+
export type Bytes = Uint8Array;
|
|
64
|
+
export type Value = Bytes | string;
|
|
65
|
+
export function isStunMessage(buffer: Bytes | null | undefined): boolean;
|
|
66
|
+
export function isChannelData(buffer: Bytes | null | undefined): boolean;
|
|
67
|
+
export function decode(buffer: Bytes | null | undefined): Message | null;
|
|
68
|
+
export function getAttr(message: Message | null | undefined, type: number): Buffer | undefined;
|
|
69
|
+
export function encode(
|
|
70
|
+
message: {method: number; cls: number; transactionId?: Value; attributes?: Array<{type: number; value?: Value}>},
|
|
71
|
+
options?: {integrityKey?: Value; fingerprint?: boolean},
|
|
72
|
+
): Buffer;
|
|
73
|
+
export function ipToBytes(address: string): Buffer | null;
|
|
74
|
+
export function bytesToIp(bytes: Bytes): string | null;
|
|
75
|
+
export function encodeAddress(address: Address): Buffer;
|
|
76
|
+
export function encodeXorAddress(address: Address, transactionId: Value): Buffer;
|
|
77
|
+
export function decodeAddress(value: Bytes | null | undefined): DecodedAddress | null;
|
|
78
|
+
export function decodeXorAddress(value: Bytes | null | undefined, transactionId: Bytes): DecodedAddress | null;
|
|
79
|
+
export function errorCodeValue(code: number, reason?: string): Buffer;
|
|
80
|
+
export function decodeErrorCode(value: Buffer | null | undefined): {code: number; reason: string} | null;
|
|
81
|
+
export function saslprep(value: string): string;
|
|
82
|
+
export function longTermKey(username: Value, realm: Value, password: Value): Buffer;
|
|
83
|
+
export function verifyIntegrity(message: Message | null | undefined, key: Value): boolean;
|
|
84
|
+
export function verifyFingerprint(message: Message | null | undefined): {present: boolean; valid: boolean};
|
|
85
|
+
export function encodeChannelData(channel: number, data: Value, options?: {pad?: boolean}): Buffer;
|
|
86
|
+
export function decodeChannelData(buffer: Bytes | null | undefined): {channel: number; data: Buffer} | null;
|
|
87
|
+
export function frameStreamMessages(buffer?: Bytes | null): {messages: Buffer[]; rest: Buffer; need: number; error?: string};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import type {BinaryLike, KeyObject} from 'node:crypto';
|
|
3
|
+
export interface GateTokenClaims {
|
|
4
|
+
exp: number;
|
|
5
|
+
room?: string;
|
|
6
|
+
aud?: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
export function mintGateToken(secret: BinaryLike | KeyObject, claims: GateTokenClaims): string;
|
|
10
|
+
export function verifyGateToken(
|
|
11
|
+
secret: BinaryLike | KeyObject,
|
|
12
|
+
token: unknown,
|
|
13
|
+
options?: number | {audience?: string; now?: number} | null,
|
|
14
|
+
): GateTokenClaims | null;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/** Values carried by session.send(). Validate incoming messages before use. */
|
|
3
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | {[key: string]: JsonValue};
|
|
4
|
+
/** Structural JSON check that also accepts application interfaces. */
|
|
5
|
+
export type JsonCompatible<T> = T extends null | boolean | number | string
|
|
6
|
+
? T
|
|
7
|
+
: T extends (...args: never[]) => unknown
|
|
8
|
+
? never
|
|
9
|
+
: T extends object
|
|
10
|
+
? {[K in keyof T]: JsonCompatible<T[K]>}
|
|
11
|
+
: never;
|
|
12
|
+
export type Log = (event: string, details: Record<string, unknown>) => void;
|
|
13
|
+
export type MaybePromise<T> = T | Promise<T>;
|
|
14
|
+
export type GateAuth = string | Record<string, string>;
|
|
15
|
+
export interface GatewayCredentials {
|
|
16
|
+
username: string;
|
|
17
|
+
credential: string;
|
|
18
|
+
}
|
|
19
|
+
export interface GatewayInfo {
|
|
20
|
+
urls: string[];
|
|
21
|
+
internalUrls?: string[];
|
|
22
|
+
ttlSeconds?: number;
|
|
23
|
+
external?: string[];
|
|
24
|
+
internal?: string | null;
|
|
25
|
+
}
|
|
26
|
+
export interface GatewayBroker {
|
|
27
|
+
info(): GatewayInfo | null;
|
|
28
|
+
credentialsFor(tag: string, peer: string): MaybePromise<GatewayCredentials | null>;
|
|
29
|
+
}
|
|
30
|
+
export type ClientGateway = GatewayBroker | (GatewayInfo & Pick<GatewayBroker, 'credentialsFor'>);
|
|
31
|
+
/** The supported, origin-restricted Electron preload bridge. */
|
|
32
|
+
export interface DesktopGateway {
|
|
33
|
+
info(): Promise<GatewayInfo | null>;
|
|
34
|
+
credentialsFor(tag: string, peer: string): Promise<GatewayCredentials | null>;
|
|
35
|
+
allowRoom(tag: string): Promise<void>;
|
|
36
|
+
revokePeer(tag: string, peer: string): Promise<void>;
|
|
37
|
+
revokeRoom(tag: string): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export interface RotationOptions {
|
|
40
|
+
dropped?: string[];
|
|
41
|
+
}
|