@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.
Files changed (65) hide show
  1. package/README.md +68 -0
  2. package/build/core/index.d.ts +17 -0
  3. package/build/core/index.d.ts.map +1 -0
  4. package/build/core/index.js +41 -0
  5. package/build/core/index.js.map +1 -0
  6. package/build/core/reconnect.d.ts +95 -0
  7. package/build/core/reconnect.d.ts.map +1 -0
  8. package/build/core/reconnect.js +160 -0
  9. package/build/core/reconnect.js.map +1 -0
  10. package/build/core/signal.d.ts +184 -0
  11. package/build/core/signal.d.ts.map +1 -0
  12. package/build/core/signal.js +416 -0
  13. package/build/core/signal.js.map +1 -0
  14. package/build/core/socket.d.ts +57 -0
  15. package/build/core/socket.d.ts.map +1 -0
  16. package/build/core/socket.js +37 -0
  17. package/build/core/socket.js.map +1 -0
  18. package/build/core/state.d.ts +67 -0
  19. package/build/core/state.d.ts.map +1 -0
  20. package/build/core/state.js +193 -0
  21. package/build/core/state.js.map +1 -0
  22. package/build/index.d.ts +29 -0
  23. package/build/index.d.ts.map +1 -0
  24. package/build/index.js +51 -0
  25. package/build/index.js.map +1 -0
  26. package/build/protocol.d.ts +10 -0
  27. package/build/protocol.d.ts.map +1 -0
  28. package/build/protocol.js +26 -0
  29. package/build/protocol.js.map +1 -0
  30. package/build/react/index.d.ts +147 -0
  31. package/build/react/index.d.ts.map +1 -0
  32. package/build/react/index.js +319 -0
  33. package/build/react/index.js.map +1 -0
  34. package/build/room/browserDevice.d.ts +3 -0
  35. package/build/room/browserDevice.d.ts.map +1 -0
  36. package/build/room/browserDevice.js +94 -0
  37. package/build/room/browserDevice.js.map +1 -0
  38. package/build/room/device.d.ts +114 -0
  39. package/build/room/device.d.ts.map +1 -0
  40. package/build/room/device.js +3 -0
  41. package/build/room/device.js.map +1 -0
  42. package/build/room/room.d.ts +219 -0
  43. package/build/room/room.d.ts.map +1 -0
  44. package/build/room/room.js +438 -0
  45. package/build/room/room.js.map +1 -0
  46. package/package.json +69 -0
  47. package/src/_tests/clientBoundary.spec.ts +110 -0
  48. package/src/core/_tests/coreBoundary.spec.ts +70 -0
  49. package/src/core/_tests/fakeSignalServer.ts +188 -0
  50. package/src/core/_tests/reconnect.spec.ts +180 -0
  51. package/src/core/_tests/signal.spec.ts +347 -0
  52. package/src/core/_tests/state.spec.ts +226 -0
  53. package/src/core/index.ts +63 -0
  54. package/src/core/reconnect.ts +233 -0
  55. package/src/core/signal.ts +527 -0
  56. package/src/core/socket.ts +58 -0
  57. package/src/core/state.ts +251 -0
  58. package/src/index.ts +54 -0
  59. package/src/protocol.ts +9 -0
  60. package/src/react/_tests/hooks.spec.tsx +509 -0
  61. package/src/react/index.tsx +439 -0
  62. package/src/room/_tests/room.spec.ts +595 -0
  63. package/src/room/browserDevice.ts +114 -0
  64. package/src/room/device.ts +119 -0
  65. 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
+ }