ompclaw 0.3.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.
@@ -0,0 +1,704 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import type {
3
+ ConversationAddress,
4
+ DeliveryContext,
5
+ MessageAttachment,
6
+ OutboundContent,
7
+ OutboundReceipt,
8
+ Principal,
9
+ Reaction,
10
+ TransportAdapter,
11
+ TransportCapabilities,
12
+ TransportIdentity,
13
+ TransportStartContext,
14
+ UiRequest,
15
+ UiResponseFor,
16
+ } from "../../gateway-types";
17
+ import {
18
+ InvalidWebSocketFrameError,
19
+ WEBSOCKET_PROTOCOL_VERSION,
20
+ WEBSOCKET_TRANSPORT_ID,
21
+ type ClientFrame,
22
+ type ServerErrorFrame,
23
+ type ServerFrame,
24
+ parseClientFrame,
25
+ validateMaxMessageLength,
26
+ } from "./protocol";
27
+
28
+ const DEFAULT_AUTH_TIMEOUT_MS = 10_000;
29
+ const DEFAULT_UI_TIMEOUT_MS = 60_000;
30
+ const MAX_CREDENTIAL_TOKEN_LENGTH = 4_096;
31
+ const MAX_OUTBOUND_RECEIPT_ID_LENGTH = 256;
32
+
33
+ export interface WebSocketTransportCredential {
34
+ readonly token: string;
35
+ readonly subject: string;
36
+ readonly channel: string;
37
+ readonly thread?: string;
38
+ }
39
+
40
+ export interface WebSocketTransportLogger {
41
+ warn(message: string): void;
42
+ }
43
+
44
+ export interface WebSocketTransportOptions {
45
+ readonly hostname: string;
46
+ readonly port: number;
47
+ readonly account: string;
48
+ readonly credentials: readonly WebSocketTransportCredential[];
49
+ readonly authTimeoutMs?: number;
50
+ readonly uiTimeoutMs?: number;
51
+ readonly maxMessageLength?: number;
52
+ readonly logger?: WebSocketTransportLogger;
53
+ }
54
+
55
+ interface ConfiguredCredential {
56
+ readonly tokenHash: Buffer;
57
+ readonly identity: TransportIdentity;
58
+ readonly origin: ConversationAddress;
59
+ readonly originKey: string;
60
+ }
61
+
62
+ interface PendingUi {
63
+ readonly requestId: string;
64
+ readonly responseType: UiRequest["type"];
65
+ readonly principalId: string;
66
+ readonly resolve: (response: UiResponseFor<UiRequest>) => void;
67
+ readonly reject: (error: Error) => void;
68
+ timeout: NodeJS.Timeout;
69
+ abortListener?: () => void;
70
+ signal?: AbortSignal;
71
+ }
72
+
73
+ interface ConnectionState {
74
+ phase: "pending" | "authenticating" | "ready" | "closed";
75
+ readonly controller: AbortController;
76
+ readonly pendingUi: Map<string, PendingUi>;
77
+ authTimer?: NodeJS.Timeout;
78
+ socket?: WebSocketConnection;
79
+ credential?: ConfiguredCredential;
80
+ principal?: Principal;
81
+ }
82
+
83
+ interface WebSocketConnection {
84
+ readonly data: ConnectionState;
85
+ send(data: string): number;
86
+ close(code?: number, reason?: string): void;
87
+ }
88
+
89
+ interface RunningServer {
90
+ readonly port: number;
91
+ stop(closeActiveConnections?: boolean): Promise<void>;
92
+ }
93
+
94
+ export class DuplicateWebSocketOriginError extends Error {
95
+ readonly name = "DuplicateWebSocketOriginError";
96
+
97
+ constructor(readonly origin: ConversationAddress) {
98
+ super("multiple WebSocket credentials target the same conversation origin");
99
+ }
100
+ }
101
+
102
+ export class DuplicateWebSocketTokenError extends Error {
103
+ readonly name = "DuplicateWebSocketTokenError";
104
+
105
+ constructor() {
106
+ super("WebSocket credential tokens must be unique");
107
+ }
108
+ }
109
+
110
+ export class WebSocketTransportDisconnectedError extends Error {
111
+ readonly name = "WebSocketTransportDisconnectedError";
112
+
113
+ constructor() {
114
+ super("the authenticated WebSocket client is no longer connected");
115
+ }
116
+ }
117
+
118
+ export class WebSocketDeliveryPrincipalMismatchError extends Error {
119
+ readonly name = "WebSocketDeliveryPrincipalMismatchError";
120
+
121
+ constructor() {
122
+ super("the authenticated WebSocket principal does not match the delivery context");
123
+ }
124
+ }
125
+
126
+ export class WebSocketUiTimeoutError extends Error {
127
+ readonly name = "WebSocketUiTimeoutError";
128
+
129
+ constructor() {
130
+ super("the WebSocket UI response timed out");
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Authenticated v1 WebSocket transport. Credential metadata, not client frames,
136
+ * determines every inbound identity and conversation address.
137
+ */
138
+ export class WebSocketTransportAdapter implements TransportAdapter {
139
+ readonly id = WEBSOCKET_TRANSPORT_ID;
140
+ readonly capabilities: TransportCapabilities;
141
+ readonly #hostname: string;
142
+ readonly #requestedPort: number;
143
+ readonly #credentials: readonly ConfiguredCredential[];
144
+ readonly #credentialByOrigin = new Map<string, ConfiguredCredential>();
145
+ readonly #connectionsByOrigin = new Map<string, ConnectionState>();
146
+ readonly #connections = new Set<ConnectionState>();
147
+ readonly #authTimeoutMs: number;
148
+ readonly #uiTimeoutMs: number;
149
+ readonly #maxMessageLength: number;
150
+ readonly #logger?: WebSocketTransportLogger;
151
+ #server?: RunningServer;
152
+ #context?: TransportStartContext;
153
+ #startAbortListener?: () => void;
154
+ #stopping?: Promise<void>;
155
+ #serverInitiatedClose = false;
156
+
157
+ constructor(options: WebSocketTransportOptions) {
158
+ assertNonEmptyString(options.hostname, "hostname");
159
+ assertNonEmptyString(options.account, "account");
160
+ if (!Number.isSafeInteger(options.port) || options.port < 0 || options.port > 65_535) {
161
+ throw new RangeError("port must be a safe integer between 0 and 65535");
162
+ }
163
+ if (!Array.isArray(options.credentials) || options.credentials.length === 0) {
164
+ throw new TypeError("at least one WebSocket credential is required");
165
+ }
166
+
167
+ this.#hostname = options.hostname;
168
+ this.#requestedPort = options.port;
169
+ this.#authTimeoutMs = positiveTimeout(options.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS, "authTimeoutMs");
170
+ this.#uiTimeoutMs = positiveTimeout(options.uiTimeoutMs ?? DEFAULT_UI_TIMEOUT_MS, "uiTimeoutMs");
171
+ this.#maxMessageLength = options.maxMessageLength ?? 4_096;
172
+ validateMaxMessageLength(this.#maxMessageLength);
173
+ this.#logger = options.logger;
174
+ this.#credentials = options.credentials.map((credential) => this.#configureCredential(options.account, credential));
175
+ this.capabilities = {
176
+ streamingUpdates: true,
177
+ buttons: true,
178
+ multiSelect: true,
179
+ textInput: true,
180
+ attachments: true,
181
+ reactions: true,
182
+ threads: true,
183
+ maxMessageLength: this.#maxMessageLength,
184
+ };
185
+ }
186
+
187
+ get port(): number {
188
+ if (this.#server === undefined) throw new Error("WebSocket transport is not started");
189
+ return this.#server.port;
190
+ }
191
+
192
+ get url(): string {
193
+ return `ws://${this.#hostname}:${this.port}`;
194
+ }
195
+
196
+ start(context: TransportStartContext): void {
197
+ if (this.#server !== undefined || this.#stopping !== undefined) throw new Error("WebSocket transport is already started");
198
+ throwIfAborted(context.signal);
199
+ this.#serverInitiatedClose = false;
200
+ this.#context = context;
201
+ this.#server = Bun.serve<ConnectionState>({
202
+ hostname: this.#hostname,
203
+ port: this.#requestedPort,
204
+ fetch: (request, server) => this.#fetch(request, server),
205
+ websocket: {
206
+ open: (socket) => this.#open(socket as unknown as WebSocketConnection),
207
+ message: (socket, message) => {
208
+ void this.#handleFrame(socket as unknown as WebSocketConnection, message as string | Buffer).catch(() => {
209
+ this.#warn("WebSocket frame handling failed");
210
+ });
211
+ },
212
+ close: (socket) => this.#close(socket.data),
213
+ },
214
+ }) as RunningServer;
215
+
216
+ if (context.signal !== undefined) {
217
+ this.#startAbortListener = () => {
218
+ void this.stop();
219
+ };
220
+ context.signal.addEventListener("abort", this.#startAbortListener, { once: true });
221
+ }
222
+ }
223
+
224
+ stop(): Promise<void> {
225
+ if (this.#stopping !== undefined) return this.#stopping;
226
+ const server = this.#server;
227
+ if (server === undefined) return Promise.resolve();
228
+
229
+ this.#server = undefined;
230
+ if (this.#context?.signal !== undefined && this.#startAbortListener !== undefined) {
231
+ this.#context.signal.removeEventListener("abort", this.#startAbortListener);
232
+ }
233
+ this.#startAbortListener = undefined;
234
+ this.#context = undefined;
235
+
236
+ const stopping = server.stop(true);
237
+ void stopping.catch(() => undefined);
238
+ for (const connection of [...this.#connections]) {
239
+ this.#close(connection);
240
+ }
241
+
242
+ // Bun 1.3.14 bug #36223 leaves stop() pending after any server-side WebSocket close.
243
+ const completed = this.#serverInitiatedClose ? Promise.resolve() : stopping;
244
+ this.#stopping = completed;
245
+ void completed.then(
246
+ () => {
247
+ if (this.#stopping === completed) this.#stopping = undefined;
248
+ },
249
+ () => {
250
+ if (this.#stopping === completed) this.#stopping = undefined;
251
+ },
252
+ );
253
+ return completed;
254
+ }
255
+
256
+ async send(
257
+ address: ConversationAddress,
258
+ content: OutboundContent,
259
+ context: DeliveryContext,
260
+ signal?: AbortSignal,
261
+ ): Promise<OutboundReceipt> {
262
+ const connection = this.#deliveryConnection(address, context, signal);
263
+ assertOutboundContent(content, this.#maxMessageLength);
264
+ const receipt: OutboundReceipt = { transport: this.id, messageId: crypto.randomUUID() };
265
+ this.#sendRequired(connection, { type: "message", messageId: receipt.messageId, content });
266
+ return receipt;
267
+ }
268
+
269
+ async update(
270
+ address: ConversationAddress,
271
+ receipt: OutboundReceipt,
272
+ content: OutboundContent,
273
+ context: DeliveryContext,
274
+ signal?: AbortSignal,
275
+ ): Promise<OutboundReceipt> {
276
+ const connection = this.#deliveryConnection(address, context, signal);
277
+ assertReceipt(receipt, this.id);
278
+ assertOutboundContent(content, this.#maxMessageLength);
279
+ this.#sendRequired(connection, { type: "update", messageId: receipt.messageId, content });
280
+ return receipt;
281
+ }
282
+
283
+ async react(
284
+ address: ConversationAddress,
285
+ receipt: OutboundReceipt,
286
+ reaction: Reaction,
287
+ context: DeliveryContext,
288
+ signal?: AbortSignal,
289
+ ): Promise<void> {
290
+ const connection = this.#deliveryConnection(address, context, signal);
291
+ assertReceipt(receipt, this.id);
292
+ assertNonEmptyString(reaction.emoji, "reaction.emoji");
293
+ if (reaction.emoji.length > this.#maxMessageLength) {
294
+ throw new RangeError("reaction.emoji exceeds maxMessageLength");
295
+ }
296
+ this.#sendRequired(connection, { type: "reaction", messageId: receipt.messageId, emoji: reaction.emoji });
297
+ }
298
+
299
+ presentUi<Request extends UiRequest>(
300
+ address: ConversationAddress,
301
+ request: Request,
302
+ context: DeliveryContext,
303
+ signal?: AbortSignal,
304
+ ): Promise<UiResponseFor<Request>> {
305
+ let response: Promise<UiResponseFor<Request>>;
306
+ try {
307
+ const connection = this.#deliveryConnection(address, context, signal);
308
+ const requestId = crypto.randomUUID();
309
+
310
+ response = new Promise<UiResponseFor<Request>>((resolve, reject) => {
311
+ const pending: PendingUi = {
312
+ requestId,
313
+ responseType: request.type,
314
+ principalId: context.principal.id,
315
+ resolve: resolve as (response: UiResponseFor<UiRequest>) => void,
316
+ reject,
317
+ timeout: setTimeout(() => {
318
+ this.#sendError(connection, "ui_timeout", "UI response timed out");
319
+ this.#rejectPending(connection, pending, new WebSocketUiTimeoutError());
320
+ }, this.#uiTimeoutMs),
321
+ signal,
322
+ };
323
+ if (signal !== undefined) {
324
+ pending.abortListener = () => {
325
+ this.#rejectPending(connection, pending, abortError(signal));
326
+ };
327
+ signal.addEventListener("abort", pending.abortListener, { once: true });
328
+ }
329
+ connection.pendingUi.set(requestId, pending);
330
+
331
+ if (!this.#sendFrame(connection, { type: "ui_request", requestId, request })) {
332
+ this.#rejectPending(connection, pending, new WebSocketTransportDisconnectedError());
333
+ }
334
+ });
335
+ } catch (error) {
336
+ response = Promise.reject(error);
337
+ }
338
+ void response.catch(() => undefined);
339
+ return response;
340
+
341
+ }
342
+ #configureCredential(account: string, credential: WebSocketTransportCredential): ConfiguredCredential {
343
+ assertNonEmptyString(credential.token, "credential.token");
344
+ if (credential.token.length > MAX_CREDENTIAL_TOKEN_LENGTH) {
345
+ throw new RangeError("credential.token is too long");
346
+ }
347
+ assertNonEmptyString(credential.subject, "credential.subject");
348
+ assertNonEmptyString(credential.channel, "credential.channel");
349
+ if (credential.thread !== undefined) assertNonEmptyString(credential.thread, "credential.thread");
350
+
351
+ const origin: ConversationAddress = {
352
+ transport: this.id,
353
+ account,
354
+ channel: credential.channel,
355
+ ...(credential.thread === undefined ? {} : { thread: credential.thread }),
356
+ };
357
+ const configured: ConfiguredCredential = {
358
+ tokenHash: tokenHash(credential.token),
359
+ identity: { transport: this.id, account, subject: credential.subject },
360
+ origin,
361
+ originKey: originKey(origin),
362
+ };
363
+ if (this.#credentialByOrigin.has(configured.originKey)) throw new DuplicateWebSocketOriginError(origin);
364
+ for (const existing of this.#credentialByOrigin.values()) {
365
+ if (timingSafeEqual(existing.tokenHash, configured.tokenHash)) throw new DuplicateWebSocketTokenError();
366
+ }
367
+ this.#credentialByOrigin.set(configured.originKey, configured);
368
+ return configured;
369
+ }
370
+
371
+ #fetch(request: Request, server: { upgrade(request: Request, options: { data: ConnectionState }): boolean }): Response | undefined {
372
+ const url = new URL(request.url);
373
+ if (url.pathname === "/healthz" && request.method === "GET") {
374
+ return Response.json({ status: "ok" });
375
+ }
376
+ if (url.pathname === "/" && request.method === "GET" && request.headers.get("upgrade")?.toLowerCase() === "websocket") {
377
+ return server.upgrade(request, {
378
+ data: { phase: "pending", controller: new AbortController(), pendingUi: new Map<string, PendingUi>() },
379
+ })
380
+ ? undefined
381
+ : new Response("WebSocket upgrade failed", { status: 400 });
382
+ }
383
+ return new Response("Not found", { status: 404 });
384
+ }
385
+
386
+
387
+ #open(socket: WebSocketConnection): void {
388
+ const connection = socket.data;
389
+ connection.socket = socket;
390
+ if (this.#server === undefined) {
391
+ this.#close(connection);
392
+ socket.close(1001, "transport stopped");
393
+ return;
394
+ }
395
+ this.#connections.add(connection);
396
+ connection.authTimer = setTimeout(() => {
397
+ this.#terminate(connection, "auth_timeout", "authentication timed out", 1008);
398
+ }, this.#authTimeoutMs);
399
+ }
400
+
401
+ async #handleFrame(socket: WebSocketConnection, raw: string | Buffer): Promise<void> {
402
+ const connection = socket.data;
403
+ if (connection.phase === "closed") return;
404
+
405
+ let frame: ClientFrame;
406
+ try {
407
+ frame = parseClientFrame(raw, this.#maxMessageLength);
408
+ } catch (error) {
409
+ const parsed = error instanceof InvalidWebSocketFrameError ? error : undefined;
410
+ this.#sendError(connection, parsed?.code ?? "invalid_frame", "invalid client frame");
411
+ if (connection.phase !== "ready") this.#closeAfterError(connection);
412
+ return;
413
+ }
414
+
415
+ if (connection.phase !== "ready") {
416
+ if (connection.phase !== "pending" || frame.type !== "authenticate") {
417
+ this.#sendError(connection, "authentication_required", "authenticate before sending other frames");
418
+ this.#closeAfterError(connection);
419
+ return;
420
+ }
421
+ await this.#authenticate(connection, frame);
422
+ return;
423
+ }
424
+
425
+ if (frame.type === "authenticate") {
426
+ this.#sendError(connection, "invalid_frame", "client is already authenticated");
427
+ return;
428
+ }
429
+ if (frame.type === "message") {
430
+ await this.#receiveMessage(connection, frame);
431
+ return;
432
+ }
433
+ await this.#receiveUiResponse(connection, frame);
434
+ }
435
+
436
+ async #authenticate(connection: ConnectionState, frame: Extract<ClientFrame, { type: "authenticate" }>): Promise<void> {
437
+ connection.phase = "authenticating";
438
+ const credential = this.#credentialForToken(frame.token);
439
+ if (credential === undefined) {
440
+ this.#terminate(connection, "unauthorized", "authentication failed", 1008);
441
+ return;
442
+ }
443
+ if (this.#connectionsByOrigin.has(credential.originKey)) {
444
+ this.#terminate(connection, "duplicate_connection", "origin already has a live connection", 1008);
445
+ return;
446
+ }
447
+
448
+ const context = this.#context;
449
+ if (context === undefined) {
450
+ this.#terminate(connection, "transport_stopped", "transport stopped", 1001);
451
+ return;
452
+ }
453
+
454
+ let principal: Principal | undefined;
455
+ try {
456
+ principal = await context.resolveIdentity(credential.identity, connection.controller.signal);
457
+ } catch {
458
+ this.#warn("WebSocket identity resolution failed");
459
+ this.#terminate(connection, "unauthorized", "authentication failed", 1008);
460
+ return;
461
+ }
462
+ if (!this.#connections.has(connection)) return;
463
+ if (principal === undefined) {
464
+ this.#terminate(connection, "unauthorized", "authentication failed", 1008);
465
+ return;
466
+ }
467
+ if (this.#connectionsByOrigin.has(credential.originKey)) {
468
+ this.#terminate(connection, "duplicate_connection", "origin already has a live connection", 1008);
469
+ return;
470
+ }
471
+
472
+ connection.credential = credential;
473
+ connection.principal = principal;
474
+ clearTimeout(connection.authTimer);
475
+ connection.authTimer = undefined;
476
+ connection.phase = "ready";
477
+ this.#connectionsByOrigin.set(credential.originKey, connection);
478
+ this.#sendFrame(connection, { type: "ready", protocolVersion: WEBSOCKET_PROTOCOL_VERSION });
479
+ }
480
+
481
+ #credentialForToken(token: string): ConfiguredCredential | undefined {
482
+ const candidateHash = tokenHash(token);
483
+ let matching: ConfiguredCredential | undefined;
484
+ for (const credential of this.#credentials) {
485
+ if (timingSafeEqual(candidateHash, credential.tokenHash) && matching === undefined) matching = credential;
486
+ }
487
+ return matching;
488
+ }
489
+
490
+ async #receiveMessage(connection: ConnectionState, frame: Extract<ClientFrame, { type: "message" }>): Promise<void> {
491
+ const credential = connection.credential;
492
+ const context = this.#context;
493
+ if (credential === undefined || context === undefined || connection.phase !== "ready") return;
494
+
495
+ try {
496
+ await context.receive(
497
+ {
498
+ id: `${credential.originKey}:${frame.id}`,
499
+ sentAt: Date.now(),
500
+ identity: credential.identity,
501
+ address: credential.origin,
502
+ content: {
503
+ ...(frame.text === undefined ? {} : { text: frame.text }),
504
+ ...(frame.attachments === undefined ? {} : { attachments: frame.attachments }),
505
+ },
506
+ },
507
+ connection.controller.signal,
508
+ );
509
+ } catch {
510
+ this.#warn("WebSocket inbound message was rejected");
511
+ this.#sendError(connection, "message_rejected", "message could not be accepted");
512
+ }
513
+ }
514
+
515
+ async #receiveUiResponse(connection: ConnectionState, frame: Extract<ClientFrame, { type: "ui_response" }>): Promise<void> {
516
+ const pending = connection.pendingUi.get(frame.requestId);
517
+ if (pending === undefined) {
518
+ this.#sendError(connection, "ui_request_not_found", "UI request is stale or unknown");
519
+ return;
520
+ }
521
+ const credential = connection.credential;
522
+ const context = this.#context;
523
+ if (credential === undefined || context === undefined) return;
524
+
525
+ let principal: Principal | undefined;
526
+ try {
527
+ principal = await context.resolveIdentity(credential.identity, connection.controller.signal);
528
+ } catch {
529
+ this.#warn("WebSocket UI identity resolution failed");
530
+ this.#sendError(connection, "ui_principal_mismatch", "UI response principal does not match the request");
531
+ return;
532
+ }
533
+ if (connection.phase !== "ready" || principal?.id !== pending.principalId) {
534
+ this.#sendError(connection, "ui_principal_mismatch", "UI response principal does not match the request");
535
+ return;
536
+ }
537
+ if (frame.response.type !== pending.responseType) {
538
+ this.#sendError(connection, "ui_response_mismatch", "UI response type does not match the request");
539
+ return;
540
+ }
541
+ this.#resolvePending(connection, pending, frame.response);
542
+ }
543
+
544
+ #deliveryConnection(address: ConversationAddress, context: DeliveryContext, signal?: AbortSignal): ConnectionState {
545
+ throwIfAborted(signal);
546
+ if (!sameOrigin(address, context.origin)) {
547
+ throw new Error("delivery address must match the delivery context origin");
548
+ }
549
+ const connection = this.#connectionsByOrigin.get(originKey(address));
550
+ if (connection === undefined || connection.phase !== "ready") throw new WebSocketTransportDisconnectedError();
551
+ if (connection.principal?.id !== context.principal.id) throw new WebSocketDeliveryPrincipalMismatchError();
552
+ return connection;
553
+ }
554
+
555
+ #sendFrame(connection: ConnectionState, frame: ServerFrame): boolean {
556
+ if (connection.phase === "closed" || connection.socket === undefined) return false;
557
+ try {
558
+ return connection.socket.send(JSON.stringify(frame)) >= 0;
559
+ } catch {
560
+ return false;
561
+ }
562
+ }
563
+
564
+ #sendRequired(connection: ConnectionState, frame: ServerFrame): void {
565
+ if (this.#sendFrame(connection, frame)) return;
566
+ this.#close(connection);
567
+ throw new WebSocketTransportDisconnectedError();
568
+ }
569
+
570
+ #sendError(connection: ConnectionState, code: string, message: string): void {
571
+ const frame: ServerErrorFrame = { type: "error", code, message };
572
+ this.#sendFrame(connection, frame);
573
+ }
574
+
575
+ #closeAfterError(connection: ConnectionState): void {
576
+ queueMicrotask(() => {
577
+ if (connection.phase === "closed") return;
578
+ const socket = connection.socket;
579
+ this.#close(connection);
580
+ if (socket !== undefined) {
581
+ this.#serverInitiatedClose = true;
582
+ socket.close(1008, "protocol error");
583
+ }
584
+ });
585
+ }
586
+
587
+ #terminate(connection: ConnectionState, code: string, message: string, closeCode: number): void {
588
+ const socket = connection.socket;
589
+ this.#sendError(connection, code, message);
590
+ this.#close(connection);
591
+ if (socket !== undefined) {
592
+ this.#serverInitiatedClose = true;
593
+ socket.close(closeCode, message);
594
+ }
595
+ }
596
+
597
+ #close(connection: ConnectionState): void {
598
+ if (connection.phase === "closed") return;
599
+ connection.phase = "closed";
600
+ clearTimeout(connection.authTimer);
601
+ connection.authTimer = undefined;
602
+ connection.controller.abort();
603
+ this.#connections.delete(connection);
604
+ if (connection.credential !== undefined && this.#connectionsByOrigin.get(connection.credential.originKey) === connection) {
605
+ this.#connectionsByOrigin.delete(connection.credential.originKey);
606
+ }
607
+ for (const pending of [...connection.pendingUi.values()]) {
608
+ this.#rejectPending(connection, pending, new WebSocketTransportDisconnectedError());
609
+ }
610
+ }
611
+
612
+ #resolvePending(connection: ConnectionState, pending: PendingUi, response: UiResponseFor<UiRequest>): void {
613
+ this.#removePending(connection, pending);
614
+ pending.resolve(response);
615
+ }
616
+
617
+ #rejectPending(connection: ConnectionState, pending: PendingUi, error: Error): void {
618
+ if (!connection.pendingUi.has(pending.requestId)) return;
619
+ this.#removePending(connection, pending);
620
+ pending.reject(error);
621
+ }
622
+
623
+ #removePending(connection: ConnectionState, pending: PendingUi): void {
624
+ connection.pendingUi.delete(pending.requestId);
625
+ clearTimeout(pending.timeout);
626
+ if (pending.signal !== undefined && pending.abortListener !== undefined) {
627
+ pending.signal.removeEventListener("abort", pending.abortListener);
628
+ }
629
+ }
630
+
631
+ #warn(message: string): void {
632
+ this.#logger?.warn(message);
633
+ }
634
+ }
635
+
636
+ function originKey(origin: ConversationAddress): string {
637
+ return JSON.stringify([origin.transport, origin.account, origin.channel, origin.thread ?? null]);
638
+ }
639
+
640
+ function sameOrigin(left: ConversationAddress, right: ConversationAddress): boolean {
641
+ return (
642
+ left.transport === right.transport &&
643
+ left.account === right.account &&
644
+ left.channel === right.channel &&
645
+ left.thread === right.thread
646
+ );
647
+ }
648
+
649
+ function tokenHash(token: string): Buffer {
650
+ return createHash("sha256").update(token).digest();
651
+ }
652
+
653
+ function assertReceipt(receipt: OutboundReceipt, transport: string): void {
654
+ if (receipt.transport !== transport) throw new TypeError("receipt transport does not match the WebSocket transport");
655
+ assertNonEmptyString(receipt.messageId, "receipt.messageId");
656
+ if (receipt.messageId.length > MAX_OUTBOUND_RECEIPT_ID_LENGTH) {
657
+ throw new RangeError("receipt.messageId is too long");
658
+ }
659
+ }
660
+
661
+ function assertOutboundContent(content: OutboundContent, maxMessageLength: number): void {
662
+ if (content.text !== undefined) {
663
+ if (typeof content.text !== "string" || content.text.length > maxMessageLength) {
664
+ throw new RangeError("outbound text exceeds maxMessageLength");
665
+ }
666
+ }
667
+ if (content.attachments !== undefined) {
668
+ if (!Array.isArray(content.attachments) || content.attachments.length > 32) {
669
+ throw new TypeError("outbound attachments must be a bounded array");
670
+ }
671
+ for (const attachment of content.attachments) assertAttachment(attachment, maxMessageLength);
672
+ }
673
+ }
674
+
675
+ function assertAttachment(attachment: MessageAttachment, maxMessageLength: number): void {
676
+ assertNonEmptyString(attachment.url, "attachment.url");
677
+ if (attachment.url.length > 8_192) throw new RangeError("attachment.url is too long");
678
+ if (attachment.name !== undefined && (typeof attachment.name !== "string" || attachment.name.length > maxMessageLength)) {
679
+ throw new RangeError("attachment.name exceeds maxMessageLength");
680
+ }
681
+ if (attachment.mediaType !== undefined && (typeof attachment.mediaType !== "string" || attachment.mediaType.length > 256)) {
682
+ throw new RangeError("attachment.mediaType is too long");
683
+ }
684
+ }
685
+
686
+ function assertNonEmptyString(value: unknown, name: string): asserts value is string {
687
+ if (typeof value !== "string" || value.length === 0) throw new TypeError(`${name} must be a non-empty string`);
688
+ }
689
+
690
+ function positiveTimeout(value: number, name: string): number {
691
+ if (!Number.isSafeInteger(value) || value < 1 || value > 3_600_000) {
692
+ throw new RangeError(`${name} must be a safe integer between 1 and 3,600,000`);
693
+ }
694
+ return value;
695
+ }
696
+
697
+ function throwIfAborted(signal?: AbortSignal): void {
698
+ if (signal?.aborted) throw abortError(signal);
699
+ }
700
+
701
+ function abortError(signal?: AbortSignal): Error {
702
+ if (signal?.reason instanceof Error) return signal.reason;
703
+ return new DOMException("The operation was aborted", "AbortError");
704
+ }