ai 7.0.100 → 7.0.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/index.d.ts +82 -20
  3. package/dist/index.js +2017 -330
  4. package/dist/index.js.map +1 -1
  5. package/dist/internal/index.js +12 -5
  6. package/dist/internal/index.js.map +1 -1
  7. package/docs/03-agents/07-workflow-agent.mdx +1 -1
  8. package/docs/03-ai-sdk-core/36-realtime.mdx +41 -18
  9. package/docs/04-ai-sdk-ui/21-transport.mdx +1 -1
  10. package/docs/07-reference/02-ai-sdk-ui/05-use-realtime.mdx +364 -48
  11. package/docs/07-reference/04-ai-sdk-workflow/02-workflow-chat-transport.mdx +4 -4
  12. package/docs/07-reference/05-ai-sdk-errors/index.mdx +131 -36
  13. package/package.json +12 -12
  14. package/src/generate-text/execute-tools-from-stream.ts +7 -0
  15. package/src/generate-text/stream-text.ts +3 -1
  16. package/src/prompt/create-tool-model-output.ts +16 -5
  17. package/src/realtime/__fixtures__/fake-live-websocket.ts +71 -0
  18. package/src/realtime/__fixtures__/fake-realtime.ts +36 -0
  19. package/src/realtime/__fixtures__/fake-webrtc.ts +133 -0
  20. package/src/realtime/browser-realtime-audio.ts +107 -10
  21. package/src/realtime/browser-realtime-live-websocket.ts +247 -0
  22. package/src/realtime/browser-realtime-transport.ts +235 -69
  23. package/src/realtime/browser-realtime-webrtc.ts +582 -0
  24. package/src/realtime/encode-realtime-frame.ts +33 -0
  25. package/src/realtime/index.ts +1 -0
  26. package/src/realtime/realtime-attempt.ts +45 -0
  27. package/src/realtime/realtime-command-tracker.ts +81 -0
  28. package/src/realtime/realtime-event-channel.ts +170 -0
  29. package/src/realtime/realtime-event-reducer.ts +3 -0
  30. package/src/realtime/realtime-session-state.ts +65 -0
  31. package/src/realtime/realtime-session.ts +768 -218
  32. package/src/realtime/realtime-types.ts +1 -1
  33. package/src/realtime/validate-realtime-setup.ts +37 -0
