@aexhq/sdk 0.56.0 → 0.57.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/transport.js CHANGED
@@ -1,4 +1,8 @@
1
+ import { MAX_CREATE_SESSION_REQUEST_BYTES, MAX_CUSTOMER_OBSERVATION_BYTES, MAX_MESSAGE_REQUEST_BYTES, MAX_PUBLIC_EVENT_BYTES, } from "@aexhq/brain";
1
2
  import { AbortError, AexError, SessionError, abortError, errorFromApi } from "./errors.js";
3
+ const MAX_ORDINARY_JSON_BYTES = 2 * 1024 * 1024;
4
+ const MAX_ERROR_RESPONSE_BYTES = 64 * 1024;
5
+ const CUSTOMER_HAND_OBSERVATION_TIMEOUT_MS = 15_000;
2
6
  export class Transport {
3
7
  baseUrl;
4
8
  #apiKey;
@@ -11,38 +15,260 @@ export class Transport {
11
15
  this.baseUrl = baseUrl.slice(0, end);
12
16
  this.#fetch = fetchImplementation;
13
17
  }
18
+ async customerHandGrant(clientId, signal) {
19
+ const grant = await this.json("POST", "/v1/customer-hand/grants", { body: { client_id: clientId }, signal });
20
+ const observationUrl = validateCustomerHandGrant(this.baseUrl, grant);
21
+ return {
22
+ url: grant.url,
23
+ protocol: grant.protocol,
24
+ expiresAt: grant.expires_at,
25
+ observationUrl,
26
+ observationToken: grant.observation_token,
27
+ };
28
+ }
29
+ async customerHandObserve(url, token, observation) {
30
+ const body = encodeJsonOnce(observation, MAX_CUSTOMER_OBSERVATION_BYTES, "Customer Hand observation");
31
+ const response = await this.#fetch(url, {
32
+ method: "POST",
33
+ redirect: "error",
34
+ headers: {
35
+ Accept: "application/json",
36
+ Authorization: `Bearer ${token}`,
37
+ "Content-Type": "application/json",
38
+ },
39
+ body,
40
+ signal: AbortSignal.timeout(CUSTOMER_HAND_OBSERVATION_TIMEOUT_MS),
41
+ });
42
+ if (response.ok)
43
+ return;
44
+ const preview = await readResponseText(response, 4096)
45
+ .catch(() => "<response too large or unreadable>");
46
+ throw new SessionError(`Customer Hand observation ingress returned HTTP ${response.status}: ${preview}`, { status: response.status, requestId: response.headers.get("x-request-id") ?? undefined });
47
+ }
48
+ async downloadTransfer(ticket, signal, expectedBytes) {
49
+ const stream = await this.downloadTransferStream(ticket, signal, expectedBytes);
50
+ const reader = stream.getReader();
51
+ const chunks = [];
52
+ let bytes = 0;
53
+ try {
54
+ while (true) {
55
+ const item = await reader.read();
56
+ if (item.done)
57
+ break;
58
+ bytes += item.value.byteLength;
59
+ chunks.push(item.value);
60
+ }
61
+ }
62
+ finally {
63
+ reader.releaseLock();
64
+ }
65
+ const content = new Uint8Array(bytes);
66
+ let offset = 0;
67
+ for (const chunk of chunks) {
68
+ content.set(chunk, offset);
69
+ offset += chunk.byteLength;
70
+ }
71
+ return content;
72
+ }
73
+ async downloadTransferStream(ticket, signal, expectedBytes) {
74
+ assertTransferTicket(ticket, "GET");
75
+ if (expectedBytes !== undefined &&
76
+ (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0 || expectedBytes > ticket.max_bytes)) {
77
+ throw new SessionError("Aex file download has an invalid expected length");
78
+ }
79
+ const response = await this.#fetch(ticket.url, {
80
+ method: "GET",
81
+ redirect: "error",
82
+ headers: ticket.headers,
83
+ ...(signal === undefined ? {} : { signal }),
84
+ });
85
+ if (!response.ok) {
86
+ throw new SessionError(`Aex file download returned HTTP ${response.status}`, {
87
+ status: response.status,
88
+ });
89
+ }
90
+ const declaredHeader = response.headers.get("content-length");
91
+ const declared = declaredHeader === null ? undefined : Number(declaredHeader);
92
+ if (declared !== undefined && Number.isFinite(declared) && declared > ticket.max_bytes) {
93
+ throw new SessionError("Aex file download exceeded its transfer ticket");
94
+ }
95
+ if (expectedBytes !== undefined &&
96
+ declared !== undefined &&
97
+ Number.isFinite(declared) &&
98
+ declared !== expectedBytes) {
99
+ throw new SessionError("Aex file download length does not match its object metadata");
100
+ }
101
+ return boundedStream(response.body ?? emptyStream(), ticket.max_bytes, signal, "download", expectedBytes);
102
+ }
103
+ async uploadTransfer(ticket, content, bytes, signal) {
104
+ assertTransferTicket(ticket, "PUT");
105
+ if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > ticket.max_bytes) {
106
+ throw new TypeError("Aex file upload exceeds its transfer ticket");
107
+ }
108
+ const streaming = typeof content === "function";
109
+ const body = streaming
110
+ ? exactStream(content(), bytes, ticket.max_bytes, signal)
111
+ : arrayBufferBody(content);
112
+ if (!streaming && content.byteLength !== bytes) {
113
+ throw new TypeError("Aex file upload length does not match its transfer request");
114
+ }
115
+ const init = {
116
+ method: "PUT",
117
+ redirect: "error",
118
+ headers: ticket.headers,
119
+ body,
120
+ ...(signal === undefined ? {} : { signal }),
121
+ ...(streaming ? { duplex: "half" } : {}),
122
+ };
123
+ const response = await this.#fetch(ticket.url, init);
124
+ if (!response.ok) {
125
+ throw new SessionError(`Aex file upload returned HTTP ${response.status}`, {
126
+ status: response.status,
127
+ });
128
+ }
129
+ }
14
130
  async json(method, path, options = {}) {
15
131
  const attempts = options.retry === true ? 2 : 1;
132
+ // Serialize once before the first await. A caller mutating its source object after a lost
133
+ // response must never send different bytes under the same idempotency identity on retry.
134
+ const requestBody = options.body === undefined
135
+ ? undefined
136
+ : encodeJsonOnce(options.body, requestLimit(method, path), "Aex API request");
16
137
  let lastError;
17
138
  for (let attempt = 0; attempt < attempts; attempt += 1) {
18
139
  try {
19
140
  const response = await this.#fetch(`${this.baseUrl}${path}`, {
20
141
  method,
142
+ redirect: "error",
21
143
  headers: {
22
144
  Accept: "application/json",
23
- ...(options.body === undefined ? {} : { "Content-Type": "application/json" }),
145
+ ...(requestBody === undefined ? {} : { "Content-Type": "application/json" }),
24
146
  Authorization: `Bearer ${this.#apiKey}`,
25
147
  ...options.headers,
26
148
  },
27
- ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
149
+ ...(requestBody === undefined ? {} : { body: requestBody }),
28
150
  ...(options.signal === undefined ? {} : { signal: options.signal }),
29
151
  });
30
152
  if (!response.ok)
31
153
  throw await this.responseError(response);
32
154
  if (response.status === 204)
33
155
  return undefined;
34
- return (await response.json());
156
+ const text = await readResponseText(response, MAX_ORDINARY_JSON_BYTES);
157
+ if (text === "")
158
+ return undefined;
159
+ try {
160
+ return JSON.parse(text);
161
+ }
162
+ catch (cause) {
163
+ throw new SessionError("Aex returned invalid JSON", { cause });
164
+ }
35
165
  }
