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.
- package/LICENSE +21 -0
- package/README.md +544 -0
- package/dist/api.d.ts +186 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +932 -0
- package/dist/api.js.map +1 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +292 -0
- package/dist/cli.js.map +1 -0
- package/dist/errors.d.ts +560 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +873 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +406 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +1679 -0
- package/dist/events.js.map +1 -0
- package/dist/format.d.ts +125 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +180 -0
- package/dist/format.js.map +1 -0
- package/dist/http.d.ts +46 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +792 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +394 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +677 -0
- package/dist/paths.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +97 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +78 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +166 -0
- package/dist/session.js.map +1 -0
- package/dist/stdio.d.ts +11 -0
- package/dist/stdio.d.ts.map +1 -0
- package/dist/stdio.js +43 -0
- package/dist/stdio.js.map +1 -0
- package/dist/tools/agent.d.ts +16 -0
- package/dist/tools/agent.d.ts.map +1 -0
- package/dist/tools/agent.js +147 -0
- package/dist/tools/agent.js.map +1 -0
- package/dist/tools/computers.d.ts +3 -0
- package/dist/tools/computers.d.ts.map +1 -0
- package/dist/tools/computers.js +1037 -0
- package/dist/tools/computers.js.map +1 -0
- package/dist/tools/events.d.ts +3 -0
- package/dist/tools/events.d.ts.map +1 -0
- package/dist/tools/events.js +1077 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/guest.d.ts +3 -0
- package/dist/tools/guest.d.ts.map +1 -0
- package/dist/tools/guest.js +761 -0
- package/dist/tools/guest.js.map +1 -0
- package/dist/tools/input.d.ts +3 -0
- package/dist/tools/input.d.ts.map +1 -0
- package/dist/tools/input.js +240 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/snapshots.d.ts +3 -0
- package/dist/tools/snapshots.d.ts.map +1 -0
- package/dist/tools/snapshots.js +333 -0
- package/dist/tools/snapshots.js.map +1 -0
- package/dist/tools/templates.d.ts +3 -0
- package/dist/tools/templates.d.ts.map +1 -0
- package/dist/tools/templates.js +492 -0
- package/dist/tools/templates.js.map +1 -0
- package/dist/tools/types.d.ts +18 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/webhooks.d.ts +3 -0
- package/dist/tools/webhooks.d.ts.map +1 -0
- package/dist/tools/webhooks.js +260 -0
- package/dist/tools/webhooks.js.map +1 -0
- 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
|