@@ -0,0 +1,582 @@
1
+ import { safeParseJSON } from '@ai-sdk/provider-utils';
2
+ import type {
3
+ RealtimeClientEvent,
4
+ RealtimeModel,
5
+ RealtimeServerEvent,
6
+ RealtimeSessionConfig,
7
+ } from '../types/realtime-model';
8
+ import { RealtimeEventChannel } from './realtime-event-channel';
9
+
10
+ const MAX_SESSION_ANSWER_BYTES = 1024 * 1024;
11
+
12
+ async function readSessionAnswer(response: Response): Promise<string> {
13
+ const reader = response.body?.getReader();
14
+ if (reader == null) throw new Error('Invalid realtime session answer');
15
+ const decoder = new TextDecoder();
16
+ let bytes = 0;
17
+ let text = '';
18
+ try {
19
+ if (
20
+ Number(response.headers.get('content-length')) > MAX_SESSION_ANSWER_BYTES
21
+ )
22
+ throw new Error('Realtime session answer exceeds the 1 MiB limit');
23
+ while (true) {
24
+ const { done, value } = await reader.read();
25
+ if (done) return text + decoder.decode();
26
+ bytes += value.byteLength;
27
+ if (bytes > MAX_SESSION_ANSWER_BYTES)
28
+ throw new Error('Realtime session answer exceeds the 1 MiB limit');
29
+ text += decoder.decode(value, { stream: true });
30
+ }
31
+ } catch (error) {
32
+ void reader.cancel(error).catch(() => {});
33
+ throw error;
34
+ } finally {
35
+ reader.releaseLock();
36
+ }
37
+ }
38
+
39
+ function selectAudioTrack(stream: MediaStream): MediaStreamTrack | undefined {
40
+ const live = stream
41
+ .getAudioTracks()
42
+ .filter(track => track.readyState === 'live');
43
+ return live.find(track => track.enabled && !track.muted) ?? live[0];
44
+ }
45
+
46
+ export class BrowserRealtimeWebRTC {
47
+ private pc?: RTCPeerConnection;
48
+ private dc?: RTCDataChannel;
49
+ private codec?: RealtimeEventChannel;
50
+ private stream?: MediaStream;
51
+ private ownsStream = false;
52
+ private audio?: HTMLAudioElement;
53
+ private abort?: AbortController;
54
+ private generation = 0;
55
+ private finishTimer?: ReturnType<typeof setTimeout>;
56
+ private finishing?: 'close' | 'failure';
57
+ private closingNotified = false;
58
+ private disconnectTimer?: ReturnType<typeof setTimeout>;
59
+ private captureGeneration = 0;
60
+ private sender?: RTCRtpSender;
61
+ private senderOperations = Promise.resolve();
62
+ private rejectStartup?: (error: Error) => void;
63
+ private trackCleanups: Array<() => void> = [];
64
+
65
+ constructor(
66
+ private readonly options: {
67
+ model: RealtimeModel;
68
+ onEvent: (event: RealtimeServerEvent) => void | Promise<void>;
69
+ onError: (error: Error) => void;
70
+ onFatalError?: (error: Error, drain?: Promise<void>) => void;
71
+ disconnectTimeoutMs?: number;
72
+ onClose: (error?: Error) => void;
73
+ onClosing?: () => void;
74
+ onCapturing: (value: boolean) => void;
75
+ onPlaying: (value: boolean) => void;
76
+ },
77
+ ) {}
78
+
79
+ async connect({
80
+ api,
81
+ sessionConfig,
82
+ stream,
83
+ timeoutMs,
84
+ capture = true,
85
+ }: {
86
+ api: string;
87
+ sessionConfig?: Partial<RealtimeSessionConfig>;
88
+ /** Caller-owned tracks are detached, never stopped, on disconnect. */
89
+ stream?: MediaStream;
90
+ timeoutMs: number;
91
+ capture?: boolean;
92
+ }): Promise<void> {
93
+ const generation = this.generation + 1;
94
+ this.dispose();
95
+ const current = () => generation === this.generation;
96
+ if (!current()) return;
97
+ const config = this.options.model.getWebRTCConfig?.();
98
+ if (!current()) return;
99
+ if (config == null)
100
+ throw new Error('This model does not support WebRTC configuration');
101
+ const abort = new AbortController();
102
+ this.abort = abort;
103
+ const timeout = setTimeout(
104
+ () => abort.abort(new Error('Realtime startup timed out')),
105
+ timeoutMs,
106
+ );
107
+ const cancelled = new Promise<never>((_, reject) => {
108
+ abort.signal.addEventListener(
109
+ 'abort',
110
+ () => reject(abort.signal.reason),
111
+ { once: true },
112
+ );
113
+ });
114
+ const start = async () => {
115
+ const captureGeneration = this.captureGeneration;
116
+ let media = capture
117
+ ? (stream ??
118
+ (await navigator.mediaDevices.getUserMedia({ audio: true })))
119
+ : undefined;
120
+ if (!current() || abort.signal.aborted) {
121
+ if (stream == null) media?.getTracks().forEach(track => track.stop());
122
+ throw new Error('Realtime connection cancelled');
123
+ }
124
+ if (captureGeneration !== this.captureGeneration) {
125
+ if (stream == null) media?.getTracks().forEach(track => track.stop());
126
+ media = undefined;
127
+ }
128
+ this.stream = media;
129
+ this.ownsStream = stream == null;
130
+ const track = media == null ? undefined : selectAudioTrack(media);
131
+ if (media != null && track == null) {
132
+ throw new Error('Realtime requires a live audio track');
133
+ }
134
+ const pc = new RTCPeerConnection();
135
+ this.pc = pc;
136
+ const audio = document.createElement('audio');
137
+ audio.autoplay = true;
138
+ this.audio = audio;
139
+ audio.onplaying = () => {
140
+ if (current()) this.options.onPlaying(true);
141
+ };
142
+ const stopped = () => {
143
+ if (current()) this.options.onPlaying(false);
144
+ };
145
+ audio.onpause = stopped;
146
+ audio.onended = stopped;
147
+ audio.onwaiting = stopped;
148
+ pc.ontrack = event => {
149
+ if (!current()) return;
150
+ audio.srcObject = event.streams[0] ?? new MediaStream([event.track]);
151
+ void audio.play().catch(error => {
152
+ if (current()) this.options.onError(error);
153
+ });
154
+ };
155
+ pc.onconnectionstatechange = () => {
156
+ if (!current()) return;
157
+ if (pc.connectionState === 'connected')
158
+ clearTimeout(this.disconnectTimer);
159
+ else if (pc.connectionState === 'disconnected') {
160
+ clearTimeout(this.disconnectTimer);
161
+ this.disconnectTimer = setTimeout(() => {
162
+ if (current() && pc.connectionState === 'disconnected')
163
+ this.fail(
164
+ new Error(
165
+ 'Realtime peer disconnected beyond recovery grace period',
166
+ ),
167
+ );
168
+ }, this.options.disconnectTimeoutMs ?? 5_000);
169
+ } else if (pc.connectionState === 'closed') {
170
+ this.drainClose(new Error('Realtime peer connection closed'));
171
+ } else if (pc.connectionState === 'failed') {
172
+ this.fail(
173
+ new Error(`Realtime peer connection ${pc.connectionState}`),
174
+ );
175
+ }
176
+ };
177
+ pc.oniceconnectionstatechange = () => {
178
+ if (current() && pc.iceConnectionState === 'failed')
179
+ this.fail(new Error('Realtime ICE connection failed'));
180
+ };
181
+ const dc = pc.createDataChannel(config.dataChannelLabel);
182
+ this.dc = dc;
183
+ const codec = new RealtimeEventChannel({
184
+ model: this.options.model,
185
+ onEvent: this.options.onEvent,
186
+ onError: this.options.onError,
187
+ onFatalError: error => {
188
+ if (current()) this.fail(error);
189
+ },
190
+ send: data => {
191
+ if (!current() || this.finishing || dc.readyState !== 'open')
192
+ throw new Error('Realtime data channel is not open');
193
+ if (dc.bufferedAmount > 1024 * 1024)
194
+ throw new Error('Realtime data channel buffer is full');
195
+ const encoded =
196
+ typeof data === 'string' ? data : JSON.stringify(data);
197
+ if (!current() || this.finishing || dc.readyState !== 'open')
198
+ throw new Error('Realtime data channel is not open');
199
+ dc.send(encoded);
200
+ },
201
+ });
202
+ if (!current()) {
203
+ codec.dispose();
204
+ return;
205
+ }
206
+ this.codec = codec;
207
+ dc.onmessage = event => {
208
+ if (current()) this.codec?.receive(event.data);
209
+ };
210
+ dc.onclose = () => {
211
+ if (current())
212
+ this.drainClose(new Error('Realtime data channel closed'));
213
+ };
214
+ dc.onerror = () => {
215
+ if (current()) this.fail(new Error('Realtime data channel error'));
216
+ };
217
+ const opened = new Promise<void>((resolve, reject) => {
218
+ this.rejectStartup = reject;
219
+ dc.onopen = () => {
220
+ this.rejectStartup = undefined;
221
+ resolve();
222
+ };
223
+ });
224
+ void opened.catch(() => {});
225
+ const gathered = new Promise<void>(resolve => {
226
+ pc.onicegatheringstatechange = () => {
227
+ if (pc.iceGatheringState === 'complete') resolve();
228
+ };
229
+ });
230
+ this.sender =
231
+ media != null && track != null
232
+ ? pc.addTrack(track, media)
233
+ : pc.addTransceiver('audio', { direction: 'sendrecv' }).sender;
234
+ this.observeCapture();
235
+ if (!current() || abort.signal.aborted) return;
236
+ const offer = await pc.createOffer();
237
+ if (!current() || abort.signal.aborted) return;
238
+ await pc.setLocalDescription(offer);
239
+ if (pc.iceGatheringState !== 'complete')
240
+ await Promise.race([gathered, cancelled]);
241
+ if (!current() || abort.signal.aborted) return;
242
+ const sdp = pc.localDescription?.sdp;
243
+ if (sdp == null) throw new Error('Realtime offer has no SDP');
244
+ const response = await fetch(api, {
245
+ method: 'POST',
246
+ headers: { 'Content-Type': 'application/json' },
247
+ body: JSON.stringify({ sdp, sessionConfig }),
248
+ signal: abort.signal,
249
+ });
250
+ if (!response.ok)
251
+ throw new Error(
252
+ `Failed to create realtime session: ${response.status}`,
253
+ );
254
+ const parsed = await safeParseJSON({
255
+ text: await readSessionAnswer(response),
256
+ });
257
+ if (!current() || abort.signal.aborted) return;
258
+ if (
259
+ !parsed.success ||
260
+ typeof parsed.value !== 'object' ||
261
+ parsed.value == null ||
262
+ !('sdp' in parsed.value) ||
263
+ typeof parsed.value.sdp !== 'string' ||
264
+ parsed.value.sdp.trim().length === 0 ||
265
+ !('sessionId' in parsed.value) ||
266
+ typeof parsed.value.sessionId !== 'string' ||
267
+ parsed.value.sessionId.trim().length === 0
268
+ ) {
269
+ throw new Error('Invalid realtime session answer');
270
+ }
271
+ await pc.setRemoteDescription({ type: 'answer', sdp: parsed.value.sdp });
272
+ await Promise.race([opened, cancelled]);
273
+ };
274
+ try {
275
+ await Promise.race([start(), cancelled]);
276
+ } catch (error) {
277
+ if (current()) this.dispose();
278
+ throw error;
279
+ } finally {
280
+ clearTimeout(timeout);
281
+ this.rejectStartup = undefined;
282
+ }
283
+ }
284
+
285
+ sendEvent(
286
+ event: RealtimeClientEvent,
287
+ shouldSend?: () => boolean,
288
+ ): Promise<void> {
289
+ if (this.finishing || this.dc?.readyState !== 'open')
290
+ throw new Error('Realtime data channel is not open');
291
+ return this.codec?.send(event, shouldSend) ?? Promise.resolve();
292
+ }
293
+
294
+ async resumePlayback(): Promise<void> {
295
+ if (this.audio == null)
296
+ throw new Error('Realtime playback is not connected');
297
+ await this.audio.play();
298
+ }
299
+
300
+ stopPlayback(): void {
301
+ this.audio?.pause();
302
+ }
303
+
304
+ finish(): Promise<void> {
305
+ return this.codec?.finish() ?? Promise.resolve();
306
+ }
307
+
308
+ private fail(error: Error): void {
309
+ if (this.finishing === 'failure') return;
310
+ this.finishing = 'failure';
311
+ clearTimeout(this.finishTimer);
312
+ if (this.rejectStartup != null) {
313
+ this.rejectStartup(error);
314
+ this.abort?.abort(error);
315
+ return;
316
+ }
317
+ const generation = this.generation;
318
+ const drain = this.finish();
319
+ this.notifyClosing();
320
+ if (generation !== this.generation) return;
321
+ void this.stopCaptureForShutdown(generation);
322
+ if (generation !== this.generation) return;
323
+ if (this.options.onFatalError != null) {
324
+ this.options.onFatalError(error, drain);
325
+ return;
326
+ }
327
+ const complete = () => {
328
+ if (generation !== this.generation) return;
329
+ this.dispose();
330
+ try {
331
+ this.options.onClose();
332
+ } catch (cause) {
333
+ this.reportCallbackError(cause);
334
+ }
335
+ };
336
+ this.finishTimer = setTimeout(complete, 1_000);
337
+ void this.awaitDrain(drain, complete);
338
+ try {
339
+ this.options.onError(error);
340
+ } catch {
341
+ /* Cleanup is already scheduled. */
342
+ }
343
+ }
344
+
345
+ private drainClose(error: Error): void {
346
+ if (this.finishing) return;
347
+ if (this.rejectStartup != null) {
348
+ this.fail(error);
349
+ return;
350
+ }
351
+ this.finishing = 'close';
352
+ const generation = this.generation;
353
+ const complete = () => {
354
+ if (generation !== this.generation || this.finishing !== 'close') return;
355
+ clearTimeout(this.finishTimer);
356
+ try {
357
+ this.options.onClose(error);
358
+ } catch (cause) {
359
+ this.reportCallbackError(cause);
360
+ } finally {
361
+ if (generation === this.generation) this.dispose();
362
+ }
363
+ };
364
+ this.finishTimer = setTimeout(complete, 1_000);
365
+ void this.awaitDrain(this.finish(), complete);
366
+ this.notifyClosing();
367
+ if (generation !== this.generation) return;
368
+ void this.stopCaptureForShutdown(generation);
369
+ }
370
+
371
+ private notifyClosing(): void {
372
+ if (this.closingNotified) return;
373
+ this.closingNotified = true;
374
+ try {
375
+ this.options.onClosing?.();
376
+ } catch (error) {
377
+ this.reportCallbackError(error);
378
+ }
379
+ }
380
+
381
+ private async stopCaptureForShutdown(generation: number): Promise<void> {
382
+ try {
383
+ await this.stopCapture();
384
+ } catch (cause) {
385
+ if (generation !== this.generation || this.finishing !== 'close') return;
386
+ try {
387
+ this.fail(cause instanceof Error ? cause : new Error(String(cause)));
388
+ } catch (error) {
389
+ this.reportCallbackError(error);
390
+ }
391
+ }
392
+ }
393
+
394
+ private async awaitDrain(
395
+ drain: Promise<void>,
396
+ complete: () => void,
397
+ ): Promise<void> {
398
+ try {
399
+ await drain;
400
+ } catch {
401
+ /* Transport loss still requires finalization. */
402
+ }
403
+ try {
404
+ complete();
405
+ } catch (error) {
406
+ this.reportCallbackError(error);
407
+ }
408
+ }
409
+
410
+ private reportCallbackError(error: unknown): void {
411
+ try {
412
+ this.options.onError(
413
+ error instanceof Error ? error : new Error(String(error)),
414
+ );
415
+ } catch {
416
+ /* Application callbacks cannot interrupt teardown. */
417
+ }
418
+ }
419
+
420
+ async stopCapture(): Promise<void> {
421
+ const generation = ++this.captureGeneration;
422
+ for (const cleanup of this.trackCleanups) cleanup();
423
+ this.trackCleanups = [];
424
+ if (this.ownsStream)
425
+ this.stream?.getTracks().forEach(track => track.stop());
426
+ this.stream = undefined;
427
+ const pc = this.pc;
428
+ const sender = this.sender;
429
+ await this.queueSenderOperation(async () => {
430
+ if (pc !== this.pc || sender !== this.sender) return;
431
+ if (pc != null && sender != null) await this.detachSender(pc, sender);
432
+ if (generation === this.captureGeneration && pc === this.pc)
433
+ this.options.onCapturing(false);
434
+ });
435
+ }
436
+
437
+ async startCapture(supplied?: MediaStream): Promise<void> {
438
+ const generation = this.captureGeneration + 1;
439
+ await this.stopCapture();
440
+ if (
441
+ generation !== this.captureGeneration ||
442
+ this.pc == null ||
443
+ this.finishing
444
+ )
445
+ return;
446
+ const media =
447
+ supplied ?? (await navigator.mediaDevices.getUserMedia({ audio: true }));
448
+ if (
449
+ generation !== this.captureGeneration ||
450
+ this.pc == null ||
451
+ this.finishing
452
+ ) {
453
+ if (supplied == null) media.getTracks().forEach(track => track.stop());
454
+ return;
455
+ }
456
+ const track = selectAudioTrack(media);
457
+ if (track == null) {
458
+ if (supplied == null) media.getTracks().forEach(track => track.stop());
459
+ throw new Error('Realtime requires a live audio track');
460
+ }
461
+ const pc = this.pc;
462
+ const sender = this.sender;
463
+ await this.queueSenderOperation(async () => {
464
+ const current = () =>
465
+ generation === this.captureGeneration &&
466
+ pc === this.pc &&
467
+ sender === this.sender &&
468
+ pc.connectionState !== 'closed' &&
469
+ !this.finishing;
470
+ let attached = false;
471
+ try {
472
+ if (!current() || sender == null) return;
473
+ await sender.replaceTrack(track);
474
+ if (!current()) {
475
+ if (pc === this.pc) await this.detachSender(pc, sender);
476
+ return;
477
+ }
478
+ this.stream = media;
479
+ this.ownsStream = supplied == null;
480
+ attached = true;
481
+ this.observeCapture();
482
+ } finally {
483
+ if (!attached && supplied == null)
484
+ media.getTracks().forEach(track => track.stop());
485
+ }
486
+ });
487
+ }
488
+
489
+ private queueSenderOperation(operation: () => Promise<void>): Promise<void> {
490
+ const pending = this.senderOperations.then(operation);
491
+ this.senderOperations = pending.catch(() => {});
492
+ return pending;
493
+ }
494
+
495
+ private async detachSender(
496
+ pc: RTCPeerConnection,
497
+ sender: RTCRtpSender,
498
+ ): Promise<void> {
499
+ if (pc.connectionState === 'closed') return;
500
+ try {
501
+ await sender.replaceTrack(null);
502
+ } catch (error) {
503
+ if (pc !== this.pc) return;
504
+ // A rejected detach can still transmit borrowed audio. Close the peer first.
505
+ this.closePeer(pc);
506
+ this.fail(error instanceof Error ? error : new Error(String(error)));
507
+ }
508
+ }
509
+
510
+ private closePeer(pc: RTCPeerConnection): void {
511
+ pc.ontrack = null;
512
+ pc.onconnectionstatechange = null;
513
+ pc.oniceconnectionstatechange = null;
514
+ pc.onicegatheringstatechange = null;
515
+ if (pc.connectionState !== 'closed') pc.close();
516
+ }
517
+
518
+ private observeCapture(): void {
519
+ const generation = this.captureGeneration;
520
+ const sender = this.sender;
521
+ const track = sender?.track;
522
+ const update = () => {
523
+ if (generation === this.captureGeneration && this.sender === sender)
524
+ this.options.onCapturing(
525
+ sender?.track != null &&
526
+ sender.track.readyState === 'live' &&
527
+ sender.track.enabled &&
528
+ !sender.track.muted,
529
+ );
530
+ };
531
+ if (track != null) {
532
+ for (const event of ['ended', 'mute', 'unmute']) {
533
+ track.addEventListener(event, update);
534
+ this.trackCleanups.push(() => track.removeEventListener(event, update));
535
+ }
536
+ }
537
+ update();
538
+ }
539
+
540
+ dispose(): void {
541
+ this.generation++;
542
+ this.captureGeneration++;
543
+ clearTimeout(this.finishTimer);
544
+ clearTimeout(this.disconnectTimer);
545
+ this.finishing = undefined;
546
+ this.closingNotified = false;
547
+ this.senderOperations = Promise.resolve();
548
+ this.abort?.abort(new Error('Realtime connection cancelled'));
549
+ this.abort = undefined;
550
+ this.codec?.dispose();
551
+ this.codec = undefined;
552
+ if (this.dc != null) {
553
+ this.dc.onopen = null;
554
+ this.dc.onclose = null;
555
+ this.dc.onerror = null;
556
+ this.dc.onmessage = null;
557
+ this.dc.close();
558
+ this.dc = undefined;
559
+ }
560
+ if (this.pc != null) {
561
+ this.closePeer(this.pc);
562
+ this.pc = undefined;
563
+ }
564
+ for (const cleanup of this.trackCleanups) cleanup();
565
+ this.trackCleanups = [];
566
+ if (this.ownsStream)
567
+ this.stream?.getTracks().forEach(track => track.stop());
568
+ this.stream = undefined;
569
+ this.sender = undefined;
570
+ if (this.audio != null) {
571
+ this.audio.onplaying = null;
572
+ this.audio.onpause = null;
573
+ this.audio.onended = null;
574
+ this.audio.onwaiting = null;
575
+ this.audio.pause();
576
+ this.audio.srcObject = null;
577
+ this.audio = undefined;
578
+ }
579
+ this.options.onCapturing(false);
580
+ this.options.onPlaying(false);
581
+ }
582
+ }
@@ -0,0 +1,33 @@
1
+ /** Private Live continuous wire budgets; legacy transports do not enforce them. */
2
+ export const REALTIME_MAX_FRAME_BYTES = 128 * 1024;
3
+ export const REALTIME_MAX_BUFFERED_BYTES = 128 * 1024;
4
+
5
+ export type EncodedRealtimeFrame = {
6
+ data: string | ArrayBuffer | ArrayBufferView | Blob;
7
+ byteLength: number;
8
+ };
9
+
10
+ /** Serialize once, measuring the exact payload passed to the browser. */
11
+ export function encodeRealtimeFrame(value: unknown): EncodedRealtimeFrame {
12
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value))
13
+ return { data: value, byteLength: value.byteLength };
14
+ if (value instanceof Blob) return { data: value, byteLength: value.size };
15
+ const data = typeof value === 'string' ? value : JSON.stringify(value);
16
+ if (data === undefined)
17
+ throw new Error('Realtime event could not be encoded as JSON');
18
+ return { data, byteLength: new TextEncoder().encode(data).byteLength };
19
+ }
20
+
21
+ export function assertRealtimeFrameBudget(
22
+ frame: EncodedRealtimeFrame,
23
+ bufferedAmount: number,
24
+ ): void {
25
+ if (frame.byteLength > REALTIME_MAX_FRAME_BYTES)
26
+ throw new Error(
27
+ `Realtime frame exceeds the ${REALTIME_MAX_FRAME_BYTES}-byte limit; reduce the event payload`,
28
+ );
29
+ if (bufferedAmount + frame.byteLength > REALTIME_MAX_BUFFERED_BYTES)
30
+ throw new Error(
31
+ `Realtime send buffer is full (${REALTIME_MAX_BUFFERED_BYTES}-byte limit); wait before retrying`,
32
+ );
33
+ }
@@ -8,6 +8,7 @@ export { AbstractRealtimeSession as Experimental_AbstractRealtimeSession } from
8
8
  export type {
9
9
  RealtimeSessionOptions as Experimental_RealtimeSessionOptions,
10
10
  RealtimeState as Experimental_RealtimeState,
11
+ RealtimeSessionState as Experimental_RealtimeSessionState,
11
12
  RealtimeStatus as Experimental_RealtimeStatus,
12
13
  } from './realtime-session';