36
166
  catch (error) {
37
167
  if (options.signal?.aborted === true || isAbort(error))
38
168
  throw abortError(error);
39
- if (error instanceof AexError)
40
- throw error;
169
+ if (error instanceof AexError) {
170
+ const retryableServerFailure = options.retry === true && error.status !== undefined && error.status >= 500;
171
+ if (!retryableServerFailure || attempt + 1 >= attempts)
172
+ throw error;
173
+ }
41
174
  lastError = error;
42
175
  }
43
176
  }
44
177
  throw new SessionError("Could not reach the Aex API", { cause: lastError });
45
178
  }
179
+ /** Accept one durable deletion job; strict callers poll its short status resource client-side. */
180
+ async deleteSession(sessionId, waitForCompletion, signal) {
181
+ const path = `/v1/sessions/${encodeURIComponent(sessionId)}`;
182
+ let accepted;
183
+ let lastError;
184
+ for (let attempt = 0; attempt < 2; attempt += 1) {
185
+ try {
186
+ const response = await this.#fetch(`${this.baseUrl}${path}`, {
187
+ method: "DELETE",
188
+ redirect: "error",
189
+ headers: {
190
+ Accept: "application/json",
191
+ Authorization: `Bearer ${this.#apiKey}`,
192
+ },
193
+ ...(signal === undefined ? {} : { signal }),
194
+ });
195
+ if (!response.ok)
196
+ throw await this.responseError(response);
197
+ if (response.status !== 202 && response.status !== 204) {
198
+ throw new SessionError(`Aex returned invalid deletion status HTTP ${response.status}`);
199
+ }
200
+ accepted = response;
201
+ break;
202
+ }
203
+ catch (error) {
204
+ if (signal?.aborted === true || isAbort(error))
205
+ throw abortError(error);
206
+ if (error instanceof AexError) {
207
+ const retryableServerFailure = error.status !== undefined && error.status >= 500;
208
+ if (!retryableServerFailure || attempt + 1 >= 2)
209
+ throw error;
210
+ }
211
+ lastError = error;
212
+ }
213
+ }
214
+ if (accepted === undefined) {
215
+ throw new SessionError("Could not reach the Aex API", { cause: lastError });
216
+ }
217
+ if (accepted.status === 204 || !waitForCompletion)
218
+ return;
219
+ const canonicalStatusPath = `${path}/deletion`;
220
+ const location = accepted.headers.get("location") ?? canonicalStatusPath;
221
+ const statusUrl = deletionStatusUrl(this.baseUrl, canonicalStatusPath, location);
222
+ let retryMs = retryAfterMs(accepted.headers.get("retry-after"));
223
+ while (true) {
224
+ await delay(jitter(retryMs), signal);
225
+ let response;
226
+ try {
227
+ response = await this.#fetch(statusUrl, {
228
+ method: "GET",
229
+ redirect: "error",
230
+ headers: {
231
+ Accept: "application/json",
232
+ Authorization: `Bearer ${this.#apiKey}`,
233
+ },
234
+ ...(signal === undefined ? {} : { signal }),
235
+ });
236
+ }
237
+ catch (error) {
238
+ if (signal?.aborted === true || isAbort(error))
239
+ throw abortError(error);
240
+ retryMs = Math.min(30_000, Math.max(250, retryMs * 2));
241
+ continue;
242
+ }
243
+ if (!response.ok) {
244
+ const error = await this.responseError(response);
245
+ if (error.status === undefined || error.status < 500)
246
+ throw error;
247
+ retryMs = Math.min(30_000, Math.max(retryAfterMs(response.headers.get("retry-after")), retryMs * 2));
248
+ continue;
249
+ }
250
+ let status;
251
+ try {
252
+ const body = await readResponseText(response, MAX_ERROR_RESPONSE_BYTES);
253
+ status = JSON.parse(body);
254
+ }
255
+ catch (cause) {
256
+ throw new SessionError("Aex returned invalid deletion status JSON", { cause });
257
+ }
258
+ const state = typeof status === "object" && status !== null && "state" in status
259
+ ? status.state
260
+ : undefined;
261
+ if (state === "succeeded")
262
+ return;
263
+ if (state !== "accepted" &&
264
+ state !== "deleting" &&
265
+ state !== "retrying" &&
266
+ state !== "blocked") {
267
+ throw new SessionError("Aex returned an invalid deletion state");
268
+ }
269
+ retryMs = retryAfterMs(response.headers.get("retry-after"));
270
+ }
271
+ }
46
272
  async *events(sessionId, options = {}) {
47
273
  let cursor = options.after ?? 0;
48
274
  const follow = options.follow ?? true;
@@ -52,6 +278,7 @@ export class Transport {
52
278
  const query = new URLSearchParams({ after: String(cursor), follow: String(follow) });
53
279
  const response = await this.#fetch(`${this.baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/events?${query}`, {
54
280
  method: "GET",
281
+ redirect: "error",
55
282
  headers: {
56
283
  Accept: "text/event-stream",
57
284
  Authorization: `Bearer ${this.#apiKey}`,
@@ -64,13 +291,18 @@ export class Transport {
64
291
  if (response.body === null)
65
292
  throw new SessionError("The Aex event stream had no body");
66
293
  let received = false;
67
- for await (const event of parseEventStream(response.body)) {
68
- if (event.seq <= cursor)
69
- continue;
70
- cursor = event.seq;
294
+ for await (const frame of parseEventFrames(response.body)) {
295
+ // Only Brain's SSE `id` is reconnect authority. Provisional provider/Tool frames can
296
+ // carry an internal JSON sequence while deliberately omitting `id`; advancing from the
297
+ // payload would skip durable recovery records after a crash.
298
+ if (frame.durableId !== undefined) {
299
+ if (frame.durableId <= cursor)
300
+ continue;
301
+ cursor = frame.durableId;
302
+ }
71
303
  received = true;
72
304
  consecutiveFailures = 0;
73
- yield event;
305
+ yield frame.event;
74
306
  }
75
307
  if (!follow)
76
308
  return;
@@ -95,7 +327,8 @@ export class Transport {
95
327
  async responseError(response) {
96
328
  let envelope;
97
329
  try {
98
- envelope = (await response.json());
330
+ const text = await readResponseText(response, MAX_ERROR_RESPONSE_BYTES);
331
+ envelope = text === "" ? undefined : JSON.parse(text);
99
332
  }
100
333
  catch {
101
334
  // Fall through to a status-based error.
@@ -108,72 +341,387 @@ export class Transport {
108
341
  });
109
342
  }
110
343
  }
111
- async function* parseEventStream(stream) {
344
+ function requestLimit(method, path) {
345
+ if (method === "POST" && path === "/v1/sessions")
346
+ return MAX_CREATE_SESSION_REQUEST_BYTES;
347
+ if (method === "POST" &&
348
+ (/^\/v1\/sessions\/[^/]+\/messages(?:\?|$)/u.test(path) ||
349
+ /^\/v1\/sessions\/[^/]+\/children(?:\?|$)/u.test(path) ||
350
+ /^\/v1\/sessions\/[^/]+\/children\/[^/]+\/(?:messages|follow-up)(?:\?|$)/u.test(path))) {
351
+ return MAX_MESSAGE_REQUEST_BYTES;
352
+ }
353
+ return MAX_ORDINARY_JSON_BYTES;
354
+ }
355
+ function encodeJsonOnce(value, maxBytes, label) {
356
+ let encoded;
357
+ try {
358
+ encoded = JSON.stringify(value);
359
+ }
360
+ catch (cause) {
361
+ throw new TypeError(`${label} is not JSON-serializable`, { cause });
362
+ }
363
+ if (encoded === undefined)
364
+ throw new TypeError(`${label} must be a JSON value`);
365
+ if (new TextEncoder().encode(encoded).byteLength > maxBytes) {
366
+ throw new TypeError(`${label} exceeds ${maxBytes} bytes`);
367
+ }
368
+ return encoded;
369
+ }
370
+ async function readResponseText(response, maxBytes) {
371
+ if (response.body === null)
372
+ return "";
373
+ const declared = Number(response.headers.get("content-length"));
374
+ if (Number.isFinite(declared) && declared > maxBytes) {
375
+ await response.body.cancel().catch(() => undefined);
376
+ throw new SessionError(`Aex response exceeds ${maxBytes} bytes`);
377
+ }
378
+ const reader = response.body.getReader();
379
+ const chunks = [];
380
+ let bytes = 0;
381
+ try {
382
+ while (true) {
383
+ const item = await reader.read();
384
+ if (item.done)
385
+ break;
386
+ bytes += item.value.byteLength;
387
+ if (bytes > maxBytes)
388
+ throw new SessionError(`Aex response exceeds ${maxBytes} bytes`);
389
+ chunks.push(item.value);
390
+ }
391
+ }
392
+ finally {
393
+ await reader.cancel().catch(() => undefined);
394
+ reader.releaseLock();
395
+ }
396
+ const content = new Uint8Array(bytes);
397
+ let offset = 0;
398
+ for (const chunk of chunks) {
399
+ content.set(chunk, offset);
400
+ offset += chunk.byteLength;
401
+ }
402
+ return new TextDecoder().decode(content);
403
+ }
404
+ function assertTransferTicket(ticket, method) {
405
+ if (ticket.method !== method) {
406
+ throw new SessionError(`Aex returned a ${ticket.method} transfer for ${method}`);
407
+ }
408
+ if (!Number.isSafeInteger(ticket.max_bytes) || ticket.max_bytes < 0) {
409
+ throw new SessionError("Aex returned an invalid file-transfer limit");
410
+ }
411
+ let url;
412
+ try {
413
+ url = new URL(ticket.url);
414
+ }
415
+ catch (cause) {
416
+ throw new SessionError("Aex returned an invalid file-transfer URL", { cause });
417
+ }
418
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) {
419
+ throw new SessionError("Aex file transfers require HTTPS (or loopback HTTP for development)");
420
+ }
421
+ }
422
+ function validateCustomerHandGrant(baseUrl, grant) {
423
+ if (!/^[A-Za-z0-9_.-]{1,128}$/u.test(grant.grant_id)) {
424
+ throw new SessionError("Aex returned an invalid customer Hand grant id");
425
+ }
426
+ if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,2048}$/u.test(grant.protocol)) {
427
+ throw new SessionError("Aex returned an invalid customer Hand WebSocket protocol");
428
+ }
429
+ if (!/^[\x21-\x7e]{1,2048}$/u.test(grant.observation_token)) {
430
+ throw new SessionError("Aex returned an invalid customer Hand observation token");
431
+ }
432
+ let socket;
433
+ let observation;
434
+ let expectedObservation;
435
+ try {
436
+ socket = new URL(grant.url);
437
+ observation = new URL(grant.observation_url);
438
+ expectedObservation = new URL(`${baseUrl}/v1/customer-hand/observations/${encodeURIComponent(grant.grant_id)}`);
439
+ }
440
+ catch (cause) {
441
+ throw new SessionError("Aex returned an invalid customer Hand URL", { cause });
442
+ }
443
+ const secureSocket = socket.protocol === "wss:" ||
444
+ (socket.protocol === "ws:" && isLoopback(socket.hostname));
445
+ if (!secureSocket || socket.username !== "" || socket.password !== "" ||
446
+ socket.search !== "" || socket.hash !== "") {
447
+ throw new SessionError("Aex customer Hand sockets require credential-free WSS (or loopback WS for development)");
448
+ }
449
+ if (observation.href !== expectedObservation.href) {
450
+ throw new SessionError("Aex returned an unsafe customer Hand observation URL");
451
+ }
452
+ if (observation.href.includes(grant.observation_token)) {
453
+ throw new SessionError("Aex returned an observation URL containing its bearer token");
454
+ }
455
+ return expectedObservation.href;
456
+ }
457
+ function arrayBufferBody(content) {
458
+ if (content.buffer instanceof ArrayBuffer) {
459
+ return content;
460
+ }
461
+ // SharedArrayBuffer-backed views are not a Fetch BodyInit. This uncommon compatibility copy is
462
+ // the only buffered-copy path; ordinary Uint8Array/ArrayBuffer uploads stay zero-copy.
463
+ return Uint8Array.from(content);
464
+ }
465
+ function emptyStream() {
466
+ return new ReadableStream({
467
+ start(controller) {
468
+ controller.close();
469
+ },
470
+ });
471
+ }
472
+ function boundedStream(source, maxBytes, signal, direction, expectedBytes) {
473
+ const reader = source.getReader();
474
+ let bytes = 0;
475
+ let finished = false;
476
+ let controller;
477
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
478
+ const onAbort = () => {
479
+ if (finished)
480
+ return;
481
+ finished = true;
482
+ void reader.cancel(signal?.reason).catch(() => undefined);
483
+ controller?.error(abortError(signal?.reason));
484
+ cleanup();
485
+ };
486
+ return new ReadableStream({
487
+ start(value) {
488
+ controller = value;
489
+ if (signal?.aborted === true)
490
+ onAbort();
491
+ else
492
+ signal?.addEventListener("abort", onAbort, { once: true });
493
+ },
494
+ async pull(value) {
495
+ if (finished)
496
+ return;
497
+ try {
498
+ const item = await reader.read();
499
+ if (item.done) {
500
+ if (expectedBytes !== undefined && bytes !== expectedBytes) {
501
+ throw new TypeError(`Aex file ${direction} produced ${bytes} bytes; expected ${expectedBytes}`);
502
+ }
503
+ finished = true;
504
+ cleanup();
505
+ value.close();
506
+ return;
507
+ }
508
+ if (!(item.value instanceof Uint8Array)) {
509
+ throw new TypeError(`Aex file ${direction} stream must yield Uint8Array chunks`);
510
+ }
511
+ bytes += item.value.byteLength;
512
+ if (bytes > maxBytes || (expectedBytes !== undefined && bytes > expectedBytes)) {
513
+ throw new TypeError(`Aex file ${direction} exceeded its declared byte limit`);
514
+ }
515
+ value.enqueue(item.value);
516
+ }
517
+ catch (error) {
518
+ finished = true;
519
+ cleanup();
520
+ await reader.cancel(error).catch(() => undefined);
521
+ value.error(error);
522
+ }
523
+ },
524
+ async cancel(reason) {
525
+ if (finished)
526
+ return;
527
+ finished = true;
528
+ cleanup();
529
+ await reader.cancel(reason);
530
+ },
531
+ });
532
+ }
533
+ function exactStream(source, expectedBytes, maxBytes, signal) {
534
+ return boundedStream(source, maxBytes, signal, "upload", expectedBytes);
535
+ }
536
+ function isLoopback(hostname) {
537
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
538
+ }
539
+ function deletionStatusUrl(baseUrl, canonicalPath, location) {
540
+ let url;
541
+ try {
542
+ url = new URL(location, `${baseUrl}/`);
543
+ }
544
+ catch (cause) {
545
+ throw new SessionError("Aex returned an invalid deletion status location", { cause });
546
+ }
547
+ const base = new URL(`${baseUrl}/`);
548
+ if (url.origin !== base.origin || url.pathname !== canonicalPath || url.search !== "") {
549
+ throw new SessionError("Aex returned an unsafe deletion status location");
550
+ }
551
+ return url.toString();
552
+ }
553
+ function retryAfterMs(value) {
554
+ if (value !== null && /^\d+$/u.test(value)) {
555
+ return Math.min(30_000, Math.max(25, Number(value) * 1_000));
556
+ }
557
+ if (value !== null) {
558
+ const at = Date.parse(value);
559
+ if (Number.isFinite(at))
560
+ return Math.min(30_000, Math.max(25, at - Date.now()));
561
+ }
562
+ return 1_000;
563
+ }
564
+ function jitter(milliseconds) {
565
+ return Math.floor(milliseconds / 2 + Math.random() * (milliseconds / 2));
566
+ }
567
+ const MAX_SSE_FRAMING_BYTES = 4 * 1024;
568
+ const MAX_SSE_WIRE_FRAME_BYTES = MAX_PUBLIC_EVENT_BYTES + MAX_SSE_FRAMING_BYTES;
569
+ const MAX_SSE_LINE_BYTES = MAX_SSE_WIRE_FRAME_BYTES;
570
+ /** @internal Incremental, constant-space decoder for Brain's bounded public SSE events. */
571
+ export async function* parseEventStream(stream) {
572
+ for await (const frame of parseEventFrames(stream))
573
+ yield frame.event;
574
+ }
575
+ async function* parseEventFrames(stream) {
112
576
  const reader = stream.getReader();
113
- const decoder = new TextDecoder();
114
- let buffer = "";
115
- let frame = { data: [] };
577
+ const decoder = new TextDecoder("utf-8", { fatal: true });
578
+ const line = new Uint8Array(MAX_SSE_LINE_BYTES);
579
+ const data = new Uint8Array(MAX_PUBLIC_EVENT_BYTES);
580
+ let lineLength = 0;
581
+ let dataLength = 0;
582
+ let dataLines = 0;
583
+ let frameFramingBytes = 0;
584
+ let frameStarted = false;
585
+ let eventId;
586
+ const reset = () => {
587
+ dataLength = 0;
588
+ dataLines = 0;
589
+ frameFramingBytes = 0;
590
+ frameStarted = false;
591
+ eventId = undefined;
592
+ };
116
593
  const dispatch = () => {
117
- if (frame.data.length === 0) {
118
- frame = { data: [] };
594
+ if (dataLines === 0) {
595
+ reset();
119
596
  return undefined;
120
597
  }
121
- const data = frame.data.join("\n");
122
- frame = { data: [] };
598
+ let encoded;
599
+ try {
600
+ encoded = decoder.decode(data.subarray(0, dataLength));
601
+ }
602
+ catch (cause) {
603
+ throw new SessionError("Aex sent an event that was not valid UTF-8", { cause });
604
+ }
605
+ const id = eventId;
606
+ reset();
123
607
  try {
124
- return JSON.parse(data);
608
+ const event = JSON.parse(encoded);
609
+ if (id === undefined) {
610
+ if (!isEphemeralEvent(event)) {
611
+ throw new SessionError(`Aex sent durable event ${event.type} without an SSE id`);
612
+ }
613
+ return { event };
614
+ }
615
+ if (!/^[1-9][0-9]*$/u.test(id)) {
616
+ throw new SessionError("Aex sent an invalid durable SSE id");
617
+ }
618
+ const durableId = Number(id);
619
+ const payloadSeq = "seq" in event ? event.seq : undefined;
620
+ if (!Number.isSafeInteger(durableId) || payloadSeq !== durableId) {
621
+ throw new SessionError("Aex durable SSE id does not match its event sequence");
622
+ }
623
+ return { event, durableId };
125
624
  }
126
625
  catch (cause) {
626
+ if (cause instanceof SessionError)
627
+ throw cause;
127
628
  throw new SessionError("Aex sent an invalid event", { cause });
128
629
  }
129
630
  };
130
- const line = (raw) => {
131
- const value = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
132
- if (value === "")
631
+ const processLine = () => {
632
+ const wireLineBytes = lineLength + 1;
633
+ let end = lineLength;
634
+ if (end > 0 && line[end - 1] === 13)
635
+ end -= 1;
636
+ lineLength = 0;
637
+ const addFraming = (payloadBytes = 0) => {
638
+ frameFramingBytes += wireLineBytes - payloadBytes;
639
+ if (frameFramingBytes > MAX_SSE_FRAMING_BYTES) {
640
+ throw new SessionError(`Aex event frame exceeds ${MAX_SSE_FRAMING_BYTES} framing bytes`);
641
+ }
642
+ };
643
+ if (end === 0) {
644
+ addFraming();
133
645
  return dispatch();
134
- if (value.startsWith(":"))
646
+ }
647
+ frameStarted = true;
648
+ if (line[0] === 58) {
649
+ addFraming();
650
+ return undefined; // SSE comment.
651
+ }
652
+ let separator = -1;
653
+ for (let index = 0; index < end; index += 1) {
654
+ if (line[index] === 58) {
655
+ separator = index;
656
+ break;
657
+ }
658
+ }
659
+ let start = separator + 1;
660
+ if (start < end && line[start] === 32)
661
+ start += 1;
662
+ const isData = separator === 4 && line[0] === 100 && line[1] === 97 &&
663
+ line[2] === 116 && line[3] === 97;
664
+ const isId = separator === 2 && line[0] === 105 && line[1] === 100;
665
+ if (!isData)
666
+ addFraming();
667
+ if (isId) {
668
+ try {
669
+ eventId = decoder.decode(line.subarray(start, end));
670
+ }
671
+ catch (cause) {
672
+ throw new SessionError("Aex sent an SSE id that was not valid UTF-8", { cause });
673
+ }
674
+ return undefined;
675
+ }
676
+ if (!isData)
135
677
  return undefined;
136
- const separator = value.indexOf(":");
137
- const field = separator === -1 ? value : value.slice(0, separator);
138
- let content = separator === -1 ? "" : value.slice(separator + 1);
139
- if (content.startsWith(" "))
140
- content = content.slice(1);
141
- if (field === "data")
142
- frame.data.push(content);
143
- else if (field === "id")
144
- frame.id = content;
145
- else if (field === "event")
146
- frame.event = content;
678
+ const contentBytes = end - start;
679
+ addFraming(contentBytes);
680
+ const joinedBytes = dataLength + (dataLines > 0 ? 1 : 0) + contentBytes;
681
+ if (joinedBytes > MAX_PUBLIC_EVENT_BYTES) {
682
+ throw new SessionError(`Aex event payload exceeds ${MAX_PUBLIC_EVENT_BYTES} bytes`);
683
+ }
684
+ if (dataLines > 0)
685
+ data[dataLength++] = 10;
686
+ data.set(line.subarray(start, end), dataLength);
687
+ dataLength += contentBytes;
688
+ dataLines += 1;
147
689
  return undefined;
148
690
  };
149
691
  try {
150
692
  while (true) {
151
693
  const { done, value } = await reader.read();
152
- buffer += decoder.decode(value, { stream: !done });
153
- let newline = buffer.indexOf("\n");
154
- while (newline !== -1) {
155
- const event = line(buffer.slice(0, newline));
156
- buffer = buffer.slice(newline + 1);
157
- if (event !== undefined)
158
- yield event;
159
- newline = buffer.indexOf("\n");
160
- }
161
694
  if (done)
162
695
  break;
696
+ if (!(value instanceof Uint8Array)) {
697
+ throw new SessionError("Aex event stream yielded a non-byte chunk");
698
+ }
699
+ for (const byte of value) {
700
+ if (byte === 10) {
701
+ const event = processLine();
702
+ if (event !== undefined)
703
+ yield event;
704
+ continue;
705
+ }
706
+ if (lineLength >= line.byteLength) {
707
+ throw new SessionError(`Aex event line exceeds ${MAX_SSE_LINE_BYTES} bytes`);
708
+ }
709
+ line[lineLength++] = byte;
710
+ }
163
711
  }
164
- if (buffer !== "") {
165
- const event = line(buffer);
166
- if (event !== undefined)
167
- yield event;
712
+ if (lineLength > 0 || frameStarted || dataLines > 0) {
713
+ throw new SessionError("Aex event stream ended in a truncated SSE frame");
168
714
  }
169
- const final = dispatch();
170
- if (final !== undefined)
171
- yield final;
172
715
  }
173
716
  finally {
717
+ await reader.cancel().catch(() => undefined);
174
718
  reader.releaseLock();
175
719
  }
176
720
  }
721
+ function isEphemeralEvent(event) {
722
+ return event.type === "assistant.delta" || event.type === "tool.output" ||
723
+ event.type === "replay.complete";
724
+ }
177
725
  function isAbort(error) {
178
726
  return error instanceof AbortError || (error instanceof DOMException && error.name === "AbortError");
179
727
  }