mandala-computer-mcp 0.1.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +544 -0
  3. package/dist/api.d.ts +186 -0
  4. package/dist/api.d.ts.map +1 -0
  5. package/dist/api.js +932 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/cli.d.ts +55 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +292 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/errors.d.ts +560 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +873 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/events.d.ts +406 -0
  16. package/dist/events.d.ts.map +1 -0
  17. package/dist/events.js +1679 -0
  18. package/dist/events.js.map +1 -0
  19. package/dist/format.d.ts +125 -0
  20. package/dist/format.d.ts.map +1 -0
  21. package/dist/format.js +180 -0
  22. package/dist/format.js.map +1 -0
  23. package/dist/http.d.ts +46 -0
  24. package/dist/http.d.ts.map +1 -0
  25. package/dist/http.js +792 -0
  26. package/dist/http.js.map +1 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +12 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/paths.d.ts +394 -0
  32. package/dist/paths.d.ts.map +1 -0
  33. package/dist/paths.js +677 -0
  34. package/dist/paths.js.map +1 -0
  35. package/dist/server.d.ts +18 -0
  36. package/dist/server.d.ts.map +1 -0
  37. package/dist/server.js +97 -0
  38. package/dist/server.js.map +1 -0
  39. package/dist/session.d.ts +78 -0
  40. package/dist/session.d.ts.map +1 -0
  41. package/dist/session.js +166 -0
  42. package/dist/session.js.map +1 -0
  43. package/dist/stdio.d.ts +11 -0
  44. package/dist/stdio.d.ts.map +1 -0
  45. package/dist/stdio.js +43 -0
  46. package/dist/stdio.js.map +1 -0
  47. package/dist/tools/agent.d.ts +16 -0
  48. package/dist/tools/agent.d.ts.map +1 -0
  49. package/dist/tools/agent.js +147 -0
  50. package/dist/tools/agent.js.map +1 -0
  51. package/dist/tools/computers.d.ts +3 -0
  52. package/dist/tools/computers.d.ts.map +1 -0
  53. package/dist/tools/computers.js +1037 -0
  54. package/dist/tools/computers.js.map +1 -0
  55. package/dist/tools/events.d.ts +3 -0
  56. package/dist/tools/events.d.ts.map +1 -0
  57. package/dist/tools/events.js +1077 -0
  58. package/dist/tools/events.js.map +1 -0
  59. package/dist/tools/guest.d.ts +3 -0
  60. package/dist/tools/guest.d.ts.map +1 -0
  61. package/dist/tools/guest.js +761 -0
  62. package/dist/tools/guest.js.map +1 -0
  63. package/dist/tools/input.d.ts +3 -0
  64. package/dist/tools/input.d.ts.map +1 -0
  65. package/dist/tools/input.js +240 -0
  66. package/dist/tools/input.js.map +1 -0
  67. package/dist/tools/snapshots.d.ts +3 -0
  68. package/dist/tools/snapshots.d.ts.map +1 -0
  69. package/dist/tools/snapshots.js +333 -0
  70. package/dist/tools/snapshots.js.map +1 -0
  71. package/dist/tools/templates.d.ts +3 -0
  72. package/dist/tools/templates.d.ts.map +1 -0
  73. package/dist/tools/templates.js +492 -0
  74. package/dist/tools/templates.js.map +1 -0
  75. package/dist/tools/types.d.ts +18 -0
  76. package/dist/tools/types.d.ts.map +1 -0
  77. package/dist/tools/types.js +2 -0
  78. package/dist/tools/types.js.map +1 -0
  79. package/dist/tools/webhooks.d.ts +3 -0
  80. package/dist/tools/webhooks.d.ts.map +1 -0
  81. package/dist/tools/webhooks.js +260 -0
  82. package/dist/tools/webhooks.js.map +1 -0
  83. package/package.json +59 -0
