@tribe-nest/media-client 0.1.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/README.md +68 -0
- package/build/core/index.d.ts +17 -0
- package/build/core/index.d.ts.map +1 -0
- package/build/core/index.js +41 -0
- package/build/core/index.js.map +1 -0
- package/build/core/reconnect.d.ts +95 -0
- package/build/core/reconnect.d.ts.map +1 -0
- package/build/core/reconnect.js +160 -0
- package/build/core/reconnect.js.map +1 -0
- package/build/core/signal.d.ts +184 -0
- package/build/core/signal.d.ts.map +1 -0
- package/build/core/signal.js +416 -0
- package/build/core/signal.js.map +1 -0
- package/build/core/socket.d.ts +57 -0
- package/build/core/socket.d.ts.map +1 -0
- package/build/core/socket.js +37 -0
- package/build/core/socket.js.map +1 -0
- package/build/core/state.d.ts +67 -0
- package/build/core/state.d.ts.map +1 -0
- package/build/core/state.js +193 -0
- package/build/core/state.js.map +1 -0
- package/build/index.d.ts +29 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +51 -0
- package/build/index.js.map +1 -0
- package/build/protocol.d.ts +10 -0
- package/build/protocol.d.ts.map +1 -0
- package/build/protocol.js +26 -0
- package/build/protocol.js.map +1 -0
- package/build/react/index.d.ts +147 -0
- package/build/react/index.d.ts.map +1 -0
- package/build/react/index.js +319 -0
- package/build/react/index.js.map +1 -0
- package/build/room/browserDevice.d.ts +3 -0
- package/build/room/browserDevice.d.ts.map +1 -0
- package/build/room/browserDevice.js +94 -0
- package/build/room/browserDevice.js.map +1 -0
- package/build/room/device.d.ts +114 -0
- package/build/room/device.d.ts.map +1 -0
- package/build/room/device.js +3 -0
- package/build/room/device.js.map +1 -0
- package/build/room/room.d.ts +219 -0
- package/build/room/room.d.ts.map +1 -0
- package/build/room/room.js +438 -0
- package/build/room/room.js.map +1 -0
- package/package.json +69 -0
- package/src/_tests/clientBoundary.spec.ts +110 -0
- package/src/core/_tests/coreBoundary.spec.ts +70 -0
- package/src/core/_tests/fakeSignalServer.ts +188 -0
- package/src/core/_tests/reconnect.spec.ts +180 -0
- package/src/core/_tests/signal.spec.ts +347 -0
- package/src/core/_tests/state.spec.ts +226 -0
- package/src/core/index.ts +63 -0
- package/src/core/reconnect.ts +233 -0
- package/src/core/signal.ts +527 -0
- package/src/core/socket.ts +58 -0
- package/src/core/state.ts +251 -0
- package/src/index.ts +54 -0
- package/src/protocol.ts +9 -0
- package/src/react/_tests/hooks.spec.tsx +509 -0
- package/src/react/index.tsx +439 -0
- package/src/room/_tests/room.spec.ts +595 -0
- package/src/room/browserDevice.ts +114 -0
- package/src/room/device.ts +119 -0
- package/src/room/room.ts +600 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { type MediaErrorCode } from "@tribe-nest/media-protocol";
|
|
2
|
+
|
|
3
|
+
import { causeFromError, type DisconnectCause } from "./signal";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* When to try again, and when to stop.
|
|
7
|
+
*
|
|
8
|
+
* Two decisions, both of which cost an outage if guessed:
|
|
9
|
+
*
|
|
10
|
+
* **Jitter is not a refinement.** Every client on a node that just died
|
|
11
|
+
* reconnects at once. Without jitter they arrive in lockstep, land together on
|
|
12
|
+
* whichever node the balancer picks, and take that one down too. The retry
|
|
13
|
+
* storm is the outage; the original failure was one node.
|
|
14
|
+
*
|
|
15
|
+
* **`draining` is not a failure.** The node is asking to be left. Retrying the
|
|
16
|
+
* same node is the one response that cannot work, so a drain always sends the
|
|
17
|
+
* client back through `MEDIA_URL` for a fresh placement and a fresh token, and
|
|
18
|
+
* waits at least as long as the node asked.
|
|
19
|
+
*
|
|
20
|
+
* Everything here is pure. `Math.random` is injectable, so a spec pins exact
|
|
21
|
+
* delays rather than asserting a range and hoping.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type JitterMode = "full" | "equal" | "none";
|
|
25
|
+
|
|
26
|
+
export type ReconnectOptions = {
|
|
27
|
+
baseMs: number;
|
|
28
|
+
maxMs: number;
|
|
29
|
+
factor: number;
|
|
30
|
+
jitter: JitterMode;
|
|
31
|
+
/** Give up after this many consecutive failed attempts. */
|
|
32
|
+
maxAttempts: number;
|
|
33
|
+
/**
|
|
34
|
+
* A reconnect that races the node's reaping of our old session comes back
|
|
35
|
+
* `duplicate_identity`. Waiting is the fix, so this is a floor rather than a
|
|
36
|
+
* refusal.
|
|
37
|
+
*/
|
|
38
|
+
duplicateIdentityFloorMs: number;
|
|
39
|
+
random: () => number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const DEFAULT_RECONNECT_OPTIONS: ReconnectOptions = {
|
|
43
|
+
baseMs: 500,
|
|
44
|
+
maxMs: 30_000,
|
|
45
|
+
factor: 2,
|
|
46
|
+
jitter: "full",
|
|
47
|
+
maxAttempts: 10,
|
|
48
|
+
duplicateIdentityFloorMs: 2_000,
|
|
49
|
+
random: Math.random,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Delay before attempt number `attempt` (0 = the first retry).
|
|
54
|
+
*
|
|
55
|
+
* Full jitter is the default because it is the variant that actually
|
|
56
|
+
* decorrelates a fleet: exponential-with-a-small-fudge still has every client
|
|
57
|
+
* retrying inside the same narrow window.
|
|
58
|
+
*/
|
|
59
|
+
export function backoffDelay(attempt: number, options?: Partial<ReconnectOptions>): number {
|
|
60
|
+
const o = { ...DEFAULT_RECONNECT_OPTIONS, ...options };
|
|
61
|
+
const ceiling = Math.min(o.maxMs, o.baseMs * Math.pow(o.factor, Math.max(0, attempt)));
|
|
62
|
+
switch (o.jitter) {
|
|
63
|
+
case "none":
|
|
64
|
+
return Math.round(ceiling);
|
|
65
|
+
case "equal":
|
|
66
|
+
return Math.round(ceiling / 2 + o.random() * (ceiling / 2));
|
|
67
|
+
case "full":
|
|
68
|
+
return Math.round(o.random() * ceiling);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type StopReason =
|
|
73
|
+
| "client_closed"
|
|
74
|
+
| "room_closed"
|
|
75
|
+
| "refused"
|
|
76
|
+
| "attempts_exhausted";
|
|
77
|
+
|
|
78
|
+
export type ReconnectDecision =
|
|
79
|
+
| { action: "stop"; reason: StopReason; code?: MediaErrorCode }
|
|
80
|
+
| {
|
|
81
|
+
action: "reconnect";
|
|
82
|
+
delayMs: number;
|
|
83
|
+
/** The attempt this decision authorises, 0-based. */
|
|
84
|
+
attempt: number;
|
|
85
|
+
/**
|
|
86
|
+
* Always true, and stated in the type rather than in prose: the client
|
|
87
|
+
* re-resolves through `MEDIA_URL` and takes a FRESH token every time. It
|
|
88
|
+
* never holds a node address (the wire deliberately carries no `nodeId`),
|
|
89
|
+
* and a join ticket lives minutes.
|
|
90
|
+
*/
|
|
91
|
+
viaMediaUrl: true;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Codes that a later attempt could plausibly survive.
|
|
96
|
+
*
|
|
97
|
+
* Split by hand, and pinned by a spec against `MEDIA_ERROR_CODES`, so that a
|
|
98
|
+
* code added to the contract fails a test here instead of silently falling into
|
|
99
|
+
* whichever branch the default happened to be.
|
|
100
|
+
*/
|
|
101
|
+
const RETRYABLE_CODES: ReadonlySet<MediaErrorCode> = new Set<MediaErrorCode>([
|
|
102
|
+
"node_draining",
|
|
103
|
+
"capacity",
|
|
104
|
+
"internal",
|
|
105
|
+
"duplicate_identity",
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Codes that mean "the credential was wrong", which a fresh mint may fix once.
|
|
110
|
+
*
|
|
111
|
+
* Once, not forever: a laptop resumed from sleep presents an expired ticket and
|
|
112
|
+
* deserves a retry, but a revoked grant answers the same way every time and
|
|
113
|
+
* retrying it is a loop against the minting endpoint.
|
|
114
|
+
*/
|
|
115
|
+
const CREDENTIAL_CODES: ReadonlySet<MediaErrorCode> = new Set<MediaErrorCode>(["unauthorized", "replayed"]);
|
|
116
|
+
|
|
117
|
+
const TERMINAL_CODES: ReadonlySet<MediaErrorCode> = new Set<MediaErrorCode>([
|
|
118
|
+
"protocol_version",
|
|
119
|
+
"forbidden_transport",
|
|
120
|
+
"forbidden",
|
|
121
|
+
"not_subscribable",
|
|
122
|
+
"subscription_limit",
|
|
123
|
+
"room_closed",
|
|
124
|
+
"no_such_room",
|
|
125
|
+
"no_such_producer",
|
|
126
|
+
"no_such_transport",
|
|
127
|
+
"bad_request",
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
/** Exposed so the exhaustiveness spec can assert every contract code is classified. */
|
|
131
|
+
export const RECONNECT_CODE_CLASSES = {
|
|
132
|
+
retryable: RETRYABLE_CODES,
|
|
133
|
+
credential: CREDENTIAL_CODES,
|
|
134
|
+
terminal: TERMINAL_CODES,
|
|
135
|
+
} as const;
|
|
136
|
+
|
|
137
|
+
export function classifyCode(code: MediaErrorCode): "retryable" | "credential" | "terminal" {
|
|
138
|
+
if (RETRYABLE_CODES.has(code)) return "retryable";
|
|
139
|
+
if (CREDENTIAL_CODES.has(code)) return "credential";
|
|
140
|
+
if (TERMINAL_CODES.has(code)) return "terminal";
|
|
141
|
+
// Unclassified is treated as terminal: a client that retries something nobody
|
|
142
|
+
// reasoned about hammers the node for as long as the user leaves the tab open.
|
|
143
|
+
return "terminal";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function decideReconnect(input: {
|
|
147
|
+
cause: DisconnectCause;
|
|
148
|
+
/** Consecutive failed attempts since the last successful join. 0 on the first drop. */
|
|
149
|
+
attempt: number;
|
|
150
|
+
options?: Partial<ReconnectOptions>;
|
|
151
|
+
}): ReconnectDecision {
|
|
152
|
+
const o = { ...DEFAULT_RECONNECT_OPTIONS, ...input.options };
|
|
153
|
+
const { cause, attempt } = input;
|
|
154
|
+
|
|
155
|
+
if (cause.type === "closed_by_client") return { action: "stop", reason: "client_closed" };
|
|
156
|
+
if (cause.type === "room_closed") return { action: "stop", reason: "room_closed" };
|
|
157
|
+
|
|
158
|
+
if (cause.type === "refused") {
|
|
159
|
+
const kind = classifyCode(cause.code);
|
|
160
|
+
if (kind === "terminal") return { action: "stop", reason: "refused", code: cause.code };
|
|
161
|
+
if (kind === "credential" && attempt >= 1) {
|
|
162
|
+
// The retry already carried a freshly minted token and was refused again.
|
|
163
|
+
return { action: "stop", reason: "refused", code: cause.code };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (attempt >= o.maxAttempts) return { action: "stop", reason: "attempts_exhausted" };
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
action: "reconnect",
|
|
171
|
+
attempt,
|
|
172
|
+
delayMs: delayFor(cause, attempt, o),
|
|
173
|
+
viaMediaUrl: true,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function delayFor(cause: DisconnectCause, attempt: number, o: ReconnectOptions): number {
|
|
178
|
+
const backoff = backoffDelay(attempt, o);
|
|
179
|
+
if (cause.type === "draining") {
|
|
180
|
+
// The node named a window; honour it and add the jittered backoff on top so
|
|
181
|
+
// a drained node's whole population does not arrive at the replacement in
|
|
182
|
+
// one burst the instant the window expires.
|
|
183
|
+
return cause.reconnectAfterMs + backoff;
|
|
184
|
+
}
|
|
185
|
+
if (cause.type === "refused" && cause.code === "node_draining") {
|
|
186
|
+
return Math.max(backoff, o.baseMs);
|
|
187
|
+
}
|
|
188
|
+
if (cause.type === "refused" && cause.code === "duplicate_identity") {
|
|
189
|
+
return Math.max(backoff, o.duplicateIdentityFloorMs);
|
|
190
|
+
}
|
|
191
|
+
return backoff;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Drives connect-then-wait-then-decide until something says stop.
|
|
196
|
+
*
|
|
197
|
+
* Deliberately knows nothing about sockets: `attempt` returns whatever a
|
|
198
|
+
* session is and `waitForClose` resolves with why it ended. That is what makes
|
|
199
|
+
* the whole loop testable with no server, and it is the same loop the room API
|
|
200
|
+
* (P3b) should use rather than deriving a second one.
|
|
201
|
+
*/
|
|
202
|
+
export async function superviseConnection<TSession>(input: {
|
|
203
|
+
attempt: () => Promise<TSession>;
|
|
204
|
+
waitForClose: (session: TSession) => Promise<DisconnectCause>;
|
|
205
|
+
onConnected?: (session: TSession) => void;
|
|
206
|
+
onDecision?: (decision: ReconnectDecision, cause: DisconnectCause) => void;
|
|
207
|
+
sleep?: (ms: number) => Promise<void>;
|
|
208
|
+
options?: Partial<ReconnectOptions>;
|
|
209
|
+
}): Promise<Extract<ReconnectDecision, { action: "stop" }>> {
|
|
210
|
+
const sleep = input.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
211
|
+
let attempt = 0;
|
|
212
|
+
|
|
213
|
+
for (;;) {
|
|
214
|
+
let cause: DisconnectCause;
|
|
215
|
+
try {
|
|
216
|
+
const session = await input.attempt();
|
|
217
|
+
// A successful join resets the ladder: an hour-long call that drops once
|
|
218
|
+
// should not start at a 30-second delay because of a blip at minute two.
|
|
219
|
+
attempt = 0;
|
|
220
|
+
input.onConnected?.(session);
|
|
221
|
+
cause = await input.waitForClose(session);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
cause = causeFromError(error);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const decision = decideReconnect({ cause, attempt, options: input.options });
|
|
227
|
+
input.onDecision?.(decision, cause);
|
|
228
|
+
if (decision.action === "stop") return decision;
|
|
229
|
+
|
|
230
|
+
await sleep(decision.delayMs);
|
|
231
|
+
attempt += 1;
|
|
232
|
+
}
|
|
233
|
+
}
|