@turingfocus/chat-gateway-tfrobot 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/dist/socket.js ADDED
@@ -0,0 +1,654 @@
1
+ import { io } from "socket.io-client";
2
+ import { chatErrorSchema, chatUpdateSchema, createGatewayDeadlineExceededError, isGatewayDeadlineExceeded, sanitizeDiagnosticText, sanitizeRaw, } from "@turingfocus/chat-protocol";
3
+ import { awaitBounded } from "./bounded.js";
4
+ import { chatErrorEventDtoSchema, eventDtoSchema, messageDtoSchema, socketProtocolErrorDtoSchema, stateChangedDtoSchema, } from "./dto.js";
5
+ import { mapEventUpdate, mapMessageUpdate, mapRun, mapUnknownSocketEvent, } from "./mapper.js";
6
+ import { isValidTFRobotSession } from "./types.js";
7
+ const KNOWN_EVENTS = new Set([
8
+ "chat_error",
9
+ "chat_event",
10
+ "chat_message",
11
+ "connect",
12
+ "connect_error",
13
+ "conversation_state_changed",
14
+ "disconnect",
15
+ "error",
16
+ ]);
17
+ const MAX_TIMER_DELAY = 2_147_483_647;
18
+ const RECONNECT_AUTH_TIMEOUT_MS = 10_000;
19
+ const sameRun = (first, second) => first === second ||
20
+ (first != null &&
21
+ second != null &&
22
+ first.id === second.id &&
23
+ first.status === second.status &&
24
+ first.canInterrupt === second.canInterrupt &&
25
+ first.startedAt === second.startedAt);
26
+ const socketAuthRejectionCode = (reason) => {
27
+ if (reason === null || typeof reason !== "object")
28
+ return undefined;
29
+ const record = reason;
30
+ const data = record["data"] !== null && typeof record["data"] === "object"
31
+ ? record["data"]
32
+ : undefined;
33
+ const status = data?.["status"] ?? data?.["statusCode"] ?? record["status"];
34
+ if (status === 401 || status === "401")
35
+ return "authentication";
36
+ if (status === 403 || status === "403")
37
+ return "authorization";
38
+ const code = String(data?.["code"] ?? record["code"] ?? "").toLowerCase();
39
+ if (code.includes("forbidden") || code.includes("permission")) {
40
+ return "authorization";
41
+ }
42
+ if (code.includes("auth") ||
43
+ code.includes("credential") ||
44
+ code.includes("token")) {
45
+ return "authentication";
46
+ }
47
+ const message = reason instanceof Error && typeof reason.message === "string"
48
+ ? reason.message.toLowerCase()
49
+ : "";
50
+ if (message.includes("connection rejected by server") ||
51
+ message.includes("not authorized") ||
52
+ message.includes("unauthorized") ||
53
+ message.includes("authentication") ||
54
+ message.includes("invalid token") ||
55
+ message.includes("jwt")) {
56
+ return "authentication";
57
+ }
58
+ if (message.includes("forbidden") || message.includes("permission denied")) {
59
+ return "authorization";
60
+ }
61
+ return undefined;
62
+ };
63
+ const transportConversationId = (payload) => {
64
+ if (payload === null || typeof payload !== "object")
65
+ return undefined;
66
+ const record = payload;
67
+ const value = record["conversationId"] ?? record["conversation_id"];
68
+ if (typeof value === "string" && value.trim().length > 0)
69
+ return value;
70
+ return typeof value === "number" && Number.isFinite(value)
71
+ ? String(value)
72
+ : undefined;
73
+ };
74
+ const belongsToForeignConversation = (payload, conversationId) => {
75
+ const target = transportConversationId(payload);
76
+ return target !== undefined && target !== conversationId;
77
+ };
78
+ const authOf = (session) => {
79
+ const auth = {};
80
+ const [field, value] = session.kind === "bearer"
81
+ ? ["token", session.token]
82
+ : ["admin_key", session.adminKey];
83
+ Object.defineProperty(auth, field, {
84
+ configurable: false,
85
+ enumerable: true,
86
+ value,
87
+ writable: false,
88
+ });
89
+ return auth;
90
+ };
91
+ export const createSocketIoFactoryWith = (connect) => ({ getAuth, namespaceUrl, path }) => connect(namespaceUrl, {
92
+ autoConnect: false,
93
+ path,
94
+ reconnection: true,
95
+ transports: ["websocket"],
96
+ auth(callback) {
97
+ void getAuth()
98
+ .then(callback)
99
+ .catch(() => callback({}));
100
+ },
101
+ });
102
+ export const createSocketIoFactory = createSocketIoFactoryWith(io);
103
+ export class TFRobotSocketClient {
104
+ #active;
105
+ #disposed = false;
106
+ #establishmentAbort;
107
+ #factory;
108
+ #namespaceUrl;
109
+ #now;
110
+ #options;
111
+ #path;
112
+ #reconnectStatusLoader;
113
+ #runs = new Map();
114
+ constructor(options, reconnectStatusLoader) {
115
+ this.#options = options;
116
+ this.#factory = options.socketFactory ?? createSocketIoFactory;
117
+ this.#path = options.socketPath ?? "/socket.io";
118
+ this.#now = options.now ?? Date.now;
119
+ this.#namespaceUrl =
120
+ options.socketNamespaceUrl ?? this.#defaultNamespaceUrl(options.baseUrl);
121
+ this.#reconnectStatusLoader = reconnectStatusLoader;
122
+ }
123
+ async subscribe(conversationId, options, observer) {
124
+ if (this.#disposed) {
125
+ return {
126
+ ok: false,
127
+ error: this.#error("conflict", "TFRobot Gateway is disposed", false, conversationId),
128
+ };
129
+ }
130
+ if (isGatewayDeadlineExceeded(options, this.#now())) {
131
+ return {
132
+ ok: false,
133
+ error: createGatewayDeadlineExceededError(conversationId),
134
+ };
135
+ }
136
+ this.#deactivateCurrent();
137
+ const establishmentAbort = new AbortController();
138
+ this.#establishmentAbort = establishmentAbort;
139
+ const authOutcome = await awaitBounded(() => this.#getSocketAuth(conversationId, "connect"), {
140
+ deadlineAt: options.deadlineAt,
141
+ now: this.#now,
142
+ signal: establishmentAbort.signal,
143
+ });
144
+ if (this.#establishmentAbort === establishmentAbort) {
145
+ this.#establishmentAbort = undefined;
146
+ }
147
+ switch (authOutcome.kind) {
148
+ case "aborted": {
149
+ return {
150
+ ok: false,
151
+ error: this.#error("conflict", this.#disposed
152
+ ? "TFRobot Gateway was disposed during subscription"
153
+ : "TFRobot subscription was replaced during authentication", false, conversationId),
154
+ };
155
+ }
156
+ case "deadline": {
157
+ return {
158
+ ok: false,
159
+ error: createGatewayDeadlineExceededError(conversationId),
160
+ };
161
+ }
162
+ case "error": {
163
+ return {
164
+ ok: false,
165
+ error: this.#error("authentication", authOutcome.reason instanceof Error
166
+ ? authOutcome.reason.message
167
+ : "Unable to obtain a TFRobot Socket session", true, conversationId),
168
+ };
169
+ }
170
+ case "value": {
171
+ break;
172
+ }
173
+ }
174
+ if (this.#disposed || establishmentAbort.signal.aborted) {
175
+ return {
176
+ ok: false,
177
+ error: this.#error("conflict", "TFRobot Gateway was disposed during subscription", false, conversationId),
178
+ };
179
+ }
180
+ let firstAuth = authOutcome.value;
181
+ let authenticationFailure;
182
+ const connectionAbort = new AbortController();
183
+ let socket;
184
+ try {
185
+ socket = this.#factory({
186
+ namespaceUrl: this.#namespaceUrl,
187
+ path: this.#path,
188
+ getAuth: async () => {
189
+ if (connectionAbort.signal.aborted) {
190
+ throw new Error("TFRobot Socket session refresh was cancelled");
191
+ }
192
+ if (firstAuth !== undefined) {
193
+ const auth = firstAuth;
194
+ firstAuth = undefined;
195
+ return auth;
196
+ }
197
+ const reconnectAuth = await awaitBounded(() => this.#getSocketAuth(conversationId, "reconnect"), {
198
+ deadlineAt: this.#now() + RECONNECT_AUTH_TIMEOUT_MS,
199
+ now: this.#now,
200
+ signal: connectionAbort.signal,
201
+ });
202
+ if (reconnectAuth.kind === "value")
203
+ return reconnectAuth.value;
204
+ const reason = reconnectAuth.kind === "error"
205
+ ? reconnectAuth.reason
206
+ : new Error(reconnectAuth.kind === "deadline"
207
+ ? "TFRobot Socket session refresh exceeded its deadline"
208
+ : "TFRobot Socket session refresh was cancelled");
209
+ authenticationFailure = reason;
210
+ throw reason;
211
+ },
212
+ });
213
+ }
214
+ catch (reason) {
215
+ connectionAbort.abort();
216
+ return {
217
+ ok: false,
218
+ error: this.#transportError(reason, "Unable to create the TFRobot Socket transport", conversationId),
219
+ };
220
+ }
221
+ const listeners = new Map();
222
+ let setupFailure;
223
+ const add = (eventName, listener) => {
224
+ if (setupFailure !== undefined)
225
+ return;
226
+ try {
227
+ socket.on(eventName, listener);
228
+ listeners.set(eventName, listener);
229
+ }
230
+ catch (reason) {
231
+ setupFailure = { reason };
232
+ }
233
+ };
234
+ const diagnose = (error) => {
235
+ try {
236
+ void Promise.resolve(this.#options.onDiagnostic?.(error)).catch(() => undefined);
237
+ }
238
+ catch {
239
+ // Diagnostics are observational and cannot break the chat stream.
240
+ }
241
+ };
242
+ const report = (error) => {
243
+ if (!active.active)
244
+ return;
245
+ try {
246
+ observer.error?.(error);
247
+ }
248
+ catch {
249
+ // Host observers are isolated from the transport listener.
250
+ }
251
+ diagnose(error);
252
+ };
253
+ const next = (update) => {
254
+ if (!active.active)
255
+ return;
256
+ const updateConversationId = update.kind === "snapshot.replace"
257
+ ? update.snapshot.conversation.id
258
+ : update.kind === "conversation.upsert"
259
+ ? update.conversation.id
260
+ : update.conversationId;
261
+ if (updateConversationId !== undefined &&
262
+ updateConversationId !== conversationId) {
263
+ return;
264
+ }
265
+ try {
266
+ observer.next(update);
267
+ }
268
+ catch {
269
+ diagnose(this.#error("unknown", "TFRobot Gateway observer rejected an update", false, conversationId));
270
+ }
271
+ };
272
+ const mapAndNext = (invalidMessage, map) => {
273
+ let update;
274
+ try {
275
+ update = map();
276
+ }
277
+ catch {
278
+ report(this.#error("validation", invalidMessage, false, conversationId));
279
+ return;
280
+ }
281
+ next(update);
282
+ };
283
+ let settleEstablishment;
284
+ let connectedOnce = false;
285
+ add("connect", () => {
286
+ if (!active.active)
287
+ return;
288
+ if (settleEstablishment !== undefined &&
289
+ isGatewayDeadlineExceeded(options, this.#now())) {
290
+ settleEstablishment({
291
+ ok: false,
292
+ error: createGatewayDeadlineExceededError(conversationId),
293
+ });
294
+ return;
295
+ }
296
+ const reconnect = connectedOnce;
297
+ connectedOnce = true;
298
+ const recoveryRevision = ++active.recoveryRevision;
299
+ try {
300
+ socket.emit("join_conversation", {
301
+ conversation_id: conversationId,
302
+ });
303
+ }
304
+ catch (reason) {
305
+ const error = this.#transportError(reason, "Unable to join the TFRobot conversation", conversationId);
306
+ if (settleEstablishment !== undefined) {
307
+ settleEstablishment({ ok: false, error });
308
+ }
309
+ else {
310
+ report(error);
311
+ active.active = false;
312
+ active.cleanup();
313
+ if (this.#active === active)
314
+ this.#active = undefined;
315
+ }
316
+ return;
317
+ }
318
+ if (settleEstablishment !== undefined &&
319
+ isGatewayDeadlineExceeded(options, this.#now())) {
320
+ settleEstablishment({
321
+ ok: false,
322
+ error: createGatewayDeadlineExceededError(conversationId),
323
+ });
324
+ return;
325
+ }
326
+ settleEstablishment?.({
327
+ ok: true,
328
+ value: {
329
+ dispose: () => {
330
+ if (!active.active)
331
+ return;
332
+ active.active = false;
333
+ active.cleanup();
334
+ if (this.#active === active)
335
+ this.#active = undefined;
336
+ },
337
+ },
338
+ });
339
+ if (reconnect) {
340
+ void this.#reconcileRunAfterReconnect(active, conversationId, recoveryRevision, next, report).catch(() => {
341
+ report(this.#error("unknown", "TFRobot reconnect reconciliation failed", true, conversationId));
342
+ });
343
+ }
344
+ });
345
+ add("chat_message", (payload) => {
346
+ if (belongsToForeignConversation(payload, conversationId))
347
+ return;
348
+ const parsed = messageDtoSchema.safeParse(payload);
349
+ if (!parsed.success) {
350
+ report(this.#error("validation", "Invalid TFRobot chat_message payload", false, conversationId));
351
+ return;
352
+ }
353
+ mapAndNext("TFRobot chat_message could not be normalized", () => mapMessageUpdate(parsed.data));
354
+ });
355
+ add("chat_event", (payload) => {
356
+ if (belongsToForeignConversation(payload, conversationId))
357
+ return;
358
+ const parsed = eventDtoSchema.safeParse(payload);
359
+ if (!parsed.success) {
360
+ report(this.#error("validation", "Invalid TFRobot chat_event payload", false, conversationId));
361
+ return;
362
+ }
363
+ mapAndNext("TFRobot chat_event could not be normalized", () => mapEventUpdate(parsed.data));
364
+ });
365
+ add("conversation_state_changed", (payload) => {
366
+ if (belongsToForeignConversation(payload, conversationId))
367
+ return;
368
+ const parsed = stateChangedDtoSchema.safeParse(payload);
369
+ if (!parsed.success) {
370
+ report(this.#error("validation", "Invalid TFRobot conversation_state_changed payload", false, conversationId));
371
+ return;
372
+ }
373
+ const targetConversationId = String(parsed.data.conversationId);
374
+ if (targetConversationId !== conversationId)
375
+ return;
376
+ active.recoveryRevision += 1;
377
+ mapAndNext("TFRobot run state could not be normalized", () => {
378
+ const run = mapRun(targetConversationId, {
379
+ working: parsed.data.state === "working",
380
+ taskId: parsed.data.taskId,
381
+ });
382
+ this.#runs.set(targetConversationId, run);
383
+ return chatUpdateSchema.parse({
384
+ kind: "run.replace",
385
+ conversationId: targetConversationId,
386
+ run,
387
+ });
388
+ });
389
+ });
390
+ add("chat_error", (payload) => {
391
+ if (belongsToForeignConversation(payload, conversationId))
392
+ return;
393
+ const parsed = chatErrorEventDtoSchema.safeParse(payload);
394
+ if (!parsed.success) {
395
+ report(this.#error("validation", "Invalid TFRobot chat_error payload", false, conversationId));
396
+ return;
397
+ }
398
+ const targetConversationId = String(parsed.data.conversationId);
399
+ if (targetConversationId !== conversationId)
400
+ return;
401
+ report(this.#error("server", typeof parsed.data.error === "string"
402
+ ? parsed.data.error
403
+ : "TFRobot run failed", false, targetConversationId, parsed.data));
404
+ });
405
+ add("error", (payload) => {
406
+ const parsed = socketProtocolErrorDtoSchema.safeParse(payload);
407
+ report(this.#error("validation", parsed.success && typeof parsed.data.message === "string"
408
+ ? parsed.data.message
409
+ : "TFRobot Socket protocol error", false, conversationId));
410
+ });
411
+ add("connect_error", (reason) => {
412
+ const injectedError = chatErrorSchema.safeParse(reason);
413
+ const rejectionCode = socketAuthRejectionCode(reason);
414
+ const error = authenticationFailure !== undefined
415
+ ? this.#error("authentication", authenticationFailure instanceof Error
416
+ ? authenticationFailure.message
417
+ : "Unable to refresh the TFRobot Socket session", true, conversationId)
418
+ : injectedError.success
419
+ ? injectedError.data
420
+ : rejectionCode !== undefined
421
+ ? this.#error(rejectionCode, reason instanceof Error
422
+ ? reason.message
423
+ : "TFRobot Socket rejected the session", false, conversationId)
424
+ : this.#error("network", reason instanceof Error
425
+ ? reason.message
426
+ : "TFRobot Socket connection failed", true, conversationId);
427
+ authenticationFailure = undefined;
428
+ if (error.code === "authentication" || error.code === "authorization") {
429
+ void this.#invalidateSession(error, rejectionCode === undefined ? undefined : "rejected");
430
+ }
431
+ if (settleEstablishment !== undefined) {
432
+ settleEstablishment({ ok: false, error });
433
+ return;
434
+ }
435
+ report(error);
436
+ });
437
+ add("disconnect", (reason) => {
438
+ if (reason === "io client disconnect")
439
+ return;
440
+ active.recoveryRevision += 1;
441
+ const injectedError = chatErrorSchema.safeParse(reason);
442
+ report(injectedError.success
443
+ ? injectedError.data
444
+ : this.#error("network", `TFRobot Socket disconnected: ${String(reason)}`, true, conversationId));
445
+ });
446
+ const anyListener = (eventName, payload) => {
447
+ if (KNOWN_EVENTS.has(eventName))
448
+ return;
449
+ if (belongsToForeignConversation(payload, conversationId))
450
+ return;
451
+ mapAndNext("Unknown TFRobot Socket event could not be normalized", () => mapUnknownSocketEvent(eventName, payload, conversationId, this.#now()));
452
+ };
453
+ if (setupFailure === undefined) {
454
+ try {
455
+ socket.onAny?.(anyListener);
456
+ }
457
+ catch (reason) {
458
+ setupFailure = { reason };
459
+ }
460
+ }
461
+ const cleanup = () => {
462
+ connectionAbort.abort();
463
+ for (const [eventName, listener] of listeners) {
464
+ try {
465
+ socket.off(eventName, listener);
466
+ }
467
+ catch {
468
+ // Continue releasing every transport resource best-effort.
469
+ }
470
+ }
471
+ try {
472
+ socket.offAny?.(anyListener);
473
+ }
474
+ catch {
475
+ // Continue to the transport disconnect.
476
+ }
477
+ try {
478
+ socket.disconnect();
479
+ }
480
+ catch {
481
+ // Adapter ownership is cleared even if an injected transport misbehaves.
482
+ }
483
+ };
484
+ if (setupFailure !== undefined) {
485
+ cleanup();
486
+ return {
487
+ ok: false,
488
+ error: this.#transportError(setupFailure.reason, "Unable to configure the TFRobot Socket transport", conversationId),
489
+ };
490
+ }
491
+ const active = {
492
+ active: true,
493
+ cancelEstablishment: () => {
494
+ settleEstablishment?.({
495
+ ok: false,
496
+ error: this.#error("conflict", "TFRobot Gateway was disposed during subscription", false, conversationId),
497
+ });
498
+ },
499
+ cleanup,
500
+ conversationId,
501
+ observer,
502
+ recoveryRevision: 0,
503
+ socket,
504
+ };
505
+ this.#active = active;
506
+ return new Promise((resolve) => {
507
+ let settled = false;
508
+ const settle = (result) => {
509
+ if (settled)
510
+ return;
511
+ settled = true;
512
+ settleEstablishment = undefined;
513
+ clearTimeout(timeout);
514
+ if (!result.ok) {
515
+ active.active = false;
516
+ active.cleanup();
517
+ if (this.#active === active)
518
+ this.#active = undefined;
519
+ }
520
+ resolve(result);
521
+ };
522
+ settleEstablishment = settle;
523
+ const timeout = setTimeout(() => {
524
+ settle({
525
+ ok: false,
526
+ error: createGatewayDeadlineExceededError(conversationId),
527
+ });
528
+ }, Math.min(MAX_TIMER_DELAY, Math.max(0, options.deadlineAt - this.#now())));
529
+ if (isGatewayDeadlineExceeded(options, this.#now())) {
530
+ settle({
531
+ ok: false,
532
+ error: createGatewayDeadlineExceededError(conversationId),
533
+ });
534
+ return;
535
+ }
536
+ try {
537
+ socket.connect();
538
+ }
539
+ catch (reason) {
540
+ settle({
541
+ ok: false,
542
+ error: this.#transportError(reason, "Unable to connect the TFRobot Socket transport", conversationId),
543
+ });
544
+ return;
545
+ }
546
+ if (isGatewayDeadlineExceeded(options, this.#now())) {
547
+ settle({
548
+ ok: false,
549
+ error: createGatewayDeadlineExceededError(conversationId),
550
+ });
551
+ }
552
+ });
553
+ }
554
+ dispose() {
555
+ if (this.#disposed)
556
+ return;
557
+ this.#disposed = true;
558
+ this.#deactivateCurrent();
559
+ this.#runs.clear();
560
+ }
561
+ rememberRun(conversationId, run) {
562
+ if (this.#disposed)
563
+ return;
564
+ this.#runs.set(conversationId, run);
565
+ }
566
+ #deactivateCurrent() {
567
+ this.#establishmentAbort?.abort();
568
+ this.#establishmentAbort = undefined;
569
+ const current = this.#active;
570
+ if (current === undefined)
571
+ return;
572
+ current.cancelEstablishment();
573
+ current.active = false;
574
+ current.cleanup();
575
+ this.#active = undefined;
576
+ }
577
+ #defaultNamespaceUrl(baseUrl) {
578
+ const url = new URL(baseUrl);
579
+ url.pathname = "/chat";
580
+ url.search = "";
581
+ url.hash = "";
582
+ return url.toString().replace(/\/$/u, "");
583
+ }
584
+ async #getSocketAuth(conversationId, purpose) {
585
+ const session = await this.#options.sessionProvider.getSession({
586
+ purpose,
587
+ operation: "subscribe",
588
+ conversationId,
589
+ });
590
+ if (!isValidTFRobotSession(session)) {
591
+ throw new TypeError("SessionProvider returned invalid TFRobot credentials");
592
+ }
593
+ return authOf(session);
594
+ }
595
+ async #reconcileRunAfterReconnect(active, conversationId, recoveryRevision, next, report) {
596
+ const result = await this.#reconnectStatusLoader(conversationId);
597
+ if (!active.active ||
598
+ this.#active !== active ||
599
+ !active.socket.connected ||
600
+ active.recoveryRevision !== recoveryRevision) {
601
+ return;
602
+ }
603
+ if (!result.ok) {
604
+ report(result.error);
605
+ return;
606
+ }
607
+ try {
608
+ const run = mapRun(conversationId, result.value);
609
+ const previous = this.#runs.get(conversationId);
610
+ this.#runs.set(conversationId, run);
611
+ if (sameRun(previous, run))
612
+ return;
613
+ next(chatUpdateSchema.parse({
614
+ kind: "run.replace",
615
+ conversationId,
616
+ run,
617
+ }));
618
+ }
619
+ catch {
620
+ report(this.#error("validation", "Reconnected TFRobot run status could not be normalized", false, conversationId));
621
+ }
622
+ }
623
+ async #invalidateSession(error, reason) {
624
+ try {
625
+ await this.#options.sessionProvider.onSessionInvalid?.({
626
+ reason: reason ?? (error.code === "authorization" ? "forbidden" : "expired"),
627
+ error,
628
+ });
629
+ }
630
+ catch {
631
+ // Host refresh/login failures must not replace the Socket error.
632
+ }
633
+ }
634
+ #transportError(reason, fallback, conversationId) {
635
+ return this.#error("network", reason instanceof Error ? reason.message : fallback, true, conversationId);
636
+ }
637
+ #error(code, message, retryable, conversationId, details) {
638
+ let safeDetails;
639
+ try {
640
+ safeDetails = details === undefined ? undefined : sanitizeRaw(details);
641
+ }
642
+ catch {
643
+ safeDetails = undefined;
644
+ }
645
+ return chatErrorSchema.parse({
646
+ code,
647
+ message: sanitizeDiagnosticText(message),
648
+ retryable,
649
+ conversationId,
650
+ ...(safeDetails === undefined ? {} : { details: safeDetails }),
651
+ });
652
+ }
653
+ }
654
+ //# sourceMappingURL=socket.js.map