@aexhq/sdk 0.62.1 → 0.63.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 +12 -51
- package/dist/index.d.ts +9 -16
- package/dist/index.js +12 -19
- package/dist/session.d.ts +16 -131
- package/dist/session.js +24 -640
- package/package.json +3 -8
- package/dist/errors.d.ts +0 -39
- package/dist/errors.js +0 -69
- package/dist/json.d.ts +0 -4
- package/dist/json.js +0 -46
- package/dist/resources.d.ts +0 -162
- package/dist/resources.js +0 -442
- package/dist/tools.d.ts +0 -48
- package/dist/tools.js +0 -119
- package/dist/transport.d.ts +0 -31
- package/dist/transport.js +0 -744
package/dist/transport.js
DELETED
|
@@ -1,744 +0,0 @@
|
|
|
1
|
-
import { MAX_CREATE_SESSION_REQUEST_BYTES, MAX_CUSTOMER_OBSERVATION_BYTES, MAX_MESSAGE_REQUEST_BYTES, MAX_PUBLIC_EVENT_BYTES, } from "@aexhq/brain";
|
|
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_ENVIRONMENT_OBSERVATION_TIMEOUT_MS = 15_000;
|
|
6
|
-
export class Transport {
|
|
7
|
-
baseUrl;
|
|
8
|
-
#apiKey;
|
|
9
|
-
#fetch;
|
|
10
|
-
constructor(apiKey, baseUrl, fetchImplementation) {
|
|
11
|
-
this.#apiKey = apiKey;
|
|
12
|
-
let end = baseUrl.length;
|
|
13
|
-
while (end > 0 && baseUrl.charCodeAt(end - 1) === 47)
|
|
14
|
-
end -= 1;
|
|
15
|
-
this.baseUrl = baseUrl.slice(0, end);
|
|
16
|
-
this.#fetch = fetchImplementation;
|
|
17
|
-
}
|
|
18
|
-
async customerEnvironmentGrant(clientId, signal) {
|
|
19
|
-
const grant = await this.json("POST", "/v1/customer-environment/grants", { body: { client_id: clientId }, signal });
|
|
20
|
-
const observationUrl = validateCustomerEnvironmentGrant(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 customerEnvironmentObserve(url, token, observation) {
|
|
30
|
-
const body = encodeJsonOnce(observation, MAX_CUSTOMER_OBSERVATION_BYTES, "Customer Environment 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_ENVIRONMENT_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 Environment 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
|
-
}
|
|
130
|
-
async json(method, path, options = {}) {
|
|
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");
|
|
137
|
-
let lastError;
|
|
138
|
-
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
139
|
-
try {
|
|
140
|
-
const response = await this.#fetch(`${this.baseUrl}${path}`, {
|
|
141
|
-
method,
|
|
142
|
-
redirect: "error",
|
|
143
|
-
headers: {
|
|
144
|
-
Accept: "application/json",
|
|
145
|
-
...(requestBody === undefined ? {} : { "Content-Type": "application/json" }),
|
|
146
|
-
Authorization: `Bearer ${this.#apiKey}`,
|
|
147
|
-
...options.headers,
|
|
148
|
-
},
|
|
149
|
-
...(requestBody === undefined ? {} : { body: requestBody }),
|
|
150
|
-
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
151
|
-
});
|
|
152
|
-
if (!response.ok)
|
|
153
|
-
throw await this.responseError(response);
|
|
154
|
-
if (response.status === 204)
|
|
155
|
-
return undefined;
|
|
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
|
-
}
|
|
165
|
-
}
|
|
166
|
-
catch (error) {
|
|
167
|
-
if (options.signal?.aborted === true || isAbort(error))
|
|
168
|
-
throw abortError(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
|
-
}
|
|
174
|
-
lastError = error;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
throw new SessionError("Could not reach the Aex API", { cause: lastError });
|
|
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
|
-
}
|
|
272
|
-
async *events(sessionId, options = {}) {
|
|
273
|
-
let cursor = options.after ?? 0;
|
|
274
|
-
const follow = options.follow ?? true;
|
|
275
|
-
let consecutiveFailures = 0;
|
|
276
|
-
while (true) {
|
|
277
|
-
try {
|
|
278
|
-
const query = new URLSearchParams({ after: String(cursor), follow: String(follow) });
|
|
279
|
-
const response = await this.#fetch(`${this.baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/events?${query}`, {
|
|
280
|
-
method: "GET",
|
|
281
|
-
redirect: "error",
|
|
282
|
-
headers: {
|
|
283
|
-
Accept: "text/event-stream",
|
|
284
|
-
Authorization: `Bearer ${this.#apiKey}`,
|
|
285
|
-
...(cursor > 0 ? { "Last-Event-ID": String(cursor) } : {}),
|
|
286
|
-
},
|
|
287
|
-
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
288
|
-
});
|
|
289
|
-
if (!response.ok)
|
|
290
|
-
throw await this.responseError(response);
|
|
291
|
-
if (response.body === null)
|
|
292
|
-
throw new SessionError("The Aex event stream had no body");
|
|
293
|
-
let received = false;
|
|
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
|
-
}
|
|
303
|
-
received = true;
|
|
304
|
-
consecutiveFailures = 0;
|
|
305
|
-
yield frame.event;
|
|
306
|
-
}
|
|
307
|
-
if (!follow)
|
|
308
|
-
return;
|
|
309
|
-
if (!received)
|
|
310
|
-
consecutiveFailures += 1;
|
|
311
|
-
}
|
|
312
|
-
catch (error) {
|
|
313
|
-
if (options.signal?.aborted === true || isAbort(error))
|
|
314
|
-
throw abortError(error);
|
|
315
|
-
if (error instanceof AexError && error.status !== undefined && error.status < 500)
|
|
316
|
-
throw error;
|
|
317
|
-
consecutiveFailures += 1;
|
|
318
|
-
if (consecutiveFailures > 5) {
|
|
319
|
-
if (error instanceof AexError)
|
|
320
|
-
throw error;
|
|
321
|
-
throw new SessionError("The Aex event stream disconnected", { cause: error });
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
await delay(Math.min(2_000, 100 * 2 ** (consecutiveFailures - 1)), options.signal);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
async responseError(response) {
|
|
328
|
-
let envelope;
|
|
329
|
-
try {
|
|
330
|
-
const text = await readResponseText(response, MAX_ERROR_RESPONSE_BYTES);
|
|
331
|
-
envelope = text === "" ? undefined : JSON.parse(text);
|
|
332
|
-
}
|
|
333
|
-
catch {
|
|
334
|
-
// Fall through to a status-based error.
|
|
335
|
-
}
|
|
336
|
-
if (envelope?.error !== undefined)
|
|
337
|
-
return errorFromApi(envelope.error, response.status);
|
|
338
|
-
return new SessionError(`Aex API request failed with HTTP ${response.status}`, {
|
|
339
|
-
status: response.status,
|
|
340
|
-
requestId: response.headers.get("x-request-id") ?? undefined,
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
}
|
|
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 validateCustomerEnvironmentGrant(baseUrl, grant) {
|
|
423
|
-
if (!/^[A-Za-z0-9_.-]{1,128}$/u.test(grant.grant_id)) {
|
|
424
|
-
throw new SessionError("Aex returned an invalid customer Environment grant id");
|
|
425
|
-
}
|
|
426
|
-
if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,2048}$/u.test(grant.protocol)) {
|
|
427
|
-
throw new SessionError("Aex returned an invalid customer Environment WebSocket protocol");
|
|
428
|
-
}
|
|
429
|
-
if (!/^[\x21-\x7e]{1,2048}$/u.test(grant.observation_token)) {
|
|
430
|
-
throw new SessionError("Aex returned an invalid customer Environment 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-environment/observations/${encodeURIComponent(grant.grant_id)}`);
|
|
439
|
-
}
|
|
440
|
-
catch (cause) {
|
|
441
|
-
throw new SessionError("Aex returned an invalid customer Environment 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 Environment 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 Environment 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) {
|
|
576
|
-
const reader = stream.getReader();
|
|
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
|
-
};
|
|
593
|
-
const dispatch = () => {
|
|
594
|
-
if (dataLines === 0) {
|
|
595
|
-
reset();
|
|
596
|
-
return undefined;
|
|
597
|
-
}
|
|
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();
|
|
607
|
-
try {
|
|
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 };
|
|
624
|
-
}
|
|
625
|
-
catch (cause) {
|
|
626
|
-
if (cause instanceof SessionError)
|
|
627
|
-
throw cause;
|
|
628
|
-
throw new SessionError("Aex sent an invalid event", { cause });
|
|
629
|
-
}
|
|
630
|
-
};
|
|
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();
|
|
645
|
-
return dispatch();
|
|
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)
|
|
677
|
-
return undefined;
|
|
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;
|
|
689
|
-
return undefined;
|
|
690
|
-
};
|
|
691
|
-
try {
|
|
692
|
-
while (true) {
|
|
693
|
-
const { done, value } = await reader.read();
|
|
694
|
-
if (done)
|
|
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
|
-
}
|
|
711
|
-
}
|
|
712
|
-
if (lineLength > 0 || frameStarted || dataLines > 0) {
|
|
713
|
-
throw new SessionError("Aex event stream ended in a truncated SSE frame");
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
finally {
|
|
717
|
-
await reader.cancel().catch(() => undefined);
|
|
718
|
-
reader.releaseLock();
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
function isEphemeralEvent(event) {
|
|
722
|
-
return event.type === "assistant.delta" || event.type === "tool.output" ||
|
|
723
|
-
event.type === "replay.complete";
|
|
724
|
-
}
|
|
725
|
-
function isAbort(error) {
|
|
726
|
-
return error instanceof AbortError || (error instanceof DOMException && error.name === "AbortError");
|
|
727
|
-
}
|
|
728
|
-
function delay(milliseconds, signal) {
|
|
729
|
-
if (signal?.aborted === true)
|
|
730
|
-
return Promise.reject(abortError(signal.reason));
|
|
731
|
-
return new Promise((resolve, reject) => {
|
|
732
|
-
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
|
733
|
-
const timer = setTimeout(() => {
|
|
734
|
-
cleanup();
|
|
735
|
-
resolve();
|
|
736
|
-
}, milliseconds);
|
|
737
|
-
const onAbort = () => {
|
|
738
|
-
clearTimeout(timer);
|
|
739
|
-
cleanup();
|
|
740
|
-
reject(abortError(signal?.reason));
|
|
741
|
-
};
|
|
742
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
743
|
-
});
|
|
744
|
-
}
|