@aexhq/sdk 0.55.1 → 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/README.md +96 -15
- package/dist/errors.d.ts +7 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.js +15 -1
- package/dist/resources.d.ts +157 -0
- package/dist/resources.js +438 -0
- package/dist/session.d.ts +53 -5
- package/dist/session.js +295 -79
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +1 -0
- package/dist/transport.d.ts +17 -8
- package/dist/transport.js +599 -51
- package/package.json +6 -5
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
|
-
...(
|
|
145
|
+
...(requestBody === undefined ? {} : { "Content-Type": "application/json" }),
|
|
24
146
|
Authorization: `Bearer ${this.#apiKey}`,
|
|
25
147
|
...options.headers,
|
|
26
148
|
},
|
|
27
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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 (
|
|
118
|
-
|
|
594
|
+
if (dataLines === 0) {
|
|
595
|
+
reset();
|
|
119
596
|
return undefined;
|
|
120
597
|
}
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
131
|
-
const
|
|
132
|
-
|
|
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
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 (
|
|
165
|
-
|
|
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
|
}
|