@johnhenry/andbox 0.1.3 → 0.3.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/src/index.d.ts CHANGED
@@ -251,27 +251,185 @@ export interface SandboxFetchReply {
251
251
  redirected?: boolean;
252
252
  }
253
253
 
254
+ /**
255
+ * `network.allowedHosts` as a function: asked on the host, with a fresh `URL`,
256
+ * before every request the sandbox makes and before every redirect hop andbox
257
+ * follows. Only `true` (or a promise of `true`) allows; anything else refuses,
258
+ * and a thrown error is the message the sandbox's `fetch` rejects with.
259
+ */
260
+ export type SandboxHostPolicy = (url: URL) => boolean | Promise<boolean>;
261
+
254
262
  /** `createSandbox({ network })`: a host-backed global `fetch` inside the sandbox. */
255
263
  export interface SandboxNetworkOptions {
256
264
  /**
257
- * Called on the host for every request the sandbox's `fetch` makes, through
258
- * the gated `fetch` capability (`policy.capabilities.fetch` applies). The
259
- * URL is always absolute http(s). Return a `Response` or a plain reply.
260
- * Called without a `this`, so the platform `fetch` itself can be passed.
261
- * Required unless `allowedHosts` is given.
265
+ * Required (0.2.0, andbox#43): which hosts the sandbox may reach. Checked on
266
+ * the host before `fetch` is called, so leaving it out is an error rather
267
+ * than "every host".
268
+ *
269
+ * - `string[]`: hostnames, matched exactly against the request URL's
270
+ * hostname (case-insensitive, IDN and IPv4 normalised like `URL`, any
271
+ * port, http or https). No subdomain matching: `'example.com'` does not
272
+ * allow `'api.example.com'`. IPv6 in brackets (`'[::1]'`). Entries with a
273
+ * scheme, port, path or `*` throw. Must not be empty. Any redirect is
274
+ * refused (the request is made with `redirect: 'manual'`).
275
+ * - `(url: URL) => boolean | Promise<boolean>`: a policy asked for every
276
+ * request, so the allowlist can change while the sandbox runs. andbox
277
+ * follows redirects itself (`redirect: 'manual'` underneath) and asks it
278
+ * again for each hop; where the platform hides the target (a browser's
279
+ * opaque redirect) the request fails.
280
+ * - `'*'`: any http(s) host. Your `fetch` is the whole policy, and the
281
+ * sandbox's redirect mode is passed to it unchanged.
282
+ */
283
+ allowedHosts: readonly string[] | '*' | SandboxHostPolicy;
284
+ /**
285
+ * Called on the host for every request the sandbox's `fetch` makes that
286
+ * `allowedHosts` allows, through the gated `fetch` capability
287
+ * (`policy.capabilities.fetch` applies). The URL is always absolute
288
+ * http(s). Return a `Response` or a plain reply. Called without a `this`,
289
+ * so the platform `fetch` itself can be passed. Default: the platform's
290
+ * global `fetch` (on a server, that has the server's network position).
262
291
  */
263
292
  fetch?: (url: string, init: SandboxFetchInit) =>
264
293
  Response | SandboxFetchReply | Promise<Response | SandboxFetchReply>;
265
- /**
266
- * Put `createNetworkFetch(allowedHosts, fetch)` in front: other hosts and
267
- * any redirect are refused. Without `fetch` it wraps the host's global
268
- * `fetch`. Must not be empty.
269
- */
270
- allowedHosts?: string[];
271
294
  /** `init.credentials` for every request. Default `'omit'`. */
272
295
  credentials?: RequestCredentials;
273
296
  }
274
297
 