package/dist/api.js ADDED
@@ -0,0 +1,932 @@
1
+ import { Agent, fetch as undiciFetch } from 'undici';
2
+ import { CancelledError, ConnectivityError, ConnectivityInterruptedError, errorForStatus, MandalaError, RangeNotSatisfiableError, RateLimitError, } from './errors.js';
3
+ export const DEFAULT_BASE_URL = 'https://app.mandala.computer/api/v1';
4
+ /** Anthropic's own key, forwarded for the one route that runs a model. */
5
+ export const MODEL_KEY_HEADER = 'X-Model-Key';
6
+ /**
7
+ * How much of an event stream will be held while waiting for a boundary.
8
+ *
9
+ * Generous for any real event — a run's steps are small — and finite, which is
10
+ * the point: without it a stream that never sends a blank line is buffered
11
+ * until the process runs out of memory.
12
+ */
13
+ const MAX_SSE_BUFFER = 8 * 1024 * 1024;
14
+ /** Finite response-body ceilings for the two paths that decode text. */
15
+ const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024;
16
+ const MAX_ERROR_BODY_BYTES = 1024 * 1024;
17
+ /**
18
+ * The longest guest exec waits 300 seconds before it answers. Node's bundled
19
+ * fetch also gives response headers 300 seconds by default, so the client can
20
+ * lose that race while the command is still finishing in the guest. Keep the
21
+ * public exec limit and give the platform enough time to report its timeout.
22
+ *
23
+ * The body is a different clock. undici's default `bodyTimeout` is 300 seconds
24
+ * of silence *between chunks*, and `run_agent` SSE (or a long exec that has
25
+ * already sent headers) can sit quiet after that. Raising only the header
26
+ * allowance left those streams aborting on the default idle limit. Zero
27
+ * disables it: a quiet gap is not a dead connection, and the caller's
28
+ * AbortSignal is what ends a request nobody is waiting for.
29
+ */
30
+ export const PLATFORM_HEADERS_TIMEOUT_MS = 330_000;
31
+ /** Disabled. A finite idle limit is what used to kill a quiet SSE stream. */
32
+ export const PLATFORM_BODY_TIMEOUT_MS = 0;
33
+ const PLATFORM_DISPATCHER = new Agent({
34
+ headersTimeout: PLATFORM_HEADERS_TIMEOUT_MS,
35
+ bodyTimeout: PLATFORM_BODY_TIMEOUT_MS,
36
+ });
37
+ /**
38
+ * `globalThis.fetch` as it was before anything replaced it.
39
+ *
40
+ * Captured so {@link platformFetch} can tell "nobody has touched this" from "a
41
+ * test or an embedder installed their own", which are the two cases that need
42
+ * opposite answers below.
43
+ */
44
+ const NATIVE_FETCH = globalThis.fetch;
45
+ /**
46
+ * The fetch a platform request actually goes through, and why it is not simply
47
+ * `fetch`.
48
+ *
49
+ * The dispatcher above is an Agent from the `undici` PACKAGE, and Node's
50
+ * built-in fetch is a DIFFERENT COPY of undici — the one bundled with the
51
+ * runtime. Handing one's Agent to the other's fetch works only while the two
52
+ * agree on the internal handler interface, and they have stopped agreeing:
53
+ * Node 26 bundles undici 8.9, whose fetch passes a handler that undici 6's
54
+ * Agent rejects outright with `invalid onError method`. That surfaces here as
55
+ * `fetch failed`, which this class then wraps as "could not reach
56
+ * app.mandala.computer" — so on Node 26 every call this server makes reported
57
+ * the platform as down, before a packet was sent.
58
+ *
59
+ * NOT FIXABLE BY A VERSION BUMP, which is the thing worth writing down: npm's
60
+ * newest undici is 7.x and Node 26 bundles 8.x, so no dependency this package
61
+ * can declare matches what the runtime carries — and even if one did, matching
62
+ * Node 26 would mean mismatching Node 20, which `engines` still admits. Two
63
+ * undicis is the bug; using one of them for both halves is the fix.
64
+ *
65
+ * So the request goes through undici's OWN fetch, which understands its own
66
+ * Agent on every Node. The global is still preferred when something has
67
+ * replaced it: that is how the tests stand a stub in front of the platform, and
68
+ * an embedder that installs an instrumented fetch means it to be used.
69
+ */
70
+ export const platformFetch = () => globalThis.fetch === NATIVE_FETCH
71
+ ? undiciFetch
72
+ : globalThis.fetch;
73
+ /**
74
+ * The transport for one API key.
75
+ *
76
+ * One per MCP session rather than one per process, because the HTTP transport
77
+ * authenticates each caller with their own `com_…` key and two sessions must
78
+ * never share a client. See `src/session.ts`.
79
+ *
80
+ * The key lives in this object's closure and is never put on an error, a log
81
+ * line, or a tool result. That is not paranoia about our own code: an MCP tool
82
+ * result goes into a model's context and from there into transcripts, and an
83
+ * API key is every computer on the account, forever.
84
+ */
85
+ export class Api {
86
+ baseUrl;
87
+ /** The same thing parsed, so a path is joined onto the path and nothing else. */
88
+ #base;
89
+ #apiKey;
90
+ #headers;
91
+ /** Applied to every request that does not carry one of its own. See `with`. */
92
+ #signal;
93
+ constructor(apiKey, baseUrl = DEFAULT_BASE_URL, signal) {
94
+ if (!apiKey) {
95
+ throw new MandalaError('No API key. Set MANDALA_API_KEY (create one at Settings → API keys), ' +
96
+ 'or send it as a bearer token when running over HTTP.');
97
+ }
98
+ // Validated here rather than at the first request. An unusable base URL is
99
+ // a configuration mistake, and the place to report one is where it is set —
100
+ // not in the middle of a tool call, and not, as it was, from a startup log
101
+ // line that threw after the transport had already come up and the client
102
+ // was waiting on it.
103
+ let parsed;
104
+ try {
105
+ parsed = new URL(baseUrl);
106
+ }
107
+ catch {
108
+ throw new MandalaError(`not a valid base URL: ${baseUrl}. Set MANDALA_BASE_URL to an absolute http(s) URL, e.g. ${DEFAULT_BASE_URL}`);
109
+ }
110
+ // The scheme the message already promised. `new URL` alone accepts
111
+ // `file:`, `ftp:` and anything else with a colon in it, so a typo that
112
+ // parsed was carried all the way to a fetch that fails with something about
113
+ // the protocol — a message about the request, in a place that was supposed
114
+ // to be about the setting.
115
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
116
+ throw new MandalaError(`not an http(s) base URL: ${baseUrl}. Set MANDALA_BASE_URL to an absolute http(s) URL, e.g. ${DEFAULT_BASE_URL}`);
117
+ }
118
+ // Normalised as a URL rather than as a string. `${base}/${path}` looked
119
+ // equivalent and is not, because a base may carry a query — a tenant or an
120
+ // API version — and string concatenation appends the path *into* the search
121
+ // string: `https://h/api/v1?t=x` + `computers` is
122
+ // `https://h/api/v1?t=x/computers`, a request to /api/v1 with a nonsense
123
+ // parameter rather than to the route the tool asked for. The trailing-slash
124
+ // strip had the same blind spot, since the slash is no longer last.
125
+ parsed.hash = '';
126
+ parsed.pathname = parsed.pathname.replace(/\/+$/, '');
127
+ this.#base = parsed;
128
+ // Still the string that was given, minus the trailing slashes it was always
129
+ // stripped of — this is what error messages name and what `with` re-parses,
130
+ // and changing its spelling would change what a reader is told they
131
+ // configured.
132
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
133
+ this.#apiKey = apiKey;
134
+ this.#signal = signal;
135
+ this.#headers = {
136
+ Authorization: `Bearer ${apiKey}`,
137
+ Accept: 'application/json',
138
+ };
139
+ }
140
+ /**
141
+ * This same client, with every request bound to one tool call's cancellation.
142
+ *
143
+ * MCP hands a tool handler an `AbortSignal` that fires when the client gives
144
+ * up on the call, and a request nobody is waiting for is one this server
145
+ * should stop making — most of all in the tools that poll. A cancelled
146
+ * `wait_for_computer` would otherwise go on asking the platform about a
147
+ * computer for the rest of its `timeout_s`, which reaches fifteen minutes.
148
+ *
149
+ * Bound per call rather than per session, because a session serves many calls
150
+ * at once and one of them being abandoned says nothing about the others.
151
+ */
152
+ with(signal) {
153
+ if (!signal || signal === this.#signal)
154
+ return this;
155
+ return new Api(this.#apiKey, this.baseUrl, signal);
156
+ }
157
+ #url(path, query) {
158
+ const url = new URL(this.#base);
159
+ // Onto the path component, keeping whatever the base carried in its query.
160
+ // A base's own parameters are part of how it was addressed — a tenant, a
161
+ // version — and dropping them would send the request somewhere else just as
162
+ // surely as appending the path to them did.
163
+ //
164
+ // A root pathname contributes NOTHING rather than its slash. The constructor
165
+ // strips trailing slashes, but a base that is only an origin has `/` for a
166
+ // pathname and the WHATWG setter puts it straight back — so the join wrote
167
+ // `https://gateway.example.com//computers`, a double slash that is a
168
+ // different path to any router that normalises and a 404 to one that does
169
+ // not. Invisible on the default base, which carries `/api/v1`; the case it
170
+ // breaks is a self-hosted MANDALA_BASE_URL whose API sits at the root.
171
+ const base = url.pathname === '/' ? '' : url.pathname;
172
+ url.pathname = `${base}/${path.replace(/^\/+/, '')}`;
173
+ for (const [k, v] of Object.entries(query ?? {})) {
174
+ if (v !== undefined)
175
+ url.searchParams.set(k, String(v));
176
+ }
177
+ return url.toString();
178
+ }
179
+ async #fetch(method, path, opts = {}) {
180
+ const headers = { ...this.#headers, ...opts.headers };
181
+ // Typed as what we actually build rather than as BodyInit, which @types/node
182
+ // does not put in the global scope.
183
+ let body;
184
+ if (opts.raw !== undefined) {
185
+ // The file upload's body IS the file. Content-Type is deliberately
186
+ // octet-stream rather than guessed from the path: the platform writes the
187
+ // bytes it is given and never looks, and a wrong guess here would be a
188
+ // claim about a file we did not read.
189
+ headers['Content-Type'] = 'application/octet-stream';
190
+ body = opts.raw;
191
+ }
192
+ else if (opts.body !== undefined) {
193
+ headers['Content-Type'] = 'application/json';
194
+ body = JSON.stringify(opts.body);
195
+ }
196
+ const signal = opts.signal ?? this.#signal;
197
+ let resp;
198
+ try {
199
+ // `dispatcher` is Node/undici's extension to RequestInit. It is kept on
200
+ // a typed variable so the standard fetch signature can still be used.
201
+ const init = {
202
+ method,
203
+ headers,
204
+ body,
205
+ signal,
206
+ dispatcher: PLATFORM_DISPATCHER,
207
+ };
208
+ resp = await platformFetch()(this.#url(path, opts.query), init);
209
+ }
210
+ catch (cause) {
211
+ // Cancellation first, because it is not a connectivity failure and the
212
+ // wrap below cannot tell the difference. An aborted fetch rejects with a
213
+ // bare `This operation was aborted`, so every cancelled tool call — and
214
+ // an MCP client's own 60s request timeout makes those routine — reported
215
+ // the platform as unreachable. Two readers were misled by that: the model,
216
+ // which retries a connectivity failure and does not retry a cancellation,
217
+ // and the wait loops, which had to test the signal themselves precisely
218
+ // because the message arriving here said nothing true about the cause.
219
+ if (isCancellation(cause, signal)) {
220
+ throw cancellationError(method, path, 'before the platform answered');
221
+ }
222
+ // Rewritten, because the raw one names the host and the failure a model
223
+ // can act on is "the platform is not reachable", not a DNS error string.
224
+ //
225
+ // Two classes, because a rejected fetch is two different outcomes wearing
226
+ // one shape. A refused socket means nothing was dispatched and a create
227
+ // may be replayed; a socket that died with the request already on the
228
+ // wire means the platform may have acted and the answer was lost. The
229
+ // second says so, and the wording follows the class rather than the other
230
+ // way round (OPL-3855).
231
+ const detail = cause instanceof Error ? cause.message : String(cause);
232
+ if (neverDispatched(cause)) {
233
+ throw new ConnectivityError(`could not reach ${this.#base.origin}: ${detail}`);
234
+ }
235
+ throw new ConnectivityInterruptedError(`${method} /${path.replace(/^\/+/, '')} to ${this.#base.origin} failed after the request ` +
236
+ `was sent: ${detail}. It may have been received, so treat anything it would have ` +
237
+ 'changed as unknown rather than undone.');
238
+ }
239
+ if (!resp.ok)
240
+ throw await this.#error(resp, method, path, signal);
241
+ return resp;
242
+ }
243
+ /**
244
+ * The platform's own message, when it sent one.
245
+ *
246
+ * Worth the trouble: these messages are written to be acted on — "send a new
247
+ * name or a new size, not both", "this computer was built from a golden image
248
+ * that predates window actions" — and replacing them with a status line would
249
+ * throw away the only part of the response a model can do anything with.
250
+ */
251
+ async #error(resp, method, path, signal) {
252
+ let body;
253
+ let message = `HTTP ${resp.status}`;
254
+ let text = '';
255
+ let truncated = false;
256
+ try {
257
+ ({ text, truncated } = await readBody(method, path, signal, () => readTextAtMost(resp, MAX_ERROR_BODY_BYTES)));
258
+ }
259
+ catch (cause) {
260
+ // A response whose error body itself is broken still has a useful status.
261
+ // Cancellation is different: the caller deliberately ended this read and
262
+ // must not be told the platform answered with an ordinary HTTP failure.
263
+ if (cause instanceof CancelledError)
264
+ throw cause;
265
+ }
266
+ if (text) {
267
+ try {
268
+ // A prefix is not JSON even when it happens to end at a syntactically
269
+ // valid boundary. Only trust a structured platform message after the
270
+ // entire body arrived.
271
+ if (truncated)
272
+ throw new SyntaxError('truncated response body');
273
+ body = JSON.parse(text);
274
+ const err = body?.error;
275
+ if (typeof err === 'string' && err)
276
+ message = err;
277
+ else
278
+ message = text.slice(0, 500);
279
+ }
280
+ catch {
281
+ message = text.slice(0, 500);
282
+ // The bounded page prefix, not the 500-character message. errorForStatus replaces
283
+ // the message on every edge status with wording of its own, and this is
284
+ // the only copy of what the edge actually said — a Cloudflare Ray ID
285
+ // lives in that HTML and nowhere else, and it is the first thing
286
+ // support asks for. It sits in the footer of a page that runs to
287
+ // several KB, so slicing to 500 for the message would throw away the
288
+ // one field this exists to keep. The separate body cap prevents a
289
+ // hostile or broken response from turning that diagnostic into an
290
+ // unbounded allocation. Shown to nobody; available to whoever needs it.
291
+ body = text;
292
+ }
293
+ }
294
+ // The one status whose headers say more than its body does. `Content-Range:
295
+ // bytes *\/<size>` carries the file's real length, and errorForStatus takes
296
+ // no headers — deliberately, since every other status it maps is decided by
297
+ // the number alone. So this one is built here, where the response is still
298
+ // in hand, and the length rides on the error to whoever asked for the range.
299
+ if (resp.status === 416) {
300
+ const total = parseContentRange(resp.headers.get('content-range'))?.total;
301
+ return new RangeNotSatisfiableError(message, resp.status, body, total);
302
+ }
303
+ // The other one, for the same reason: `Retry-After` is a header, and it is
304
+ // the platform saying how long to wait rather than leaving the wait tools
305
+ // to guess. Built here while the response is still in hand; the BY_STATUS
306
+ // entry covers a 429 reaching errorForStatus from anywhere else, without
307
+ // the number.
308
+ if (resp.status === 429) {
309
+ return new RateLimitError(message, resp.status, body, retryAfterMs(resp.headers.get('retry-after')));
310
+ }
311
+ return errorForStatus(resp.status, message, body);
312
+ }
313
+ /**
314
+ * A JSON body, or nothing, or a named failure.
315
+ *
316
+ * Shared by `json` and `listing` so the two cannot disagree about what a
317
+ * non-JSON 200 is. That is not hypothetical tidiness: a captive portal or a
318
+ * misconfigured proxy answers 200 with an HTML page, and the difference
319
+ * between `expected JSON from GET /computers, got: <!DOCTYPE html…` and a
320
+ * bare `SyntaxError: Unexpected token '<'` is whether the reader learns which
321
+ * request went wrong.
322
+ */
323
+ async #decode(resp, method, path, signal) {
324
+ if (resp.status === 204)
325
+ return undefined;
326
+ const { text, truncated } = await readBody(method, path, signal, () => readTextAtMost(resp, MAX_JSON_BODY_BYTES));
327
+ if (truncated) {
328
+ throw new MandalaError(`${method} ${path} sent more than ${MAX_JSON_BODY_BYTES} bytes of JSON; refusing to buffer the rest`);
329
+ }
330
+ if (!text)
331
+ return undefined;
332
+ try {
333
+ return JSON.parse(text);
334
+ }
335
+ catch {
336
+ throw new MandalaError(`expected JSON from ${method} ${path}, got: ${text.slice(0, 200)}`);
337
+ }
338
+ }
339
+ /**
340
+ * A JSON body, from a route that is supposed to have one.
341
+ *
342
+ * An empty answer here is a failure, not a value, and it has to be said so
343
+ * rather than cast away. `as T` was a lie the compiler could not catch: a 204
344
+ * on a route that should have answered handed every caller `undefined` typed
345
+ * as present, and what a caller does with that is either `text: undefined` —
346
+ * which is not a valid tool result, so the client rejects the whole call with
347
+ * a schema error naming nothing useful — or a TypeError reading a field off
348
+ * it. Both report the platform's silence as this server's own bug.
349
+ *
350
+ * Routes where an empty body IS the answer use `send`.
351
+ */
352
+ async json(method, path, opts = {}) {
353
+ const resp = await this.#fetch(method, path, opts);
354
+ const body = await this.#decode(resp, method, path, opts.signal ?? this.#signal);
355
+ if (body === undefined || body === null) {
356
+ throw new MandalaError(`${method} ${path} answered ${resp.status} with ${body === null ? 'JSON null' : 'an empty body'}, where a JSON value was expected`);
357
+ }
358
+ return body;
359
+ }
360
+ /**
361
+ * A request whose answer may legitimately be nothing.
362
+ *
363
+ * The DELETEs and the acknowledgements: /api/v1 answers some of them with a
364
+ * body worth repeating and some with a 204, and both are correct. Typed as
365
+ * possibly-absent so a caller has to decide what to say when it is.
366
+ */
367
+ async send(method, path, opts = {}) {
368
+ const resp = await this.#fetch(method, path, opts);
369
+ return this.#decode(resp, method, path, opts.signal ?? this.#signal);
370
+ }
371
+ /**
372
+ * A collection read that the platform may have had to answer short.
373
+ *
374
+ * `GET /computers` and `GET /snapshots` are fan-outs across the fleet, so a
375
+ * hypervisor nobody can reach makes the answer incomplete. /api/v1 fails
376
+ * closed about that — without `allow_partial` a short listing is a 503, not a
377
+ * short 200 — but a caller that opts in gets the list plus `X-GC-Incomplete`,
378
+ * and a header is only a warning if something reads it.
379
+ *
380
+ * It is the count of what the placement cache could account for, and it is
381
+ * legitimately `0`: a computer created during the outage was never cached
382
+ * against the host now holding it. So presence is the signal and the number is
383
+ * detail, which is why this returns `null` versus a number rather than a
384
+ * count that means nothing at zero.
385
+ */
386
+ async listing(path, opts = {}) {
387
+ const resp = await this.#fetch('GET', path, opts);
388
+ const short = resp.headers.get('X-GC-Incomplete');
389
+ return {
390
+ // `T | undefined` and not `T`, because an empty body is a real answer
391
+ // here. Typing it as present would let a caller write `items.length`
392
+ // against a value the compiler had been told could not be missing.
393
+ items: await this.#decode(resp, 'GET', path, opts.signal ?? this.#signal),
394
+ incomplete: short === null ? null : Number(short),
395
+ };
396
+ }
397
+ /** For the two routes whose body is not JSON: the screenshot and the download. */
398
+ async bytes(method, path, opts = {}, maxBytes) {
399
+ const resp = await this.#fetch(method, path, opts);
400
+ const contentType = mediaType(resp.headers.get('content-type'));
401
+ const limit = typeof maxBytes === 'function' ? maxBytes(contentType) : maxBytes;
402
+ const declared = contentLength(resp);
403
+ // Only off a 206. See Bytes.window: the status is the promise, and a 200
404
+ // carrying a stray Content-Range would otherwise be read as one.
405
+ const served = resp.status === 206 ? parseContentRange(resp.headers.get('content-range')) : undefined;
406
+ const window = served?.start !== undefined && served.end !== undefined
407
+ ? { start: served.start, end: served.end, total: served.total }
408
+ : undefined;
409
+ // A 206 is a promise that these bytes are a PART of something, and the
410
+ // Content-Range is the only thing that says which part. Without a readable
411
+ // one the response is indistinguishable from a whole-file 200 — same
412
+ // status-free shape, `truncated` false, no window — so a caller stitching a
413
+ // file writes a middle chunk at offset zero, and a caller paging one calls
414
+ // it complete and stops. Refused rather than assumed to start at zero,
415
+ // because assuming is the exact failure the status exists to prevent, and
416
+ // because nothing downstream can tell the difference afterwards.
417
+ //
418
+ // The platform always sends the header (`bytes %d-%d/%d` in server/api.go).
419
+ // A hop in front of it that drops the header is the case this is for, and
420
+ // the same one mandala-computer-typescript's toFileChunk refuses.
421
+ if (resp.status === 206 && !window) {
422
+ await resp.body?.cancel().catch(() => { });
423
+ throw new MandalaError(`${method} ${path} answered 206 without a readable Content-Range ` +
424
+ `(${resp.headers.get('content-range') ?? 'header absent'}), so where these bytes ` +
425
+ 'belong in the file is unknown');
426
+ }
427
+ const { bytes, truncated } = await readBody(method, path, opts.signal ?? this.#signal, async () => limit === undefined
428
+ ? { bytes: new Uint8Array(await resp.arrayBuffer()), truncated: false }
429
+ : await readAtMost(resp, limit));
430
+ return {
431
+ bytes,
432
+ contentType,
433
+ filename: filenameFrom(resp.headers.get('content-disposition')),
434
+ truncated,
435
+ // The window's total first, because on a partial response every other
436
+ // number here is about the window: Content-Length is how long THIS body
437
+ // is, and `bytes.length` is how much of it was kept. Reading either as
438
+ // the file's size is how a caller decides it has the whole thing.
439
+ totalBytes: window?.total !== undefined
440
+ ? window.total
441
+ : truncated
442
+ ? declared !== undefined && declared > bytes.length
443
+ ? declared
444
+ : undefined
445
+ : // A 206 whose Content-Range said `*`: the window arrived in full
446
+ // and the file's length is still unknown, so this must not fall
447
+ // through to `bytes.length`, which would call the window the file.
448
+ window
449
+ ? undefined
450
+ : bytes.length,
451
+ unrangeable: (resp.headers.get('accept-ranges') ?? '').trim().toLowerCase() === 'none',
452
+ window,
453
+ };
454
+ }
455
+ /**
456
+ * The agent route, which answers with a stream of steps rather than a result.
457
+ *
458
+ * Yielded rather than collected so the caller can report progress while the
459
+ * run is going. A run is minutes of clicking; a tool that says nothing until
460
+ * it is over is one the person watching cannot tell from a hang.
461
+ */
462
+ async *sse(method, path, opts = {}) {
463
+ const resp = await this.#fetch(method, path, {
464
+ ...opts,
465
+ headers: { ...opts.headers, Accept: 'text/event-stream' },
466
+ });
467
+ const contentType = mediaType(resp.headers.get('content-type'));
468
+ if (contentType !== 'text/event-stream') {
469
+ throw new MandalaError(`${method} ${path} expected text/event-stream, but the platform answered ${contentType}`);
470
+ }
471
+ if (!resp.body)
472
+ throw new MandalaError(`${method} ${path} answered with no body`);
473
+ const reader = resp.body.getReader();
474
+ const decoder = new TextDecoder();
475
+ let buffer = '';
476
+ try {
477
+ for (;;) {
478
+ const { done, value } = await readBody(method, path, opts.signal ?? this.#signal, () => reader.read());
479
+ if (done)
480
+ break;
481
+ // Buffered exactly as it arrived. Rewriting terminators per chunk was
482
+ // the tempting shortcut and is wrong: a CRLF split across two reads
483
+ // becomes CR-then-LF, each rewritten to its own LF, and the pair reads
484
+ // as the blank line that ends an event — so a frame gets cut in half at
485
+ // a boundary that was never in the stream.
486
+ buffer += decoder.decode(value, { stream: true });
487
+ // Events are separated by a blank line, in whichever of the three
488
+ // terminators the sender chose: the spec allows CRLF, LF and lone CR,
489
+ // and a proxy that reframes the stream is entitled to any of them.
490
+ // Matching only "\n\n" found no boundary at all in a CRLF stream, which
491
+ // collapsed a whole run into one unparseable event and lost the result
492
+ // of a run that had in fact succeeded.
493
+ for (;;) {
494
+ const sep = /\r?\n\r?\n|\r\r/.exec(buffer);
495
+ // A tail of "\r\n\r" is deliberately not a boundary yet — the LF that
496
+ // would complete it may be in the next read.
497
+ if (!sep)
498
+ break;
499
+ const chunk = buffer.slice(0, sep.index);
500
+ buffer = buffer.slice(sep.index + sep[0].length);
501
+ const parsed = parseEvent(chunk);
502
+ if (parsed)
503
+ yield parsed;
504
+ }
505
+ // Checked on what the drain could not consume, not on what arrived. A
506
+ // stream that never sends a boundary is buffered forever otherwise:
507
+ // this only bounds the unparseable remainder, so a single read that
508
+ // happens to carry more than the limit in well-formed, boundary-
509
+ // separated events is no longer mistaken for one giant event and the
510
+ // message stays true to what it says — no boundary was found in this.
511
+ if (buffer.length > MAX_SSE_BUFFER) {
512
+ throw new MandalaError(`${method} ${path} sent ${buffer.length} characters with no event boundary; giving up rather than buffering the rest of the stream.`);
513
+ }
514
+ }
515
+ // Flushed before the tail is parsed. Every chunk decodes with
516
+ // `{stream: true}`, which holds an incomplete multi-byte sequence back
517
+ // for the next read; on a stream that ends mid-character those bytes are
518
+ // simply dropped without this, rather than surfacing as the replacement
519
+ // character that says something was lost.
520
+ buffer += decoder.decode();
521
+ const tail = parseEvent(buffer);
522
+ if (tail)
523
+ yield tail;
524
+ }
525
+ finally {
526
+ await reader.cancel().catch(() => { });
527
+ }
528
+ }
529
+ }
530
+ /**
531
+ * Was this rejection the caller hanging up, rather than the network?
532
+ *
533
+ * The watched signal is the only reliable answer. `AbortSignal.abort(reason)`
534
+ * rejects the fetch with whatever reason was given, which may be any value at
535
+ * all, so the error name cannot be relied on to say what happened. An undici
536
+ * body or idle timeout is also an `AbortError` / `TimeoutError` /
537
+ * `BodyTimeoutError` without that signal ever having fired — those are
538
+ * transport failures. Calling them a cancellation sent wait loops down the
539
+ * "the caller gave up" path while the caller was still waiting.
540
+ */
541
+ function isCancellation(_cause, signal) {
542
+ return Boolean(signal?.aborted);
543
+ }
544
+ /**
545
+ * Every error under one, including the ones a fetch hides two levels down.
546
+ *
547
+ * A rejected fetch is a `TypeError: fetch failed` whose `cause` is what
548
+ * actually went wrong, and on a dual-stack host that cause is an
549
+ * `AggregateError` holding one attempt per address. Neither the top error nor
550
+ * its immediate cause carries the code the classifiers below read, so both
551
+ * links have to be followed. Bounded, because a cause chain is user-reachable
552
+ * data and nothing here needs to be robust to a cycle.
553
+ */
554
+ function* causes(err, depth = 0) {
555
+ if (!err || typeof err !== 'object' || depth > 5)
556
+ return;
557
+ const e = err;
558
+ yield e;
559
+ yield* causes(e.cause, depth + 1);
560
+ if (Array.isArray(e.errors)) {
561
+ for (const inner of e.errors)
562
+ yield* causes(inner, depth + 1);
563
+ }
564
+ }
565
+ /**
566
+ * TLS failures that can only happen before the handshake finishes.
567
+ *
568
+ * NAMED IN FULL, with no prefix test, and that is the correction worth
569
+ * recording. This started as `ERR_SSL_` and `ERR_TLS_` prefixes, and NEITHER
570
+ * prefix means "handshake". Node spells every OpenSSL reason `ERR_SSL_`,
571
+ * including the fatal alerts a peer can send on any record — a TLS-terminating
572
+ * proxy that dies mid-response answers `ERR_SSL_TLSV1_ALERT_INTERNAL_ERROR`,
573
+ * and a corrupted record answers `ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC`. Both
574
+ * arrive with the request long since on the wire. `ERR_TLS_` is narrower and
575
+ * still not safe: `ERR_TLS_RENEGOTIATION_DISABLED` is by definition
576
+ * mid-connection. A prefix that admits those puts a possibly-dispatched
577
+ * failure into the class that says nothing was sent, which is the one mistake
578
+ * this whole function exists to avoid.
579
+ *
580
+ * So: an explicit set, holding certificate verification results (OpenSSL's,
581
+ * which carry no prefix), the protocol mismatches that can only be diagnosed
582
+ * from the first record, and the two Node codes that are genuinely handshake
583
+ * events. Add to it when a new one turns up. A missing entry costs an embedder
584
+ * one blind retry it could have made; a wrong entry costs a second billable
585
+ * computer, so the set stays short on purpose.
586
+ */
587
+ const TLS_CODES = new Set([
588
+ // Certificate verification, from OpenSSL. All of these end the handshake.
589
+ 'CERT_HAS_EXPIRED',
590
+ 'CERT_NOT_YET_VALID',
591
+ 'CERT_REVOKED',
592
+ 'CERT_SIGNATURE_FAILURE',
593
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
594
+ 'HOSTNAME_MISMATCH',
595
+ 'SELF_SIGNED_CERT_IN_CHAIN',
596
+ 'UNABLE_TO_GET_ISSUER_CERT',
597
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
598
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
599
+ // Node's own TLS layer, for the two events that are the handshake itself.
600
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
601
+ 'ERR_TLS_HANDSHAKE_TIMEOUT',
602
+ // Protocol mismatches, diagnosable only from the first record on the wire.
603
+ // `ERR_SSL_WRONG_VERSION_NUMBER` is what https onto a plaintext port gives.
604
+ 'ERR_SSL_NO_CIPHERS_AVAILABLE',
605
+ 'ERR_SSL_NO_PROTOCOLS_AVAILABLE',
606
+ 'ERR_SSL_NO_SHARED_CIPHER',
607
+ 'ERR_SSL_PACKET_LENGTH_TOO_LONG',
608
+ 'ERR_SSL_UNKNOWN_PROTOCOL',
609
+ 'ERR_SSL_UNSUPPORTED_PROTOCOL',
610
+ 'ERR_SSL_VERSION_TOO_LOW',
611
+ 'ERR_SSL_WRONG_VERSION_NUMBER',
612
+ ]);
613
+ /**
614
+ * Can this rejection be shown to have happened BEFORE the request was written?
615
+ *
616
+ * The one question that decides whether {@link ConnectivityError} or
617
+ * {@link ConnectivityInterruptedError} comes out of `#fetch`, and therefore
618
+ * whether `isTransient` tells an embedder a create is safe to replay.
619
+ *
620
+ * FAIL CLOSED, which is the whole design. The two wrong answers do not cost the
621
+ * same: calling a connect failure a possible dispatch costs one retry that a
622
+ * caller could have made blind, and calling a lost response a connect failure
623
+ * costs a second billable computer. So this is an ALLOW-LIST of causes that can
624
+ * only arise from the connector, and everything else — anything unrecognised,
625
+ * anything new undici invents — is treated as possibly dispatched.
626
+ *
627
+ * The discriminator is the syscall, not the errno, and that distinction earns
628
+ * its place. `ECONNRESET` alone is ambiguous: it is what a TLS handshake
629
+ * against a non-TLS port produces (`syscall: 'read'`, connect phase) and also
630
+ * what a peer resetting a live connection produces (post-dispatch). `connect`
631
+ * and `getaddrinfo`, by contrast, happen once and only before the request
632
+ * exists. undici's own post-dispatch failures are unmistakable in the other
633
+ * direction — `SocketError`/`UND_ERR_SOCKET`, `HTTPParserError`, the two
634
+ * timeout classes — and none of them match anything here.
635
+ *
636
+ * The allow-list is matched in full rather than by prefix, for the reason
637
+ * {@link TLS_CODES} sets out: the obvious prefixes admit failures that happen
638
+ * after the handshake, and one of those in this branch is exactly the bug this
639
+ * function was written to prevent.
640
+ *
641
+ * Measured against undici 6 on Node 26, 2026-08-27: refused → `ECONNREFUSED`
642
+ * with `syscall: 'connect'`; DNS → `ENOTFOUND` with `syscall: 'getaddrinfo'`;
643
+ * dual-stack refusal → the same, inside an `AggregateError`; unroutable →
644
+ * `UND_ERR_CONNECT_TIMEOUT`; TLS against a plaintext port →
645
+ * `ERR_SSL_WRONG_VERSION_NUMBER`. Post-dispatch: a socket closed after the
646
+ * request → `UND_ERR_SOCKET`, a garbage response → `HPE_INVALID_CONSTANT`, no
647
+ * response → `UND_ERR_HEADERS_TIMEOUT`.
648
+ */
649
+ function neverDispatched(err) {
650
+ for (const cause of causes(err)) {
651
+ const code = typeof cause.code === 'string' ? cause.code : '';
652
+ const syscall = cause.syscall;
653
+ if (syscall === 'connect' || syscall === 'getaddrinfo' || syscall === 'lookup')
654
+ return true;
655
+ if (code === 'UND_ERR_CONNECT_TIMEOUT')
656
+ return true;
657
+ if (TLS_CODES.has(code))
658
+ return true;
659
+ }
660
+ return false;
661
+ }
662
+ /** Errnos a live connection dies with, once the request is already on it. */
663
+ const SOCKET_ERRNOS = new Set(['ECONNRESET', 'ECONNABORTED', 'EPIPE', 'ENOTCONN', 'ETIMEDOUT']);
664
+ /**
665
+ * A transport failure while reading a body, as opposed to a bug in this file.
666
+ *
667
+ * Only reached from {@link readBody}, so the phase is not in question — the
668
+ * response headers already arrived. What is in question is whether the throw
669
+ * came from the connection or from us: `#decode` and `sse` raise
670
+ * {@link MandalaError} for a body that arrived and made no sense, and wrapping
671
+ * one of those as a connectivity failure would send a poll loop round again on
672
+ * a defect.
673
+ *
674
+ * The names were here first and are undici's aborts and idle timeouts. The two
675
+ * tests below them close the case that used to fall straight through: a socket
676
+ * that dies mid-body surfaces from `fetch` as `TypeError: terminated` — a name
677
+ * this list does not have and never will — carrying a `SocketError` as its
678
+ * cause. That reached `throw cause` and came out as a bare `TypeError`, which
679
+ * is neither transient nor pollable, so a wait loop died on a blip it existed
680
+ * to ride out.
681
+ */
682
+ function isTransportFailure(cause) {
683
+ const name = cause?.name;
684
+ if (name === 'AbortError' ||
685
+ name === 'TimeoutError' ||
686
+ name === 'BodyTimeoutError' ||
687
+ name === 'HeadersTimeoutError') {
688
+ return true;
689
+ }
690
+ for (const inner of causes(cause)) {
691
+ const code = typeof inner.code === 'string' ? inner.code : '';
692
+ if (code.startsWith('UND_ERR_'))
693
+ return true;
694
+ if (SOCKET_ERRNOS.has(code))
695
+ return true;
696
+ }
697
+ return false;
698
+ }
699
+ /** The same cancellation semantics for response bodies as for response headers. */
700
+ async function readBody(method, path, signal, read) {
701
+ try {
702
+ return await read();
703
+ }
704
+ catch (cause) {
705
+ if (isCancellation(cause, signal)) {
706
+ throw cancellationError(method, path, 'while reading the platform response');
707
+ }
708
+ // Always the post-dispatch class. Getting here means the response headers
709
+ // arrived, so the platform received the request and acted on it; what was
710
+ // lost is the answer. That is precisely the case `isTransient` must say no
711
+ // to and the poll predicate must ride out (OPL-3855).
712
+ if (isTransportFailure(cause)) {
713
+ throw new ConnectivityInterruptedError(`could not finish reading ${method} /${path.replace(/^\/+/, '')}: ${cause instanceof Error ? cause.message : String(cause)}. The request was received, so treat anything it would have changed as ` +
714
+ 'unknown rather than undone.');
715
+ }
716
+ throw cause;
717
+ }
718
+ }
719
+ function cancellationError(method, path, when) {
720
+ return new CancelledError(`${method} /${path.replace(/^\/+/, '')} was cancelled ${when}. ` +
721
+ 'It may still have been received, so treat anything it would have changed as unknown rather than undone.');
722
+ }
723
+ /**
724
+ * The bare media type, without the parameters a Content-Type may carry.
725
+ *
726
+ * MCP's image content takes a media type, and `image/png; charset=binary` is a
727
+ * header — a client matching on the former renders nothing for the latter. The
728
+ * parameters say nothing this server uses, so they are dropped at the one place
729
+ * the header is read.
730
+ */
731
+ function mediaType(header) {
732
+ const bare = (header ?? '').split(';')[0].trim().toLowerCase();
733
+ return bare || 'application/octet-stream';
734
+ }
735
+ /**
736
+ * `Content-Range`, in both the shapes this surface sends.
737
+ *
738
+ * `bytes A-B/T` on a 206 says which bytes arrived and how long the file is;
739
+ * `bytes *\/T` on a 416 says only the length, which is the one thing a caller
740
+ * who guessed an offset wrong needs. Both are parsed here so the two readers
741
+ * cannot disagree about the grammar, and `*` in either position comes back as
742
+ * `undefined` rather than as a number nothing sent.
743
+ *
744
+ * A malformed header is nothing rather than a throw: it is metadata about a
745
+ * body that already arrived, and failing a download over the label on it would
746
+ * be a worse answer than the one this gives.
747
+ */
748
+ function parseContentRange(header) {
749
+ if (!header)
750
+ return undefined;
751
+ const m = /^\s*bytes\s+(?:(\d+)-(\d+)|\*)\/(\d+|\*)\s*$/i.exec(header);
752
+ if (!m)
753
+ return undefined;
754
+ const num = (v) => {
755
+ if (v === undefined || v === '*')
756
+ return undefined;
757
+ const n = Number(v);
758
+ return Number.isSafeInteger(n) ? n : undefined;
759
+ };
760
+ const start = num(m[1]);
761
+ const end = num(m[2]);
762
+ // A window whose end precedes its start describes no bytes. Dropping the pair
763
+ // rather than passing it on keeps `end - start + 1` from being negative in
764
+ // every caller that trusts this.
765
+ if (start !== undefined && end !== undefined && end < start)
766
+ return undefined;
767
+ return { start, end, total: num(m[3]) };
768
+ }
769
+ /**
770
+ * The longest delay `setTimeout` takes without wrapping.
771
+ *
772
+ * Node stores it in a 32-bit signed int, and a larger one does NOT clamp — it
773
+ * warns and fires at 1ms instead, which is the opposite of every use of this
774
+ * number. About 24.9 days.
775
+ */
776
+ const MAX_TIMER_MS = 2_147_483_647;
777
+ /**
778
+ * A `Retry-After` header, in milliseconds from now.
779
+ *
780
+ * Both spellings the header has: delta-seconds, and an HTTP date. A date in the
781
+ * past is zero rather than negative, because the only consumer is a sleep.
782
+ *
783
+ * CAPPED at {@link MAX_TIMER_MS}, and that is the whole reason this is not four
784
+ * lines. `Retry-After: 2147484` is a valid header — under a month — and it is
785
+ * 2147484000ms, which does not fit a 32-bit signed int, so Node fires the timer
786
+ * at 1ms. A poll loop then retries a rate limit it was told to leave alone for
787
+ * weeks, immediately and for the rest of its deadline: the exact opposite of
788
+ * what the header asked for, reached by honouring it. The TypeScript SDK's
789
+ * `retryAfterMs` has carried this cap since it was written; this copy was made
790
+ * without it (Codex adversarial review, OPL-3724).
791
+ *
792
+ * A malformed value is nothing rather than a throw, for parseContentRange's
793
+ * reason — it is metadata about a refusal that already arrived, and the poll
794
+ * loops have their own interval to fall back on. Note that a NEGATIVE
795
+ * delta-seconds is not malformed enough to stop there: `Date.parse('-5')` is a
796
+ * date in 2001, so it falls through to the branch below and lands on 0, which
797
+ * is the same answer a date in the past gets and is why nothing worse happens.
798
+ */
799
+ function retryAfterMs(header) {
800
+ if (!header)
801
+ return undefined;
802
+ const seconds = Number(header);
803
+ if (Number.isFinite(seconds) && seconds >= 0)
804
+ return Math.min(seconds * 1_000, MAX_TIMER_MS);
805
+ const at = Date.parse(header);
806
+ if (!Number.isFinite(at))
807
+ return undefined;
808
+ return Math.min(Math.max(at - Date.now(), 0), MAX_TIMER_MS);
809
+ }
810
+ /** A trustworthy response length, when fetch has not transparently decoded it. */
811
+ function contentLength(resp) {
812
+ const encoding = resp.headers.get('content-encoding');
813
+ if (encoding && encoding.toLowerCase() !== 'identity')
814
+ return undefined;
815
+ const raw = resp.headers.get('content-length');
816
+ if (raw === null || !/^\d+$/.test(raw))
817
+ return undefined;
818
+ const n = Number(raw);
819
+ return Number.isSafeInteger(n) ? n : undefined;
820
+ }
821
+ /**
822
+ * Read no more than a tool can return, then cancel the rest of the download.
823
+ *
824
+ * The files route may send 64 MiB while read_file can put only 256 KiB into a
825
+ * conversation. `arrayBuffer()` paid for and retained the other 63.75 MiB just
826
+ * to throw it away. A one-chunk lookahead says whether a response of exactly
827
+ * `limit` bytes was clipped. It also verifies a declared oversize body really
828
+ * had more bytes: Content-Length is useful metadata, not proof that data was
829
+ * discarded.
830
+ */
831
+ async function readAtMost(resp, limit) {
832
+ if (!Number.isSafeInteger(limit) || limit < 0) {
833
+ throw new MandalaError(`byte limit must be a non-negative integer, got ${limit}`);
834
+ }
835
+ if (!resp.body)
836
+ return { bytes: new Uint8Array(), truncated: false };
837
+ const reader = resp.body.getReader();
838
+ const chunks = [];
839
+ let length = 0;
840
+ let truncated = false;
841
+ try {
842
+ while (length < limit) {
843
+ const { done, value } = await reader.read();
844
+ if (done)
845
+ break;
846
+ const take = Math.min(value.length, limit - length);
847
+ if (take) {
848
+ chunks.push(value.subarray(0, take));
849
+ length += take;
850
+ }
851
+ if (take < value.length) {
852
+ truncated = true;
853
+ break;
854
+ }
855
+ }
856
+ if (length === limit && !truncated) {
857
+ const next = await reader.read();
858
+ truncated = !next.done;
859
+ }
860
+ }
861
+ finally {
862
+ // Release the response on every exit, including a rejected read. On a
863
+ // clean EOF this is a harmless no-op; on an error it prevents the body and
864
+ // its connection from being left open.
865
+ await reader.cancel().catch(() => { });
866
+ }
867
+ const bytes = new Uint8Array(length);
868
+ let offset = 0;
869
+ for (const chunk of chunks) {
870
+ bytes.set(chunk, offset);
871
+ offset += chunk.length;
872
+ }
873
+ return { bytes, truncated };
874
+ }
875
+ /** Decode a bounded UTF-8 prefix and cancel anything beyond it. */
876
+ async function readTextAtMost(resp, limit) {
877
+ const { bytes, truncated } = await readAtMost(resp, limit);
878
+ return { text: new TextDecoder().decode(bytes), truncated };
879
+ }
880
+ function parseEvent(chunk) {
881
+ let event = 'message';
882
+ const data = [];
883
+ for (const line of chunk.split(/\r\n|\n|\r/)) {
884
+ if (line.startsWith('event:'))
885
+ event = line.slice(6).trim();
886
+ else if (line.startsWith('data:')) {
887
+ // Exactly one space, which is what the spec strips. `trimStart()` took
888
+ // every leading space and tab, and whitespace inside a data field is
889
+ // payload — significant the moment an event carries text rather than the
890
+ // JSON every event happens to carry today.
891
+ const v = line.slice(5);
892
+ data.push(v.startsWith(' ') ? v.slice(1) : v);
893
+ }
894
+ }
895
+ if (!data.length)
896
+ return undefined;
897
+ const joined = data.join('\n');
898
+ try {
899
+ return { event, data: JSON.parse(joined) };
900
+ }
901
+ catch {
902
+ return { event, data: joined };
903
+ }
904
+ }
905
+ /** The filename the platform put on a download, if it put one there. */
906
+ export function filenameFrom(disposition) {
907
+ if (!disposition)
908
+ return undefined;
909
+ // Any charset and any language, not only `UTF-8''`. RFC 5987 writes this
910
+ // value as charset, language, then the text, with the language ordinarily
911
+ // empty — and matching only the empty spelling meant that both
912
+ // `filename*=ISO-8859-1''…` and `filename*=UTF-8'en'…` were read by neither
913
+ // branch — the plain form below cannot match either, since there is no
914
+ // `filename=` in them — so a download the platform had named came back with
915
+ // no name at all. Three groups, not two: the middle one is the language tag,
916
+ // present or empty.
917
+ const star = /filename\*=([^']*)'([^']*)'([^;]+)/i.exec(disposition);
918
+ if (star) {
919
+ // A stray `%` in a guest filename is legal on disk and makes this throw.
920
+ // Letting it out would turn a download whose bytes already arrived intact
921
+ // into a failure, over the label on it.
922
+ try {
923
+ return decodeURIComponent(star[3]);
924
+ }
925
+ catch {
926
+ return star[3];
927
+ }
928
+ }
929
+ const plain = /filename="?([^";]+)"?/i.exec(disposition);
930
+ return plain ? plain[1] : undefined;
931
+ }
932
+ //# sourceMappingURL=api.js.map