@arcote.tech/arc-chat 0.7.12 → 0.7.14

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-chat",
3
3
  "type": "module",
4
- "version": "0.7.12",
4
+ "version": "0.7.14",
5
5
  "private": false,
6
6
  "description": "Chat module with AI integration for Arc framework",
7
7
  "main": "./src/index.ts",
@@ -10,12 +10,12 @@
10
10
  "type-check": "tsc --noEmit"
11
11
  },
12
12
  "peerDependencies": {
13
- "@arcote.tech/arc": "^0.7.12",
14
- "@arcote.tech/arc-ai": "^0.7.12",
15
- "@arcote.tech/arc-ai-voice": "^0.7.12",
16
- "@arcote.tech/arc-auth": "^0.7.12",
17
- "@arcote.tech/arc-ds": "^0.7.12",
18
- "@arcote.tech/platform": "^0.7.12",
13
+ "@arcote.tech/arc": "^0.7.14",
14
+ "@arcote.tech/arc-ai": "^0.7.14",
15
+ "@arcote.tech/arc-ai-voice": "^0.7.14",
16
+ "@arcote.tech/arc-auth": "^0.7.14",
17
+ "@arcote.tech/arc-ds": "^0.7.14",
18
+ "@arcote.tech/platform": "^0.7.14",
19
19
  "lucide-react": ">=0.400.0",
20
20
  "react": ">=18.0.0",
21
21
  "typescript": "^5.0.0"
@@ -100,6 +100,13 @@ export const createMessageAggregate = <
100
100
  * otwiera SSE i streaming jest visible od pierwszego chunka.
101
101
  */
102
102
  assistantMessageId: messageId.optional(),
103
+ /**
104
+ * JSON-encoded `string[]` z fileId'ami arc-files attachmentów do tej
105
+ * konkretnej wiadomości. Listener AI generation ładuje `ArcFile`
106
+ * records i przekazuje je w `CompletionRequest.files`. Nie zapisujemy
107
+ * w aggregate fields — flow jest read-once przez listener z event.
108
+ */
109
+ attachmentsJson: string().optional(),
103
110
  },
104
111
  async (ctx, event) => {
105
112
  const p = event.payload;
@@ -253,6 +260,12 @@ export const createMessageAggregate = <
253
260
  scopeId,
254
261
  content: string().minLength(1),
255
262
  model: string(),
263
+ /**
264
+ * Opcjonalna JSON-encoded `string[]` z fileId'ami arc-files
265
+ * dołączonymi do user message. Listener AI generation ładuje
266
+ * pełne records po stronie serwera.
267
+ */
268
+ attachmentsJson: string().optional(),
256
269
  }).handle(
257
270
  ONLY_SERVER &&
258
271
  (async (ctx, params) => {
@@ -282,6 +295,7 @@ export const createMessageAggregate = <
282
295
  content: params.content,
283
296
  model: params.model,
284
297
  assistantMessageId: assistantMsgId,
298
+ attachmentsJson: params.attachmentsJson,
285
299
  });
286
300
 
287
301
  return { messageId: userMsgId, sessionId, assistantMessageId: assistantMsgId };
@@ -85,6 +85,17 @@ export interface ArcChatData {
85
85
  toolChoice: "auto" | "required" | { type: "function"; name: string };
86
86
  alias: string | null;
87
87
  billTo: BillToFn | null;
88
+ /**
89
+ * `arc-files` aggregate dla file attachmentów (PDF/DOCX/...) do user
90
+ * messages. Gdy ustawione, listener AI generation:
91
+ * - ładuje `ArcFile` records po fileId'ach z `attachmentsJson` user msg,
92
+ * - przekazuje je w `CompletionRequest.files`,
93
+ * - po complete cachuje `boundProviderFiles` w aggregate'cie (lazy
94
+ * upload do OpenAI Files API → file_id zapisany w `providerFileIdsJson`).
95
+ *
96
+ * Consumer wstrzykuje przez `.attachFiles({ File })`.
97
+ */
98
+ attachFiles: { File: any } | null;
88
99
  }
89
100
 
90
101
  const defaultChatData = {
@@ -101,6 +112,7 @@ const defaultChatData = {
101
112
  toolChoice: "auto" as const,
102
113
  alias: null,
103
114
  billTo: null,
115
+ attachFiles: null,
104
116
  } as const satisfies ArcChatData;
105
117
 
106
118
  type DefaultChatData = typeof defaultChatData;
@@ -237,6 +249,22 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
237
249
  } as any);