298
+ // ── bridges (createSandbox({ bridges }), andbox#46) ──
299
+
300
+ /** Per-sandbox limits of one bridge. 0 = unlimited. */
301
+ export interface BridgeLimits {
302
+ /** Live handles at a time. Default 32 (chromeAI: 8). */
303
+ maxHandles?: number;
304
+ /** Open streams at a time. Default 8 (chromeAI: 4). */
305
+ maxStreams?: number;
306
+ /** Approximate size of one result or stream chunk (strings as UTF-8, binary by byteLength). Default 0. */
307
+ maxResultBytes?: number;
308
+ /** Wall-clock time for one call; for a stream method, until the stream ends. Default 0. */
309
+ timeoutMs?: number;
310
+ }
311
+
312
+ /** Defaults for {@link BridgeLimits}. */
313
+ export declare const DEFAULT_BRIDGE_LIMITS: Readonly<Required<BridgeLimits>>;
314
+
315
+ /** An opaque reference to a host object, from `ctx.handle()`. Return it (or put it in a result). */
316
+ export interface BridgeHandleRef<T = unknown> {
317
+ readonly type: string;
318
+ readonly target: T;
319
+ }
320
+
321
+ /** What every bridge method receives as its first argument. */
322
+ export interface BridgeContext<State = any, Target = any> {
323
+ /** The bridge's name (its global in the sandbox). */
324
+ bridge: string;
325
+ /** `'chat.create'` for an api method, `'Session.ask'` for a handle method. */
326
+ method: string;
327
+ /**
328
+ * Aborts when the sandbox's AbortSignal for this call fires, the call times
329
+ * out (`limits.timeoutMs`), its handle is destroyed, the stream is
330
+ * cancelled, or the sandbox's Worker/frame is terminated. Pass it on.
331
+ */
332
+ signal: AbortSignal;
333
+ /** Per-sandbox state from `createState()` (lives as long as the sandbox, across restarts). */
334
+ state: State;
335
+ /** Handle methods: the host object the sandbox's handle refers to. */
336
+ target: Target;
337
+ /** Wrap a host object so the sandbox gets a handle to it (type: a key of `handles`). */
338
+ handle<T>(type: string, target: T): BridgeHandleRef<T>;
339
+ }
340
+
341
+ /**
342
+ * A bridge method. Arguments arrive by structured clone, with sandbox
343
+ * functions replaced by async proxies (reverse calls into the sandbox),
344
+ * AbortSignals by `ctx.signal`, and handles by their host objects.
345
+ */
346
+ export type BridgeMethodFn<State = any, Target = any> = (ctx: BridgeContext<State, Target>, ...args: any[]) => unknown;
347
+
348
+ /** The activation a method needs: `true`/`'transient'` (a gesture in the last few seconds) or `'sticky'` (any gesture since load). */
349
+ export type BridgeActivation = boolean | 'transient' | 'sticky';
350
+
351
+ export interface BridgeMethodSpec<State = any, Target = any> {
352
+ call: BridgeMethodFn<State, Target>;
353
+ /** Returns a ReadableStream / (async) iterable; the sandbox gets a ReadableStream at once. */
354
+ stream?: boolean;
355
+ /** Returns a handle: `limits.maxHandles` is checked before the method runs. */
356
+ handle?: boolean;
357
+ /** Needs page user activation; a function decides per call (e.g. only when a download is needed). */
358
+ requiresUserActivation?: BridgeActivation | ((ctx: BridgeContext<State, Target>, ...args: any[]) => BridgeActivation | Promise<BridgeActivation>);
359
+ }
360
+
361
+ export type BridgeMethod<State = any, Target = any> = BridgeMethodFn<State, Target> | BridgeMethodSpec<State, Target>;
362
+
363
+ /** A tree of namespaces and methods: `{ chat: { create, list } }` becomes `svc.chat.create()`. */
364
+ export interface BridgeApi<State = any> {
365
+ [name: string]: BridgeMethod<State> | BridgeApi<State>;
366
+ }
367
+
368
+ /** A host object type the sandbox holds handles to. */
369
+ export interface BridgeHandleType<State = any, Target = any> {
370
+ /** Methods callable on the sandbox's handle; `ctx.target` is the host object. */
371
+ methods?: Record<string, BridgeMethod<State, Target>>;
372
+ /** Properties copied to the sandbox (primitives and arrays of primitives) with the handle and after every call. */
373
+ props?: string[];
374
+ /** Release the host object: on handle.destroy(), and for every live handle when the sandbox is disposed, restarted or killed. */
375
+ destroy?: (target: Target) => void | Promise<void>;
376
+ }
377
+
378
+ /** What `onRequest` sees for every bridge method call (not for destroy/cancel/abort). */
379
+ export interface BridgeRequest {
380
+ bridge: string;
381
+ /** `'chat.create'` or `'Session.ask'`; the gate name is `${bridge}.${method}`. */
382
+ method: string;
383
+ /** Decoded arguments (sandbox functions are proxies; signals are the call's signal). */
384
+ args: unknown[];
385
+ handle: { id: string; type: string } | null;
386
+ /** The activation the method needs for this call, or false. */
387
+ requiresUserActivation: false | 'transient' | 'sticky';
388
+ /** The page's `navigator.userActivation` now; null where the platform has none (Node). */
389
+ userActivation: { isActive: boolean; hasBeenActive: boolean } | null;
390
+ /** Aborts when the call does (the sandbox gave up, timed out or was terminated): close your consent UI. */
391
+ signal: AbortSignal;
392
+ }
393
+
394
+ export interface BridgeDefinition<State = any> {
395
+ /** Methods and namespaces of the sandbox global. */
396
+ api?: BridgeApi<State>;
397
+ /** Host object types, by name. A handle type cannot share a name with an api member. */
398
+ handles?: Record<string, BridgeHandleType<State>>;
399
+ limits?: BridgeLimits;
400
+ /**
401
+ * Consent hook, asked on the host before every method call. Only `true`
402
+ * (or a promise of it) allows; anything else, or a throw, rejects the call
403
+ * in the sandbox with `NotAllowedError`. It may wait for the user.
404
+ */
405
+ onRequest?: (request: BridgeRequest) => boolean | Promise<boolean>;
406
+ /** Per-sandbox state (`ctx.state`), created once per sandbox. */
407
+ createState?: () => State;
408
+ /** Extra fields for `sandbox.stats().bridges[name]`. */
409
+ stats?: (state: State) => Record<string, unknown>;
410
+ /**
411
+ * Sandbox-side adapter: a self-contained function (stringified; no outside
412
+ * references) called in the sandbox with the generated global and
413
+ * `clientOptions`; returns the global to install.
414
+ */
415
+ client?: ((api: any, options: any) => any) | string;
416
+ /** JSON passed to `client` in the sandbox. */
417
+ clientOptions?: unknown;
418
+ /** Extra sandbox globals aliasing paths of the api: `{ LanguageModel: 'languageModel' }`. */
419
+ globals?: Record<string, string>;
420
+ }
421
+
422
+ /** Check a bridge definition (throws a TypeError) and return it unchanged. */
423
+ export declare function defineBridge<State = any>(definition: BridgeDefinition<State>): BridgeDefinition<State>;
424
+
425
+ /** `sandbox.stats().bridges[name]`. */
426
+ export interface BridgeStats {
427
+ handles: number;
428
+ streams: number;
429
+ pendingCalls: number;
430
+ [extra: string]: unknown;
431
+ }
432
+
275
433
  // ── stdio ──