13
14
  export type { RealtimeSetupResponse as Experimental_RealtimeSetupResponse } from './realtime-types';
@@ -0,0 +1,45 @@
1
+ /** Cancellation and settlement belong to one connection attempt. */
2
+ export class RealtimeAttempt {
3
+ readonly abort = new AbortController();
4
+ private timers = new Map<string, ReturnType<typeof setTimeout>>();
5
+ private settleClose?: () => void;
6
+ closePromise?: Promise<void>;
7
+ active = true;
8
+ ready = false;
9
+ closing = false;
10
+ transportClosing = false;
11
+ cause?: Error;
12
+
13
+ timer(name: string, duration: number, callback: () => void): void {
14
+ this.clearTimer(name);
15
+ this.timers.set(
16
+ name,
17
+ setTimeout(() => {
18
+ this.timers.delete(name);
19
+ if (this.active) callback();
20
+ }, duration),
21
+ );
22
+ }
23
+
24
+ clearTimer(name: string): void {
25
+ clearTimeout(this.timers.get(name));
26
+ this.timers.delete(name);
27
+ }
28
+
29
+ beginClose(): Promise<void> {
30
+ this.closing = true;
31
+ return (this.closePromise ??= new Promise(resolve => {
32
+ this.settleClose = resolve;
33
+ }));
34
+ }
35
+
36
+ retire(): void {
37
+ if (!this.active) return;
38
+ this.active = false;
39
+ this.abort.abort();
40
+ for (const timer of this.timers.values()) clearTimeout(timer);
41
+ this.timers.clear();
42
+ this.settleClose?.();
43
+ this.settleClose = undefined;
44
+ }
45
+ }