238
250
  }
239
251
 
252
+ /**
253
+ * Włącza obsługę file attachments dla user messages — listener AI
254
+ * generation będzie ładował `ArcFile` records, przekazywał je w
255
+ * `CompletionRequest.files`, i cachował lazy-uploadowane `file_id`'y
256
+ * provider'ów (OpenAI Files API).
257
+ *
258
+ * const files = arcFiles({ name: "ndt", scopeId: workspaceId, ... });
259
+ * chat("identity").attachFiles({ File: files.File }).build();
260
+ */
261
+ attachFiles(config: { File: any }): ArcChat<Data> {
262
+ return new ArcChat<Data>({
263
+ ...this.data,
264
+ attachFiles: config,
265
+ } as any);
266
+ }
267
+
240
268
  build() {
241
269
  const {
242
270
  name,
@@ -252,6 +280,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
252
280
  toolChoice,
253
281
  alias: aliasOverride,
254
282
  billTo,
283
+ attachFiles,
255
284
  } = this.data;
256
285
 
257
286
  if (!name) throw new Error("ArcChat: name is required");
@@ -319,6 +348,54 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
319
348
  }
320
349
  }
321
350
 
351
+ // Attachments bridge — gdy chat woła `.attachFiles({File})`, dodajemy
352
+ // File aggregate do query/mutation deps i budujemy callbacks używane
353
+ // przez listener do: (a) load ArcFile records dla user attachmentów,
354
+ // (b) cache providerFileId po lazy upload.
355
+ let attachmentsBridge: any;
356
+ if (attachFiles) {
357
+ const FileAgg = attachFiles.File;
358
+ if (!allQueryElements.includes(FileAgg)) allQueryElements.push(FileAgg);
359
+ if (!allMutationElements.includes(FileAgg))
360
+ allMutationElements.push(FileAgg);
361
+ attachmentsBridge = {
362
+ loadAttachments: async (ctx: any, fileIds: string[]) => {
363
+ const refs: any[] = [];
364
+ for (const fileId of fileIds) {
365
+ const rec = await ctx
366
+ .query(FileAgg)
367
+ .getById({ _id: fileId });
368
+ if (!rec) continue;
369
+ let providerFileIds: Record<string, string> = {};
370
+ if (typeof rec.providerFileIdsJson === "string") {
371
+ try {
372
+ const parsed = JSON.parse(rec.providerFileIdsJson);
373
+ if (parsed && typeof parsed === "object") providerFileIds = parsed;
374
+ } catch {}
375
+ }
376
+ refs.push({
377
+ fileId,
378
+ name: rec.name,
379
+ mime: rec.mime,
380
+ s3Key: rec.s3Key,
381
+ providerFileIds,
382
+ });
383
+ }
384
+ return refs;
385
+ },
386
+ bindProviderFileId: async (
387
+ ctx: any,
388
+ fileId: string,
389
+ provider: string,
390
+ providerFileId: string,
391
+ ) => {
392
+ await ctx
393
+ .mutate(FileAgg)
394
+ .bindProviderFileId({ _id: fileId, provider, providerFileId });
395
+ },
396
+ };
397
+ }
398
+
322
399
  const listenerConfig = {
323
400
  name,
324
401
  messageElement: Message,
@@ -333,6 +410,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
333
410
  alias: aliasOverride ?? name,
334
411
  recordUsage: aiConfig.recordUsage,
335
412
  billTo: billTo ?? undefined,
413
+ attachments: attachmentsBridge,
336
414
  };
337
415
 
338
416
  const aiListener = createAiGenerationListener(listenerConfig);
@@ -1,8 +1,10 @@
1
1
  /// <reference path="../arc.d.ts" />
2
2
  import { listener, type ArcContextElement, type ArcFunction } from "@arcote.tech/arc";