276
434
 
277
435
  /** An async iterable stdio stream with push/end controls. */
@@ -302,9 +460,11 @@ export declare function createStdio(): StdioStream;
302
460
  * @param options.networkFetch Install the host-backed global `fetch` shim
303
461
  * (what `createSandbox({ network })` uses). It calls the host's `fetch`
304
462
  * capability. Default false: `fetch` is removed like the other network globals.
463
+ * @param options.bridges Include the bridge client (`createSandbox({ bridges })`);
464
+ * the globals are built from the manifests sent with `configure`. Default false.
305
465
  * @returns The complete Worker script source code as a string.
306
466
  */
307
- export declare function makeWorkerSource(options?: { networkFetch?: boolean }): string;
467
+ export declare function makeWorkerSource(options?: { networkFetch?: boolean; bridges?: boolean }): string;
308
468
 
309
469
  // ── service-worker-source ──
310
470
 
@@ -475,12 +635,24 @@ export interface SandboxOptions {
475
635
  * `worker`, `node-worker` and `iframe` modes (throws in `wasm`): install a
476
636
  * global `fetch` in the sandbox that sends each request to `network.fetch`
477
637
  * on the host, through the gated `fetch` capability (andbox#39). http(s)
478
- * only; `credentials` is the host's choice. Other network globals stay
638
+ * only, and only to the hosts `network.allowedHosts` allows (required,
639
+ * andbox#43); `credentials` is the host's choice. Other network globals stay
479
640
  * locked in worker modes; the platform `import()` operator is not affected.
480
641
  * Unset (default): worker modes have no `fetch`. Conflicts with a
481
642
  * capability named `fetch`.
482
643
  */
483
644
  network?: SandboxNetworkOptions;
645
+ /**
646
+ * `worker`, `node-worker` and `iframe` modes (throws in the others): one
647
+ * sandbox global per entry that proxies a host API (andbox#46). Host
648
+ * objects never cross: the sandbox holds opaque handles, gets structured
649
+ * clones, pulls streams chunk by chunk, and its functions run in the
650
+ * sandbox when the host calls them. Every method call goes through the
651
+ * capability gate as `${bridge}.${method}` (so `policy` limits it) and
652
+ * through the bridge's `onRequest`; every handle is destroyed when the
653
+ * sandbox is disposed, restarted or killed. See `@johnhenry/andbox/bridges/chrome-ai`.
654
+ */
655
+ bridges?: Record<string, BridgeDefinition>;
484
656
  }
485
657
 
486
658
  /** Options for the built-in Node worker_threads mode (`nodeWorker`). */
@@ -533,6 +705,8 @@ export interface SandboxStats {
533
705
  pendingEvaluations: number;
534
706
  virtualModules: string[];
535
707
  gate: GateStatsResult;
708
+ /** With `bridges`: live handles/streams/calls per bridge, plus the bridge's own `stats()`. */
709
+ bridges?: Record<string, BridgeStats>;
536
710
  /** `mode: 'wasm'` only: interrupt polls used by the most recent evaluate(). */
537
711
  fuelUsed?: number;
538
712
  /** `mode: 'wasm'` only: peak sampled JS heap bytes seen so far. */
package/src/index.mjs CHANGED
@@ -10,6 +10,7 @@ export { createVirtualModuleRegistry } from './virtual-module-registry.mjs';
10
10
  export { gateCapabilities } from './capability-gate.mjs';
11
11
  export { createStdio } from './stdio.mjs';
12
12
  export { createNetworkFetch } from './network-policy.mjs';
13
+ export { defineBridge, DEFAULT_BRIDGE_LIMITS } from './bridge-host.mjs';
13
14
  export { makeDeferred, makeAbortError, makeTimeoutError } from './deferred.mjs';
14
15
  export { DEFAULT_TIMEOUT_MS, DEFAULT_LIMITS, DEFAULT_CAPABILITY_LIMITS } from './constants.mjs';
15
16
  export { makeWorkerSource } from './worker-source.mjs';
@@ -107,47 +107,232 @@ async function toWireResponse(res, requestURL) {
107
107
  };
108
108
  }
109
109
 
110
+ // ── allowedHosts: required, three forms (andbox#43) ──
111
+
112
+ /** The explicit opt-in for "any http(s) host; my `fetch` is the whole policy". */
113
+ const ANY_HOST = '*';
114
+
115
+ const MAX_REDIRECTS = 20;
116
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
117
+ /** Request headers that describe the body; dropped when a redirect turns the request into a GET. */
118
+ const BODY_HEADERS = ['content-encoding', 'content-language', 'content-location', 'content-type'];
119
+
120
+ const ALLOWED_HOSTS_EXAMPLE =
121
+ " network: { allowedHosts: ['api.example.com'] } // only these hosts\n" +
122
+ ' network: { fetch, allowedHosts: (url) => policy.allows(url.host) } // decided per request\n' +
123
+ " network: { fetch, allowedHosts: '*' } // any host: your fetch is the whole policy";
124
+
125
+ const ENTRY_HINT =
126
+ "entries are hostnames without a scheme, port, path or wildcard, e.g. 'api.example.com', '127.0.0.1', '[::1]'";
127
+
110
128
  /**
111
- * Validate `createSandbox({ network })` and build the host-side `fetch`
112
- * capability behind the sandbox's global `fetch` shim.
113
- *
114
- * Everything that arrives from the sandbox is treated as untrusted input
115
- * (evaluated code can also call `host.call('fetch', url, init)` directly):
116
- * the URL must be http(s), only method/headers/body/redirect are taken from
117
- * the request, and `credentials` and `signal` are always set by the host.
129
+ * Normalise one `allowedHosts` array entry to the form `URL#hostname` has
130
+ * (lowercase, punycode, canonical IPv4, bracketed IPv6), so it can be
131
+ * compared with a request URL's hostname. Throws on anything that would
132
+ * silently never match (a port, a scheme, a path, a wildcard).
133
+ */
134
+ function normalizeHostEntry(entry) {
135
+ if (typeof entry !== 'string' || entry.length === 0) {
136
+ throw new TypeError(`network.allowedHosts: ${ENTRY_HINT} (got ${JSON.stringify(entry)})`);
137
+ }
138
+ if (entry === ANY_HOST) {
139
+ throw new TypeError("network.allowedHosts: to allow any host pass the string '*' itself, not inside an array");
140
+ }
141
+ const bare = entry.startsWith('[') ? entry.replace(/^\[[^\]]*\]/, '') : entry;
142
+ if (/[/?#@\s*\\]/.test(entry) || bare.includes(':')) {
143
+ throw new TypeError(`network.allowedHosts: ${ENTRY_HINT} (got '${entry}')`);
144
+ }
145
+ let hostname;
146
+ try {
147
+ hostname = new URL(`http://${entry}/`).hostname;
148
+ } catch {
149
+ throw new TypeError(`network.allowedHosts: '${entry}' is not a valid hostname; ${ENTRY_HINT}`);
150
+ }
151
+ return hostname;
152
+ }
153
+
154
+ /**
155
+ * Validate `createSandbox({ network })` without building anything, so
156
+ * `createSandbox()` can refuse a bad option before it starts a Worker.
118
157
  *
119
- * @param {{ fetch?: Function, allowedHosts?: string[], credentials?: RequestCredentials }} network
120
- * @returns {(this: { signal?: AbortSignal }, url: unknown, init?: unknown) => Promise<object>}
158
+ * @returns {{ hostFetch?: Function, allowedHosts: string[] | '*' | ((url: URL) => unknown), credentials: RequestCredentials }}
121
159
  */
122
- export function createFetchCapability(network) {
160
+ export function validateNetworkOptions(network) {
123
161
  if (network === null || typeof network !== 'object' || Array.isArray(network)) {
124
- throw new TypeError('network must be an object: { fetch?, allowedHosts?, credentials? }');
162
+ throw new TypeError('network must be an object: { allowedHosts, fetch?, credentials? }');
125
163
  }
126
164
  for (const key of Object.keys(network)) {
127
165
  if (!NETWORK_KEYS.has(key)) {
128
- throw new TypeError(`network.${key} is not a known option (expected fetch, allowedHosts, credentials)`);
166
+ throw new TypeError(`network.${key} is not a known option (expected allowedHosts, fetch, credentials)`);
129
167
  }
130
168
  }
131
169
  const { fetch: hostFetch, allowedHosts, credentials = 'omit' } = network;
132
170
  if (hostFetch !== undefined && typeof hostFetch !== 'function') {
133
171
  throw new TypeError('network.fetch must be a function (url, init) => Response');
134
172
  }
135
- if (allowedHosts !== undefined) {
136
- if (!Array.isArray(allowedHosts) || !allowedHosts.every((h) => typeof h === 'string' && h.length > 0)) {
137
- throw new TypeError('network.allowedHosts must be an array of hostname strings');
138
- }
173
+ if (allowedHosts === undefined) {
174
+ throw new TypeError(
175
+ 'network.allowedHosts is required: the sandbox gets no network unless you say which hosts it may reach. ' +
176
+ `For example:\n${ALLOWED_HOSTS_EXAMPLE}`
177
+ );
178
+ }
179
+ let hosts;
180
+ if (allowedHosts === ANY_HOST || typeof allowedHosts === 'function') {
181
+ hosts = allowedHosts;
182
+ } else if (Array.isArray(allowedHosts)) {
139
183
  if (allowedHosts.length === 0) {
140
- throw new TypeError('network.allowedHosts must list at least one host; to give the sandbox no network, omit `network`');
184
+ throw new TypeError(
185
+ 'network.allowedHosts must list at least one host; to give the sandbox no network, omit `network`. ' +
186
+ `For example:\n${ALLOWED_HOSTS_EXAMPLE}`
187
+ );
141
188
  }
142
- }
143
- if (hostFetch === undefined && allowedHosts === undefined) {
144
- throw new TypeError('network needs `fetch` (a host function) and/or `allowedHosts`');
189
+ hosts = [...new Set(allowedHosts.map(normalizeHostEntry))];
190
+ } else {
191
+ throw new TypeError(
192
+ "network.allowedHosts must be an array of hostnames, a function (url: URL) => boolean, or '*' " +
193
+ `(got ${typeof allowedHosts === 'string' ? `'${allowedHosts}'` : typeof allowedHosts}). For example:\n${ALLOWED_HOSTS_EXAMPLE}`
194
+ );
145
195
  }
146
196
  if (!CREDENTIALS.includes(credentials)) {
147
197
  throw new TypeError(`network.credentials must be one of ${CREDENTIALS.map((c) => `'${c}'`).join(', ')}`);
148
198
  }
199
+ return { hostFetch, allowedHosts: hosts, credentials };
200
+ }
149
201
 
150
- const send = allowedHosts ? createNetworkFetch(allowedHosts, hostFetch) : hostFetch;
202
+ /** The host's fetch, or the platform's, called without a `this`. */
203
+ function resolveFetch(hostFetch) {
204
+ if (hostFetch) return (url, init) => hostFetch(url, init);
205
+ return (url, init) => {
206
+ const platformFetch = globalThis.fetch;
207
+ if (typeof platformFetch !== 'function') throw new Error('fetch is not available');
208
+ return platformFetch.call(globalThis, url, init);
209
+ };
210
+ }
211
+
212
+ async function askPolicy(policy, href) {
213
+ // A fresh URL each time: the policy cannot change the URL andbox requests.
214
+ const verdict = await policy(new URL(href));
215
+ if (verdict !== true) {
216
+ throw new Error(`Network access denied: ${new URL(href).host} is not allowed by network.allowedHosts`);
217
+ }
218
+ }
219
+
220
+ function responseHeaders(res) {
221
+ if (res?.headers && typeof res.headers.get === 'function') return res.headers;
222
+ try {
223
+ return new Headers(res?.headers ?? undefined);
224
+ } catch {
225
+ return new Headers();
226
+ }
227
+ }
228
+
229
+ function discardBody(res) {
230
+ try {
231
+ res?.body?.cancel?.().catch(() => {});
232
+ } catch {
233
+ // nothing to release
234
+ }
235
+ }
236
+
237
+ /**
238
+ * `allowedHosts` as a function: ask it about every URL andbox is about to
239
+ * request, the first one and every redirect hop. Redirects are followed by
240
+ * andbox itself (`redirect: 'manual'` underneath), re-asking the function for
241
+ * each `Location`, with the Fetch standard's method/body rewriting and
242
+ * `Authorization` dropped on a cross-origin hop. Where the platform hides the
243
+ * target (a browser's `opaqueredirect`), the request fails closed.
244
+ */
245
+ function createPolicyFetch(policy, hostFetch) {
246
+ const send = resolveFetch(hostFetch);
247
+ return async function policyFetch(url, init) {
248
+ const { body: firstBody, headers: firstHeaders, method: firstMethod, ...rest } = init;
249
+ const mode = init.redirect ?? 'follow';
250
+ let href = url;
251
+ let method = firstMethod;
252
+ let headers = new Headers(firstHeaders);
253
+ let body = firstBody;
254
+ for (let hop = 0; ; hop++) {
255
+ await askPolicy(policy, href);
256
+ const res = await send(href, {
257
+ ...rest,
258
+ method,
259
+ headers,
260
+ ...(body !== undefined ? { body } : {}),
261
+ redirect: 'manual',
262
+ });
263
+ if (mode === 'manual') return res;
264
+ if (res?.type === 'opaqueredirect') {
265
+ throw new Error(
266
+ `Network access denied: ${new URL(href).host} answered with a redirect whose target this platform's fetch hides, ` +
267
+ "so network.allowedHosts cannot check it; pass a network.fetch that returns the redirect response, or use allowedHosts: '*'"
268
+ );
269
+ }
270
+ const location = REDIRECT_STATUSES.has(res?.status) ? responseHeaders(res).get('location') : null;
271
+ if (location === null) {
272
+ if (hop === 0) return res;
273
+ return { status: res.status, statusText: res.statusText, headers: responseHeaders(res), body: await readBody(res), url: href, redirected: true };
274
+ }
275
+ discardBody(res);
276
+ if (mode === 'error') throw new Error(`fetch: ${new URL(href).host} redirected and the request's redirect mode is 'error'`);
277
+ if (hop + 1 > MAX_REDIRECTS) throw new Error(`fetch: more than ${MAX_REDIRECTS} redirects`);
278
+ let next;
279
+ try {
280
+ next = new URL(location, href);
281
+ } catch {
282
+ throw new Error(`fetch: ${new URL(href).host} redirected to an invalid URL`);
283
+ }
284
+ if (next.protocol !== 'http:' && next.protocol !== 'https:') {
285
+ throw new Error(`Network access denied: ${new URL(href).host} redirected to a ${next.protocol} URL`);
286
+ }
287
+ const status = res.status;
288
+ if ((status === 303 && method !== 'GET' && method !== 'HEAD') || ((status === 301 || status === 302) && method === 'POST')) {
289
+ method = 'GET';
290
+ body = undefined;
291
+ headers = new Headers(headers);
292
+ for (const name of BODY_HEADERS) headers.delete(name);
293
+ }
294
+ if (next.origin !== new URL(href).origin) {
295
+ headers = new Headers(headers);
296
+ headers.delete('authorization');
297
+ }
298
+ href = next.href;
299
+ }
300
+ };
301
+ }
302
+
303
+ async function readBody(res) {
304
+ if (res == null) return null;
305
+ if (typeof res.arrayBuffer === 'function') return res.arrayBuffer();
306
+ return res.body ?? null;
307
+ }
308
+
309
+ /**
310
+ * The host-side sender for a validated `network`: the policy in front of the
311
+ * host's (or the platform's) fetch.
312
+ */
313
+ function createNetworkSender({ hostFetch, allowedHosts }) {
314
+ if (allowedHosts === ANY_HOST) return resolveFetch(hostFetch);
315
+ if (typeof allowedHosts === 'function') return createPolicyFetch(allowedHosts, hostFetch);
316
+ return createNetworkFetch(allowedHosts, hostFetch);
317
+ }
318
+
319
+ /**
320
+ * Validate `createSandbox({ network })` and build the host-side `fetch`
321
+ * capability behind the sandbox's global `fetch` shim.
322
+ *
323
+ * Everything that arrives from the sandbox is treated as untrusted input
324
+ * (evaluated code can also call `host.call('fetch', url, init)` directly):
325
+ * the URL must be http(s) and pass `allowedHosts` before the host's `fetch`
326
+ * is called, only method/headers/body/redirect are taken from the request,
327
+ * and `credentials` and `signal` are always set by the host.
328
+ *
329
+ * @param {{ allowedHosts: string[] | '*' | ((url: URL) => boolean | Promise<boolean>), fetch?: Function, credentials?: RequestCredentials }} network
330
+ * @returns {(this: { signal?: AbortSignal }, url: unknown, init?: unknown) => Promise<object>}
331
+ */
332
+ export function createFetchCapability(network) {
333
+ const options = validateNetworkOptions(network);
334
+ const { credentials } = options;
335
+ const send = createNetworkSender(options);
151
336
 
152
337
  return async function fetchCapability(url, init) {
153
338
  if (typeof url !== 'string') throw new TypeError('fetch: the URL must be a string');