@openmarket/rooms-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/LICENSE +201 -0
- package/README.md +25 -0
- package/dist/agent/armed-mode.d.ts +187 -0
- package/dist/agent/armed-mode.js +306 -0
- package/dist/agent/clients/common.d.ts +70 -0
- package/dist/agent/clients/common.js +46 -0
- package/dist/agent/lane-draft.d.ts +15 -0
- package/dist/agent/lane-draft.js +43 -0
- package/dist/agent/lane-prompts.d.ts +102 -0
- package/dist/agent/lane-prompts.js +120 -0
- package/dist/agent/library-context.d.ts +38 -0
- package/dist/agent/library-context.js +57 -0
- package/dist/agent/library-model.d.ts +41 -0
- package/dist/agent/library-model.js +173 -0
- package/dist/agent/room-context.d.ts +151 -0
- package/dist/agent/room-context.js +251 -0
- package/dist/agent/sidebar-model.d.ts +86 -0
- package/dist/agent/sidebar-model.js +162 -0
- package/dist/agent/topic-inbox.d.ts +119 -0
- package/dist/agent/topic-inbox.js +266 -0
- package/dist/agent/topic-view-batcher.d.ts +54 -0
- package/dist/agent/topic-view-batcher.js +115 -0
- package/dist/client.d.ts +421 -0
- package/dist/client.js +1428 -0
- package/dist/doc-reconcile.d.ts +100 -0
- package/dist/doc-reconcile.js +110 -0
- package/dist/doc-uri.d.ts +29 -0
- package/dist/doc-uri.js +55 -0
- package/dist/endpoints.d.ts +9 -0
- package/dist/endpoints.js +13 -0
- package/dist/jwt.d.ts +10 -0
- package/dist/jwt.js +51 -0
- package/dist/library-publisher.d.ts +52 -0
- package/dist/library-publisher.js +49 -0
- package/dist/merge3.d.ts +50 -0
- package/dist/merge3.js +280 -0
- package/dist/names.d.ts +6 -0
- package/dist/names.js +35 -0
- package/dist/shared/emoji-data.d.ts +22 -0
- package/dist/shared/emoji-data.js +8834 -0
- package/dist/shared/mentions.d.ts +6 -0
- package/dist/shared/mentions.js +32 -0
- package/dist/shared/rooms-protocol.d.ts +1183 -0
- package/dist/shared/rooms-protocol.js +160 -0
- package/dist/social-client.d.ts +361 -0
- package/dist/social-client.js +686 -0
- package/dist/social-types.d.ts +338 -0
- package/dist/social-types.js +1 -0
- package/dist/suggestion-attribution.d.ts +10 -0
- package/dist/suggestion-attribution.js +56 -0
- package/dist/topic-uri.d.ts +26 -0
- package/dist/topic-uri.js +40 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/ws-client.d.ts +115 -0
- package/dist/ws-client.js +491 -0
- package/package.json +180 -0
- package/src/agent/armed-mode.ts +368 -0
- package/src/agent/clients/common.ts +91 -0
- package/src/agent/lane-draft.ts +47 -0
- package/src/agent/lane-prompts.ts +267 -0
- package/src/agent/library-context.ts +97 -0
- package/src/agent/library-model.ts +210 -0
- package/src/agent/room-context.ts +351 -0
- package/src/agent/sidebar-model.ts +235 -0
- package/src/agent/topic-inbox.ts +297 -0
- package/src/agent/topic-view-batcher.ts +134 -0
- package/src/client.ts +2331 -0
- package/src/doc-reconcile.ts +160 -0
- package/src/doc-uri.ts +83 -0
- package/src/endpoints.ts +14 -0
- package/src/jwt.ts +59 -0
- package/src/library-publisher.ts +93 -0
- package/src/merge3.ts +326 -0
- package/src/names.ts +44 -0
- package/src/shared/emoji-data.ts +8868 -0
- package/src/shared/mentions.ts +32 -0
- package/src/shared/rooms-protocol.ts +1339 -0
- package/src/social-client.ts +1287 -0
- package/src/social-types.ts +376 -0
- package/src/suggestion-attribution.ts +83 -0
- package/src/topic-uri.ts +64 -0
- package/src/types.ts +83 -0
- package/src/version.ts +2 -0
- package/src/ws-client.ts +611 -0
package/src/ws-client.ts
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type RoomAuthSuccessPayload,
|
|
3
|
+
type RoomClientMessage,
|
|
4
|
+
RoomClientMessageType,
|
|
5
|
+
type RoomErrorPayload,
|
|
6
|
+
type RoomServerMessage,
|
|
7
|
+
type RoomServerMessageOf,
|
|
8
|
+
RoomServerMessageType,
|
|
9
|
+
SUPPORTED_ROOM_PROTOCOL_VERSION,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
import { VERSION } from "./version.js";
|
|
12
|
+
|
|
13
|
+
interface WsCloseLike extends Event {
|
|
14
|
+
code?: number;
|
|
15
|
+
reason?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface WsMessageLike {
|
|
19
|
+
data: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class RoomProtocolError extends Error {
|
|
23
|
+
override readonly name = "RoomProtocolError";
|
|
24
|
+
readonly code: string;
|
|
25
|
+
readonly details?: unknown;
|
|
26
|
+
readonly requestId?: string;
|
|
27
|
+
|
|
28
|
+
constructor(payload: RoomErrorPayload) {
|
|
29
|
+
super(payload.message);
|
|
30
|
+
this.code = payload.code;
|
|
31
|
+
if (payload.details !== undefined) this.details = payload.details;
|
|
32
|
+
if (payload.requestId) this.requestId = payload.requestId;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class RoomTransportError extends Error {
|
|
37
|
+
override readonly name = "RoomTransportError";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type RoomWebSocketCtor = new (url: string) => WebSocket;
|
|
41
|
+
|
|
42
|
+
export interface RoomWsClientOptions {
|
|
43
|
+
url: string;
|
|
44
|
+
apiKey: string;
|
|
45
|
+
/** Refreshes a locally managed credential after an authentication refusal. */
|
|
46
|
+
refreshAuthToken?: () => Promise<string>;
|
|
47
|
+
webSocketCtor?: RoomWebSocketCtor;
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
/** Enable bounded exponential-backoff reconnection on an unexpected drop. */
|
|
50
|
+
reconnect?: boolean;
|
|
51
|
+
/** Base backoff in ms (default 500). */
|
|
52
|
+
reconnectBaseMs?: number;
|
|
53
|
+
/** Max backoff in ms (default 15_000). */
|
|
54
|
+
reconnectMaxMs?: number;
|
|
55
|
+
/** Flat 0..N ms herd jitter added to every reconnect delay (default 5_000).
|
|
56
|
+
* A relay restart drops every client at once; the exponential backoff's
|
|
57
|
+
* first window is only ~250-500ms, so without this the whole fleet
|
|
58
|
+
* re-AUTHENTICATEs in the same instant. */
|
|
59
|
+
reconnectHerdJitterMs?: number;
|
|
60
|
+
/** Give up after this many consecutive failures (default: never; live sessions reconnect indefinitely). */
|
|
61
|
+
reconnectMaxAttempts?: number;
|
|
62
|
+
/** Sidekick identity assertion: this session presents itself as the named
|
|
63
|
+
* operator-owned agent (AUTHENTICATE carries isAgent plus the display
|
|
64
|
+
* username, additive fields old relays ignore). Ghost and plain user
|
|
65
|
+
* sessions omit it entirely, keeping their first frame byte-shaped like
|
|
66
|
+
* today's. Display only; safety keys on the daemon credential. */
|
|
67
|
+
agentIdentity?: { name: string };
|
|
68
|
+
onProtocolMismatch?: (info: { supported: number; server: number }) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type RoomListener = (msg: RoomServerMessage) => void;
|
|
72
|
+
|
|
73
|
+
/** Notified when the socket drops and when it comes back after a reconnect. */
|
|
74
|
+
export type RoomConnectionListener = (
|
|
75
|
+
state: "reconnecting" | "reconnected" | "failed",
|
|
76
|
+
detail?: { attempt?: number; error?: string },
|
|
77
|
+
) => void;
|
|
78
|
+
|
|
79
|
+
interface PendingWait {
|
|
80
|
+
type: RoomServerMessageType;
|
|
81
|
+
types?: ReadonlySet<RoomServerMessageType>;
|
|
82
|
+
predicate?: (msg: RoomServerMessage) => boolean;
|
|
83
|
+
requestId?: string;
|
|
84
|
+
resolve: (msg: RoomServerMessage) => void;
|
|
85
|
+
reject: (err: Error) => void;
|
|
86
|
+
timer: ReturnType<typeof setTimeout>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class RoomWsClient {
|
|
90
|
+
private readonly url: string;
|
|
91
|
+
// Mutable: a guest session re-mints its key via refreshAuthToken.
|
|
92
|
+
private apiKey: string;
|
|
93
|
+
private readonly agentIdentity: { name: string } | undefined;
|
|
94
|
+
private readonly webSocketCtor: new (
|
|
95
|
+
url: string,
|
|
96
|
+
) => WebSocket;
|
|
97
|
+
private readonly timeoutMs: number;
|
|
98
|
+
|
|
99
|
+
private ws: WebSocket | null = null;
|
|
100
|
+
private auth: RoomAuthSuccessPayload | null = null;
|
|
101
|
+
private serverProtocolVersionValue: number | null = null;
|
|
102
|
+
private listeners = new Set<RoomListener>();
|
|
103
|
+
private connectionListeners = new Set<RoomConnectionListener>();
|
|
104
|
+
private pending: PendingWait[] = [];
|
|
105
|
+
private connected = false;
|
|
106
|
+
|
|
107
|
+
private readonly reconnectEnabled: boolean;
|
|
108
|
+
private readonly reconnectBaseMs: number;
|
|
109
|
+
private readonly reconnectMaxMs: number;
|
|
110
|
+
private readonly reconnectHerdJitterMs: number;
|
|
111
|
+
private readonly reconnectMaxAttempts: number;
|
|
112
|
+
private readonly refreshAuthToken: (() => Promise<string>) | undefined;
|
|
113
|
+
private readonly onProtocolMismatch:
|
|
114
|
+
| ((info: { supported: number; server: number }) => void)
|
|
115
|
+
| undefined;
|
|
116
|
+
private reconnectAttempts = 0;
|
|
117
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
118
|
+
private closedByClient = false;
|
|
119
|
+
private authRefreshRequested = false;
|
|
120
|
+
private authRefreshInFlight = false;
|
|
121
|
+
/** True after a successful re-mint whose refreshed attempt hasn't authed yet.
|
|
122
|
+
* Caps the mint-succeeds-but-server-rejects cycle (config skew) at ONE
|
|
123
|
+
* refreshed attempt per episode — without it, openSocket → fail → re-mint →
|
|
124
|
+
* openSocket recurses in a hot loop with no backoff. Reset on AUTH_SUCCESS.
|
|
125
|
+
* A FAILED re-mint does not set it: that path already backs off through
|
|
126
|
+
* maybeReconnect, so transient auth-service outages keep retrying. */
|
|
127
|
+
private authRefreshAttempted = false;
|
|
128
|
+
|
|
129
|
+
constructor(options: RoomWsClientOptions) {
|
|
130
|
+
this.url = options.url;
|
|
131
|
+
this.apiKey = options.apiKey;
|
|
132
|
+
this.agentIdentity = options.agentIdentity;
|
|
133
|
+
this.webSocketCtor = options.webSocketCtor ?? globalThis.WebSocket;
|
|
134
|
+
this.timeoutMs = options.timeoutMs ?? 15_000;
|
|
135
|
+
this.reconnectEnabled = options.reconnect ?? false;
|
|
136
|
+
this.reconnectBaseMs = options.reconnectBaseMs ?? 500;
|
|
137
|
+
this.reconnectMaxMs = options.reconnectMaxMs ?? 15_000;
|
|
138
|
+
this.reconnectHerdJitterMs = options.reconnectHerdJitterMs ?? 5_000;
|
|
139
|
+
this.reconnectMaxAttempts = options.reconnectMaxAttempts ?? Number.POSITIVE_INFINITY;
|
|
140
|
+
this.refreshAuthToken = options.refreshAuthToken;
|
|
141
|
+
this.onProtocolMismatch = options.onProtocolMismatch;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Subscribe to connection-state transitions (reconnecting/reconnected/failed). */
|
|
145
|
+
onConnectionChange(listener: RoomConnectionListener): () => void {
|
|
146
|
+
this.connectionListeners.add(listener);
|
|
147
|
+
return () => {
|
|
148
|
+
this.connectionListeners.delete(listener);
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private emitConnectionState(
|
|
153
|
+
state: "reconnecting" | "reconnected" | "failed",
|
|
154
|
+
detail?: { attempt?: number; error?: string },
|
|
155
|
+
): void {
|
|
156
|
+
for (const listener of this.connectionListeners) {
|
|
157
|
+
try {
|
|
158
|
+
listener(state, detail);
|
|
159
|
+
} catch {
|
|
160
|
+
// A listener error must not break reconnection.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
connect(): Promise<RoomAuthSuccessPayload> {
|
|
166
|
+
if (this.ws) {
|
|
167
|
+
if (this.auth) return Promise.resolve(this.auth);
|
|
168
|
+
return this.waitFor(RoomServerMessageType.AUTH_SUCCESS).then((msg) => msg.payload);
|
|
169
|
+
}
|
|
170
|
+
this.closedByClient = false;
|
|
171
|
+
return this.openSocket();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private openSocket(): Promise<RoomAuthSuccessPayload> {
|
|
175
|
+
// Fence any prior socket so a slow-to-die predecessor (e.g. one whose auth
|
|
176
|
+
// timed out while still connecting) can't feed record() or null out the
|
|
177
|
+
// fresh socket. Handlers below no-op unless they belong to the current ws.
|
|
178
|
+
const previous = this.ws;
|
|
179
|
+
if (previous) {
|
|
180
|
+
this.ws = null;
|
|
181
|
+
try {
|
|
182
|
+
previous.close(1000, "superseded");
|
|
183
|
+
} catch {
|
|
184
|
+
// best-effort
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let ws: WebSocket;
|
|
189
|
+
try {
|
|
190
|
+
ws = new this.webSocketCtor(this.url);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
throw new RoomTransportError(errorMessage(err));
|
|
193
|
+
}
|
|
194
|
+
this.ws = ws;
|
|
195
|
+
|
|
196
|
+
ws.addEventListener("open", () => {
|
|
197
|
+
if (this.ws !== ws) return;
|
|
198
|
+
this.connected = true;
|
|
199
|
+
this.send({
|
|
200
|
+
type: RoomClientMessageType.AUTHENTICATE,
|
|
201
|
+
payload: {
|
|
202
|
+
token: this.apiKey,
|
|
203
|
+
protocolVersion: SUPPORTED_ROOM_PROTOCOL_VERSION,
|
|
204
|
+
// Sidekick sessions assert their agenthood and display name;
|
|
205
|
+
// ghost and user sessions send the bare frame unchanged.
|
|
206
|
+
...(this.agentIdentity ? { isAgent: true, username: this.agentIdentity.name } : {}),
|
|
207
|
+
},
|
|
208
|
+
timestamp: Date.now(),
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
ws.addEventListener("message", (event) => {
|
|
213
|
+
if (this.ws !== ws) return;
|
|
214
|
+
const data = (event as WsMessageLike).data;
|
|
215
|
+
if (typeof data !== "string" || data.length === 0) return;
|
|
216
|
+
let parsed: unknown;
|
|
217
|
+
try {
|
|
218
|
+
parsed = JSON.parse(data);
|
|
219
|
+
} catch {
|
|
220
|
+
this.rejectAll(new RoomTransportError("rooms ws: dropped non-json frame"));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (!isRoomShapedFrame(parsed)) {
|
|
224
|
+
this.rejectAll(new RoomTransportError("rooms ws: dropped malformed frame"));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (!isRoomServerMessage(parsed)) {
|
|
228
|
+
// Additive-only protocol: a newer relay may send frame types this
|
|
229
|
+
// client does not know yet. Ignore them; never fail the session.
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
this.record(parsed);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
ws.addEventListener("close", (event) => {
|
|
236
|
+
// A superseded socket's close must not touch current state or trigger
|
|
237
|
+
// another reconnect.
|
|
238
|
+
if (this.ws !== ws) return;
|
|
239
|
+
const e = event as WsCloseLike;
|
|
240
|
+
const hadAuth = this.auth !== null;
|
|
241
|
+
this.connected = false;
|
|
242
|
+
this.auth = null;
|
|
243
|
+
this.ws = null;
|
|
244
|
+
if (!this.closedByClient && !hadAuth && e.code === 1008 && this.refreshAuthToken) {
|
|
245
|
+
this.authRefreshRequested = true;
|
|
246
|
+
}
|
|
247
|
+
this.rejectAll(
|
|
248
|
+
new RoomTransportError(
|
|
249
|
+
`rooms ws closed${e.code ? ` (${e.code})` : ""}${e.reason ? `: ${e.reason}` : ""}`,
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
this.maybeReconnect(e.reason || (e.code ? `code ${e.code}` : undefined));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
ws.addEventListener("error", () => {
|
|
256
|
+
if (this.ws !== ws) return;
|
|
257
|
+
this.rejectAll(new RoomTransportError("rooms ws error"));
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
return this.waitFor(RoomServerMessageType.AUTH_SUCCESS)
|
|
261
|
+
.then((msg) => {
|
|
262
|
+
this.reconnectAttempts = 0;
|
|
263
|
+
this.authRefreshAttempted = false;
|
|
264
|
+
return msg.payload;
|
|
265
|
+
})
|
|
266
|
+
.catch(async (error) => this.retryGuestAuthentication(error));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private maybeReconnect(reason?: string): void {
|
|
270
|
+
if (
|
|
271
|
+
!this.reconnectEnabled ||
|
|
272
|
+
this.closedByClient ||
|
|
273
|
+
this.authRefreshRequested ||
|
|
274
|
+
this.authRefreshInFlight
|
|
275
|
+
) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (this.reconnectTimer) return;
|
|
279
|
+
if (this.reconnectAttempts >= this.reconnectMaxAttempts) {
|
|
280
|
+
this.emitConnectionState("failed", {
|
|
281
|
+
attempt: this.reconnectAttempts,
|
|
282
|
+
...(reason ? { error: reason } : {}),
|
|
283
|
+
});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const attempt = this.reconnectAttempts + 1;
|
|
287
|
+
this.reconnectAttempts = attempt;
|
|
288
|
+
// Exponential backoff with full jitter, capped.
|
|
289
|
+
const ceiling = Math.min(this.reconnectMaxMs, this.reconnectBaseMs * 2 ** (attempt - 1));
|
|
290
|
+
const delay = Math.floor(
|
|
291
|
+
ceiling * (0.5 + Math.random() * 0.5) + Math.random() * this.reconnectHerdJitterMs,
|
|
292
|
+
);
|
|
293
|
+
this.emitConnectionState("reconnecting", {
|
|
294
|
+
attempt,
|
|
295
|
+
...(reason ? { error: reason } : {}),
|
|
296
|
+
});
|
|
297
|
+
this.reconnectTimer = setTimeout(() => {
|
|
298
|
+
this.reconnectTimer = null;
|
|
299
|
+
if (this.closedByClient) return;
|
|
300
|
+
try {
|
|
301
|
+
this.openSocket()
|
|
302
|
+
.then(() => {
|
|
303
|
+
this.emitConnectionState("reconnected", { attempt });
|
|
304
|
+
})
|
|
305
|
+
.catch((err) => {
|
|
306
|
+
// Re-auth failed; drive the next backoff attempt.
|
|
307
|
+
this.maybeReconnect(errorMessage(err));
|
|
308
|
+
});
|
|
309
|
+
} catch (err) {
|
|
310
|
+
// openSocket can throw synchronously (constructor failure) before it
|
|
311
|
+
// returns a promise — that must not escape the timer as an uncaught
|
|
312
|
+
// exception. Fold it back into the backoff loop.
|
|
313
|
+
this.maybeReconnect(errorMessage(err));
|
|
314
|
+
}
|
|
315
|
+
}, delay);
|
|
316
|
+
if (typeof (this.reconnectTimer as { unref?: () => void }).unref === "function") {
|
|
317
|
+
(this.reconnectTimer as unknown as { unref: () => void }).unref();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
send(msg: RoomClientMessage): void {
|
|
322
|
+
if (!this.ws || !this.connected) {
|
|
323
|
+
throw new RoomTransportError("rooms ws: not open");
|
|
324
|
+
}
|
|
325
|
+
this.ws.send(JSON.stringify(msg));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
waitFor<T extends RoomServerMessageType>(
|
|
329
|
+
type: T,
|
|
330
|
+
predicate?: (msg: RoomServerMessageOf<T>) => boolean,
|
|
331
|
+
timeoutMs = this.timeoutMs,
|
|
332
|
+
requestId?: string,
|
|
333
|
+
): Promise<RoomServerMessageOf<T>> {
|
|
334
|
+
return new Promise((resolve, reject) => {
|
|
335
|
+
let pending: PendingWait;
|
|
336
|
+
const timer = setTimeout(() => {
|
|
337
|
+
this.pending = this.pending.filter((wait) => wait !== pending);
|
|
338
|
+
reject(new RoomTransportError(`timed out waiting for ${type}`));
|
|
339
|
+
}, timeoutMs);
|
|
340
|
+
pending = {
|
|
341
|
+
type,
|
|
342
|
+
...(predicate
|
|
343
|
+
? { predicate: (msg: RoomServerMessage) => predicate(msg as RoomServerMessageOf<T>) }
|
|
344
|
+
: {}),
|
|
345
|
+
...(requestId ? { requestId } : {}),
|
|
346
|
+
resolve: (msg: RoomServerMessage) => resolve(msg as RoomServerMessageOf<T>),
|
|
347
|
+
reject,
|
|
348
|
+
timer,
|
|
349
|
+
};
|
|
350
|
+
this.pending.push(pending);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
waitForAny<T extends RoomServerMessageType>(
|
|
355
|
+
types: readonly T[],
|
|
356
|
+
predicate?: (msg: Extract<RoomServerMessage, { type: T }>) => boolean,
|
|
357
|
+
timeoutMs = this.timeoutMs,
|
|
358
|
+
requestId?: string,
|
|
359
|
+
): Promise<Extract<RoomServerMessage, { type: T }>> {
|
|
360
|
+
return new Promise((resolve, reject) => {
|
|
361
|
+
let pending: PendingWait;
|
|
362
|
+
const timer = setTimeout(() => {
|
|
363
|
+
this.pending = this.pending.filter((wait) => wait !== pending);
|
|
364
|
+
reject(new RoomTransportError(`timed out waiting for ${types.join(" or ")}`));
|
|
365
|
+
}, timeoutMs);
|
|
366
|
+
const typeSet = new Set<RoomServerMessageType>(types);
|
|
367
|
+
pending = {
|
|
368
|
+
type: types[0] as RoomServerMessageType,
|
|
369
|
+
types: typeSet,
|
|
370
|
+
predicate: (msg: RoomServerMessage) =>
|
|
371
|
+
typeSet.has(msg.type) &&
|
|
372
|
+
(!predicate || predicate(msg as Extract<RoomServerMessage, { type: T }>)),
|
|
373
|
+
...(requestId ? { requestId } : {}),
|
|
374
|
+
resolve: (msg: RoomServerMessage) =>
|
|
375
|
+
resolve(msg as Extract<RoomServerMessage, { type: T }>),
|
|
376
|
+
reject,
|
|
377
|
+
timer,
|
|
378
|
+
};
|
|
379
|
+
this.pending.push(pending);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
onMessage(listener: RoomListener): () => void {
|
|
384
|
+
this.listeners.add(listener);
|
|
385
|
+
return () => {
|
|
386
|
+
this.listeners.delete(listener);
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
close(): void {
|
|
391
|
+
this.closedByClient = true;
|
|
392
|
+
if (this.reconnectTimer) {
|
|
393
|
+
clearTimeout(this.reconnectTimer);
|
|
394
|
+
this.reconnectTimer = null;
|
|
395
|
+
}
|
|
396
|
+
const ws = this.ws;
|
|
397
|
+
this.ws = null;
|
|
398
|
+
this.auth = null;
|
|
399
|
+
this.connected = false;
|
|
400
|
+
this.rejectAll(new RoomTransportError("rooms ws closed by client"));
|
|
401
|
+
ws?.close(1000, "client closed");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private readonly waiterHandledErrors = new WeakSet<object>();
|
|
405
|
+
|
|
406
|
+
serverProtocolVersion(): number | null {
|
|
407
|
+
return this.serverProtocolVersionValue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
isGuest(): boolean {
|
|
411
|
+
return this.auth?.isGuest === true;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private record(msg: RoomServerMessage): void {
|
|
415
|
+
if (msg.type === RoomServerMessageType.AUTH_SUCCESS) {
|
|
416
|
+
if (!this.handleAuthSuccess(msg.payload)) return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (msg.type === RoomServerMessageType.ERROR) {
|
|
420
|
+
if (
|
|
421
|
+
!this.auth &&
|
|
422
|
+
this.refreshAuthToken &&
|
|
423
|
+
isAuthenticationError(msg.payload.code) &&
|
|
424
|
+
!this.closedByClient
|
|
425
|
+
) {
|
|
426
|
+
this.authRefreshRequested = true;
|
|
427
|
+
}
|
|
428
|
+
const err = new RoomProtocolError(msg.payload);
|
|
429
|
+
if (msg.payload.requestId) {
|
|
430
|
+
if (this.rejectByRequestId(msg.payload.requestId, err)) {
|
|
431
|
+
// Precisely correlated: the request's caller owns this failure. Mark
|
|
432
|
+
// the frame so passive listeners (the tape pump) don't ALSO surface
|
|
433
|
+
// it — background requests against older relays degrade silently.
|
|
434
|
+
this.waiterHandledErrors.add(msg);
|
|
435
|
+
}
|
|
436
|
+
// A requestId we don't recognize is someone else's request (another
|
|
437
|
+
// session, a stale retry): never misattribute it to an unrelated
|
|
438
|
+
// waiter. Passive surfaces still see the frame.
|
|
439
|
+
} else if (this.rejectOldest(err)) {
|
|
440
|
+
// Heuristic old-relay fallback: the oldest waiter got the rejection,
|
|
441
|
+
// but the correlation is a guess. Only hide pure protocol noise;
|
|
442
|
+
// user-actionable errors (MUTED, READ_ONLY, …) must stay visible.
|
|
443
|
+
if (msg.payload.code === "BAD_FRAME") this.waiterHandledErrors.add(msg);
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
this.resolvePending(msg);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
for (const listener of this.listeners) listener(msg);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private handleAuthSuccess(payload: RoomAuthSuccessPayload): boolean {
|
|
453
|
+
this.auth = payload;
|
|
454
|
+
this.serverProtocolVersionValue = payload.protocolVersion;
|
|
455
|
+
if (payload.protocolVersion !== SUPPORTED_ROOM_PROTOCOL_VERSION) {
|
|
456
|
+
this.onProtocolMismatch?.({
|
|
457
|
+
supported: SUPPORTED_ROOM_PROTOCOL_VERSION,
|
|
458
|
+
server: payload.protocolVersion,
|
|
459
|
+
});
|
|
460
|
+
const err = new RoomTransportError(
|
|
461
|
+
`rooms protocol mismatch: server v${payload.protocolVersion}, client supports v${SUPPORTED_ROOM_PROTOCOL_VERSION}`,
|
|
462
|
+
);
|
|
463
|
+
this.closedByClient = true;
|
|
464
|
+
this.rejectAll(err);
|
|
465
|
+
this.ws?.close(1002, "protocol mismatch");
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (
|
|
470
|
+
!payload.minClientBuild ||
|
|
471
|
+
!isRoomClientBuildBelowMinimum(VERSION, payload.minClientBuild)
|
|
472
|
+
) {
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const err = new RoomTransportError(
|
|
477
|
+
`rooms client upgrade required: server requires om v${payload.minClientBuild} or newer (this build is v${VERSION})`,
|
|
478
|
+
);
|
|
479
|
+
this.closedByClient = true;
|
|
480
|
+
this.rejectAll(err);
|
|
481
|
+
this.ws?.close(1008, "client upgrade required");
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private async retryGuestAuthentication(error: unknown): Promise<RoomAuthSuccessPayload> {
|
|
486
|
+
if (!this.authRefreshRequested || !this.refreshAuthToken || this.closedByClient) {
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
if (this.authRefreshAttempted) {
|
|
490
|
+
// The previous re-mint SUCCEEDED and the server still refused the fresh
|
|
491
|
+
// key — retrying would mint in a hot loop. Fail the episode instead.
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
this.authRefreshRequested = false;
|
|
496
|
+
this.authRefreshInFlight = true;
|
|
497
|
+
try {
|
|
498
|
+
this.apiKey = await this.refreshAuthToken();
|
|
499
|
+
this.authRefreshAttempted = true;
|
|
500
|
+
return await this.openSocket();
|
|
501
|
+
} catch (refreshError) {
|
|
502
|
+
this.authRefreshInFlight = false;
|
|
503
|
+
this.maybeReconnect(errorMessage(refreshError));
|
|
504
|
+
throw refreshError;
|
|
505
|
+
} finally {
|
|
506
|
+
this.authRefreshInFlight = false;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private resolvePending(msg: RoomServerMessage): void {
|
|
511
|
+
const remaining: PendingWait[] = [];
|
|
512
|
+
for (const wait of this.pending) {
|
|
513
|
+
if (wait.types ? !wait.types.has(msg.type) : wait.type !== msg.type) {
|
|
514
|
+
remaining.push(wait);
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (wait.predicate && !wait.predicate(msg)) {
|
|
518
|
+
remaining.push(wait);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
clearTimeout(wait.timer);
|
|
522
|
+
wait.resolve(msg);
|
|
523
|
+
}
|
|
524
|
+
this.pending = remaining;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private rejectAll(err: Error): void {
|
|
528
|
+
for (const wait of this.pending) {
|
|
529
|
+
clearTimeout(wait.timer);
|
|
530
|
+
wait.reject(err);
|
|
531
|
+
}
|
|
532
|
+
this.pending = [];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private rejectByRequestId(requestId: string | undefined, err: Error): boolean {
|
|
536
|
+
if (!requestId) return false;
|
|
537
|
+
const index = this.pending.findIndex((wait) => wait.requestId === requestId);
|
|
538
|
+
if (index < 0) return false;
|
|
539
|
+
const [wait] = this.pending.splice(index, 1);
|
|
540
|
+
if (!wait) return false;
|
|
541
|
+
clearTimeout(wait.timer);
|
|
542
|
+
wait.reject(err);
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Fallback for old relays that do not include requestId on ERROR frames.
|
|
547
|
+
private rejectOldest(err: Error): boolean {
|
|
548
|
+
const wait = this.pending.shift();
|
|
549
|
+
if (!wait) return false;
|
|
550
|
+
clearTimeout(wait.timer);
|
|
551
|
+
wait.reject(err);
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Was this ERROR frame already delivered to a waiting request's caller? */
|
|
556
|
+
errorHandledByWaiter(msg: RoomServerMessage): boolean {
|
|
557
|
+
return this.waiterHandledErrors.has(msg);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Returns false for an invalid server value so a typo cannot lock out every client. */
|
|
562
|
+
export function isRoomClientBuildBelowMinimum(clientBuild: string, minimumBuild: string): boolean {
|
|
563
|
+
const client = parseBuild(clientBuild);
|
|
564
|
+
const minimum = parseBuild(minimumBuild);
|
|
565
|
+
if (!client || !minimum) return false;
|
|
566
|
+
for (const index of [0, 1, 2] as const) {
|
|
567
|
+
const clientPart = client[index];
|
|
568
|
+
const minimumPart = minimum[index];
|
|
569
|
+
if (clientPart !== minimumPart) return clientPart < minimumPart;
|
|
570
|
+
}
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function parseBuild(value: string): readonly [number, number, number] | null {
|
|
575
|
+
const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(value.trim());
|
|
576
|
+
if (!match) return null;
|
|
577
|
+
const parts = [match[1], match[2] ?? "0", match[3] ?? "0"].map(Number);
|
|
578
|
+
if (parts.some((part) => !Number.isSafeInteger(part))) return null;
|
|
579
|
+
return parts as [number, number, number];
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const KNOWN_SERVER_MESSAGE_TYPES = new Set<string>(Object.values(RoomServerMessageType));
|
|
583
|
+
|
|
584
|
+
function isRoomShapedFrame(
|
|
585
|
+
value: unknown,
|
|
586
|
+
): value is { type: string; payload: object; timestamp: number } {
|
|
587
|
+
if (!value || typeof value !== "object") return false;
|
|
588
|
+
const candidate = value as {
|
|
589
|
+
type?: unknown;
|
|
590
|
+
timestamp?: unknown;
|
|
591
|
+
payload?: unknown;
|
|
592
|
+
};
|
|
593
|
+
if (typeof candidate.type !== "string") return false;
|
|
594
|
+
if (typeof candidate.timestamp !== "number") return false;
|
|
595
|
+
if (!candidate.payload || typeof candidate.payload !== "object") return false;
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function isRoomServerMessage(value: unknown): value is RoomServerMessage {
|
|
600
|
+
// A hostile or buggy relay cannot smuggle an unexpected shape past the
|
|
601
|
+
// type switch that consumers rely on: only known frame types pass.
|
|
602
|
+
return isRoomShapedFrame(value) && KNOWN_SERVER_MESSAGE_TYPES.has(value.type);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function errorMessage(err: unknown): string {
|
|
606
|
+
return err instanceof Error ? err.message : String(err);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function isAuthenticationError(code: string): boolean {
|
|
610
|
+
return code === "AUTH_FAILED" || code === "AUTH_TIMEOUT" || code === "TOKEN_REQUIRED";
|
|
611
|
+
}
|