3
3
  import type {
4
+ ArcFileRef,
4
5
  ArcToolAny,
5
6
  AssistantContentBlock,
7
+ BoundProviderFile,
6
8
  Conversation,
7
9
  ConversationTurn,
8
10
  LLMProvider,
@@ -59,6 +61,29 @@ export interface AiGenerationListenerConfig {
59
61
  * listener can treat the pair as always-present in the call site.
60
62
  */
61
63
  billTo?: (tokenParams: Record<string, any>) => string;
64
+ /**
65
+ * Wiązanie z fragmentem `arc-files` — wstrzykiwany przez chat-builder gdy
66
+ * consumer woła `.attachFiles({ File })`.
67
+ *
68
+ * - `loadAttachments(fileIds)` — ładuje `ArcFile` records po fileId'ach
69
+ * (dla `request.files`); zwracamy ArcFileRef z aktualnymi
70
+ * `providerFileIds` z DB.
71
+ * - `bindProviderFileId(fileId, provider, providerFileId)` — wywoływane
72
+ * dla każdego `BoundProviderFile` zwróconego przez adapter (cache
73
+ * lazy uploadu — przy następnej generacji pomijamy upload).
74
+ */
75
+ attachments?: {
76
+ loadAttachments(
77
+ ctx: any,
78
+ fileIds: string[],
79
+ ): Promise<ArcFileRef[]>;
80
+ bindProviderFileId(
81
+ ctx: any,
82
+ fileId: string,
83
+ provider: string,
84
+ providerFileId: string,
85
+ ): Promise<void>;
86
+ };
62
87
  }
63
88
 
64
89
  // ─── History reconstruction ─────────────────────────────────────
@@ -233,6 +258,11 @@ interface RunLoopConfig {
233
258
  recordUsage?: AiGenerationListenerConfig["recordUsage"];
234
259
  /** Token-params → scopeId mapper from chat-builder `.billTo(...)`. */
235
260
  billTo?: AiGenerationListenerConfig["billTo"];
261
+ /** Attachments bridge — see `AiGenerationListenerConfig.attachments`. */
262
+ attachments?: AiGenerationListenerConfig["attachments"];
263
+ /** ArcFile records dla attachmentów ostatniego user msg (resolved przed
264
+ * generation start; reused per loop iteration). */
265
+ initialAttachments?: ArcFileRef[];
236
266
  }
237
267
 
238
268
  async function runGenerationLoop(config: RunLoopConfig) {
@@ -310,6 +340,13 @@ async function runGenerationLoop(config: RunLoopConfig) {
310
340
  // robi nic.
311
341
  startStream(currentTurnId!);
312
342
 
343
+ // Files lecą TYLKO w pierwszej iteracji. Provider z continuation
344
+ // (OpenAI Responses) trzyma context server-side; provider w full
345
+ // mode (Claude/Gemini) ignoruje files z warningiem — i tak nie
346
+ // ma sensu duplikować attachmentów w każdym tool_result roundzie.
347
+ const filesForRequest =
348
+ executionCount === 0 ? config.initialAttachments : undefined;
349
+
313
350
  const result = await provider.streamComplete(
314
351
  {
315
352
  model,
@@ -320,6 +357,9 @@ async function runGenerationLoop(config: RunLoopConfig) {
320
357
  // Skraca time-to-first-token dla gpt-5 / o-series — pomija reasoning
321
358
  // step. Adaptery bez wsparcia ignorują.
322
359
  reasoningEffort: "minimal",
360
+ ...(filesForRequest && filesForRequest.length > 0
361
+ ? { files: filesForRequest }
362
+ : {}),
323
363
  },
324
364
  (chunk) => {
325
365
  if (chunk.type === "text_delta" && chunk.textDelta) {
@@ -356,6 +396,32 @@ async function runGenerationLoop(config: RunLoopConfig) {
356
396
  },
357
397
  );
358
398
 
399
+ // Cache lazy-uploaded provider file IDs w ArcFile aggregate — żeby
400
+ // kolejne generacje (i odnowiona historia) pomijały re-upload.
401
+ if (
402
+ config.attachments &&
403
+ result.boundProviderFiles &&
404
+ result.boundProviderFiles.length > 0
405
+ ) {
406
+ for (const bound of result.boundProviderFiles) {
407
+ try {
408
+ await config.attachments.bindProviderFileId(
409
+ ctx,
410
+ bound.fileId,
411
+ bound.provider,
412
+ bound.providerFileId,
413
+ );
414
+ } catch (err) {
415
+ // Best-effort cache — failure tu nie psuje turn'u, tylko
416
+ // następny request uploadowi plik ponownie.
417
+ console.warn(
418
+ "[arc-chat] attachments.bindProviderFileId failed:",
419
+ err,
420
+ );
421
+ }
422
+ }
423
+ }
424
+
359
425
  // Append to local history so the next iteration sees this turn.
360
426
  const assistantTurn: ConversationTurn = {
361
427
  role: "assistant",
@@ -559,6 +625,18 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
559
625
  const provider = resolveProvider(model, scopeId);
560
626
  if (!provider) return;
561
627
 
628
+ // Otwórz in-memory stream PRZED pierwszym awaitem (getByScope niżej +
629
+ // buildInstructions w pętli). Async listenery w arc startują
630
+ // synchronicznie w trakcie emit i suspendują na 1. await, więc wpis
631
+ // powstaje ZANIM mutacja zwróci → klient subskrybujący zaraz po mutacji
632
+ // nie wyprzedzi startStream (to był główny powód 410 ~1/5, szczególnie
633
+ // dla stage'ów z wolnym buildInstructions). Idempotent; runGenerationLoop
634
+ // woła ponownie dla iteracji 2+ (multi-turn).
635
+ const preAssistantId = (
636
+ event.payload as { assistantMessageId?: string }
637
+ ).assistantMessageId;
638
+ if (preAssistantId) startStream(preAssistantId);
639
+
562
640
  const dbMessages = await ctx
563
641
  .query(messageElement)
564
642
  .getByScope({ scopeId });
@@ -570,6 +648,14 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
570
648
  const newTurnsStartIdx = history.length;
571
649
  history.push({ role: "user", content: userContent });
572
650
 
651
+ // Załaduj ArcFile records dla attachmentów ostatniego user msg
652
+ // (jeśli consumer wired `attachments` bridge przez `.attachFiles({File})`).
653
+ const initialAttachments = await resolveAttachments(
654
+ ctx,
655
+ config.attachments,
656
+ (event.payload as { attachmentsJson?: string }).attachmentsJson,
657
+ );
658
+
573
659
  await runGenerationLoop({
574
660
  ctx,
575
661
  messageElement,
@@ -588,6 +674,8 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
588
674
  alias: config.alias,
589
675
  recordUsage: config.recordUsage,
590
676
  billTo: config.billTo,
677
+ attachments: config.attachments,
678
+ initialAttachments,
591
679
  preCreatedAssistantMessageId: (
592
680
  event.payload as { assistantMessageId?: string }
593
681
  ).assistantMessageId,
@@ -595,6 +683,36 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
595
683
  });
596
684
  }
597
685
 
686
+ /**
687
+ * Parsuje `attachmentsJson` (string[] fileIds) i deleguje do
688
+ * `attachments.loadAttachments(...)`. Brak attachments bridge ALBO brak
689
+ * JSON ALBO pusta lista → zwraca undefined (request bez `files`).
690
+ */
691
+ async function resolveAttachments(
692
+ ctx: any,
693
+ attachments: AiGenerationListenerConfig["attachments"],
694
+ attachmentsJson: string | undefined,
695
+ ): Promise<ArcFileRef[] | undefined> {
696
+ if (!attachments || !attachmentsJson) return undefined;
697
+ let fileIds: string[] = [];
698
+ try {
699
+ const parsed = JSON.parse(attachmentsJson);
700
+ if (Array.isArray(parsed)) {
701
+ fileIds = parsed.filter((x): x is string => typeof x === "string");
702
+ }
703
+ } catch {
704
+ return undefined;
705
+ }
706
+ if (fileIds.length === 0) return undefined;
707
+ try {
708
+ const refs = await attachments.loadAttachments(ctx, fileIds);
709
+ return refs.length > 0 ? refs : undefined;
710
+ } catch (err) {
711
+ console.warn("[arc-chat] attachments.loadAttachments failed:", err);
712
+ return undefined;
713
+ }
714
+ }
715
+
598
716
  // ─── Listener B: userResponded → AI resume ──────────────────────
599
717
 
600
718
  export function createAiResumeListener(config: AiGenerationListenerConfig) {
@@ -627,6 +745,14 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
627
745
  .handle(async (ctx, event) => {
628
746
  const { sessionId, scopeId, toolCallId } = event.payload;
629
747
 
748
+ // Otwórz in-memory stream przed 1. awaitem — patrz listener generation.
749
+ // (provider sprawdzany niżej po await; rzadki misconfig sprząta
750
+ // MAX_STREAM_MS w stream-registry.)
751
+ const preAssistantId = (
752
+ event.payload as { assistantMessageId?: string }
753
+ ).assistantMessageId;
754
+ if (preAssistantId) startStream(preAssistantId);
755
+
630
756
  const dbMessages = await ctx
631
757
  .query(messageElement)
632
758
  .getByScope({ scopeId });
@@ -671,6 +797,7 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
671
797
  alias: config.alias,
672
798
  recordUsage: config.recordUsage,
673
799
  billTo: config.billTo,
800
+ attachments: config.attachments,
674
801
  preCreatedAssistantMessageId: (
675
802
  event.payload as { assistantMessageId?: string }
676
803
  ).assistantMessageId,
@@ -723,6 +850,9 @@ export function createAiRetryListener(config: AiGenerationListenerConfig) {
723
850
  model: modelName,
724
851
  } = event.payload as any;
725
852
 
853
+ // Otwórz in-memory stream przed 1. awaitem — patrz listener generation.
854
+ if (assistantMsgId) startStream(assistantMsgId);
855
+
726
856
  const dbMessages = await ctx
727
857
  .query(messageElement)
728
858
  .getByScope({ scopeId });
@@ -554,21 +554,30 @@ export function createChatComponent(
554
554
 
555
555
  (async () => {
556
556
  try {
557
- const res = await fetch(
558
- `/route/chat/${chatName}/stream/${messageId}`,
559
- {
557
+ // 410 = brak in-memory streamu dla messageId. Serwer tworzy stream
558
+ // synchronicznie ze startem turnu (listener przed 1. awaitem), więc
559
+ // race "GET przed startStream" jest zamknięty — ale zostaje krótki
560
+ // residualny race i okno restartu serwera. Ponów kilka razy z
561
+ // backoffem zanim uznasz turn za przerwany: startStream / grace
562
+ // window zwykle dogania w tym czasie.
563
+ let res: Response | null = null;
564
+ const MAX_410_RETRIES = 4;
565
+ const RETRY_DELAY_MS = 300;
566
+ for (let attempt = 0; ; attempt++) {
567
+ res = await fetch(`/route/chat/${chatName}/stream/${messageId}`, {
560
568
  credentials: "include",
561
569
  signal: ctrl.signal,
562
570
  headers: { Accept: "text/event-stream" },
563
- },
564
- );
565
- if (res.status === 410) {
566
- // Stream nie istnieje proces zrestartował się mid-stream
567
- // (in-memory state utracony). Mark messageId jako interrupted,
568
- // klient pokaże retry UI.
569
- setInterruptedIds((prev) => new Set(prev).add(messageId));
570
- setIsStreaming(false);
571
- return;
571
+ });
572
+ if (res.status !== 410) break;
573
+ if (attempt >= MAX_410_RETRIES) {
574
+ // Naprawdę nieosiągalny (restart mid-stream / poza grace window).
575
+ setInterruptedIds((prev) => new Set(prev).add(messageId));
576
+ setIsStreaming(false);
577
+ return;
578
+ }
579
+ await new Promise<void>((r) => setTimeout(r, RETRY_DELAY_MS));
580
+ if (cancelled) return;
572
581
  }
573
582
  if (!res.ok) throw new Error(`Stream failed: ${res.status}`);
574
583
 
@@ -622,6 +631,9 @@ export function createChatComponent(
622
631
  scopeId,
623
632
  content,
624
633
  model: options.model,
634
+ ...(options.attachments && options.attachments.length > 0
635
+ ? { attachmentsJson: JSON.stringify(options.attachments) }
636
+ : {}),
625
637
  });
626
638
  // Reszta dzieje się przez auto-subscribe effect powyżej: mutacja
627
639
  // emit'uje `assistantTurnStarted` → DB query pushuje fresh assistant
@@ -33,6 +33,13 @@ interface MessageStream {
33
33
  >;
34
34
  subscribers: Set<ReadableStreamDefaultController<Uint8Array>>;
35
35
  keepAliveInterval?: ReturnType<typeof setInterval>;
36
+ /**
37
+ * Hard safety cap. `startStream` schedules it; `finalize` clears it. If a
38
+ * generation never calls `finalize()` (listener threw before the loop, no
39
+ * provider, hung stream) this evicts the entry so the registry can't grow
40
+ * unbounded — the only TTL on a *live* (non-finalized) stream.
41
+ */
42
+ maxLifetimeTimer?: ReturnType<typeof setTimeout>;
36
43
  finalized: boolean;
37
44
  finalEvent?: ChatStreamEvent;
38
45
  }
@@ -40,6 +47,9 @@ interface MessageStream {
40
47
  const streams = new Map<string, MessageStream>();
41
48
  const FINALIZE_GRACE_MS = 5_000;
42
49
  const KEEPALIVE_INTERVAL_MS = 5_000;
50
+ // Generous upper bound for a single turn's stream (one LLM response, even with
51
+ // tools). Far longer than any realistic generation; only trips on a leak.
52
+ const MAX_STREAM_MS = 10 * 60_000;
43
53
  const encoder = new TextEncoder();
44
54
 
45
55
  function encode(event: ChatStreamEvent): Uint8Array {
@@ -75,13 +85,40 @@ function ensureKeepAlive(s: MessageStream): void {
75
85
  */
76
86
  export function startStream(messageId: string): void {
77
87
  if (streams.has(messageId)) return;
78
- streams.set(messageId, {
88
+ const s: MessageStream = {
79
89
  messageId,
80
90
  currentBlocks: [],
81
91
  toolCallsById: new Map(),
82
92
  subscribers: new Set(),
83
93
  finalized: false,
84
- });
94
+ };
95
+ // Leak guard — see MAX_STREAM_MS. Cleared in finalize() on the normal path.
96
+ s.maxLifetimeTimer = setTimeout(() => evict(messageId), MAX_STREAM_MS);
97
+ streams.set(messageId, s);
98
+ }
99
+
100
+ /**
101
+ * Force-drop a stream that never finalized (safety net). Closes any subscribers
102
+ * and tears down timers. No-op if already gone.
103
+ */
104
+ function evict(messageId: string): void {
105
+ const s = streams.get(messageId);
106
+ if (!s) return;
107
+ if (s.keepAliveInterval) {
108
+ clearInterval(s.keepAliveInterval);
109
+ s.keepAliveInterval = undefined;
110
+ }
111
+ if (s.maxLifetimeTimer) {
112
+ clearTimeout(s.maxLifetimeTimer);
113
+ s.maxLifetimeTimer = undefined;
114
+ }
115
+ for (const ctrl of s.subscribers) {
116
+ try {
117
+ ctrl.close();
118
+ } catch {}
119
+ }
120
+ s.subscribers.clear();
121
+ streams.delete(messageId);
85
122
  }
86
123
 
87
124
  /**
@@ -266,6 +303,12 @@ export function finalize(
266
303
  clearInterval(s.keepAliveInterval);
267
304
  s.keepAliveInterval = undefined;
268
305
  }
306
+ // Normal teardown owns the lifecycle now — cancel the safety cap so it can't
307
+ // fire during/after the grace window.
308
+ if (s.maxLifetimeTimer) {
309
+ clearTimeout(s.maxLifetimeTimer);
310
+ s.maxLifetimeTimer = undefined;
311
+ }
269
312
 
270
313
  setTimeout(() => {
271
314
  streams.delete(messageId);