@fedify/vocab-runtime 2.4.0-dev.2169 → 2.4.0-dev.2193

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 (50) hide show
  1. package/deno.json +1 -1
  2. package/dist/{docloader-ClQraSWr.d.cts → docloader-C63enuyr.d.cts} +53 -1
  3. package/dist/{docloader-ClQraSWr.d.ts → docloader-C63enuyr.d.ts} +53 -1
  4. package/dist/internal/jsonld-cache.cjs +1 -1
  5. package/dist/internal/jsonld-cache.d.cts +1 -1
  6. package/dist/internal/jsonld-cache.d.ts +1 -1
  7. package/dist/internal/jsonld-cache.js +1 -1
  8. package/dist/internal/portable-dereference.cjs +1 -1
  9. package/dist/internal/portable-dereference.d.cts +2 -2
  10. package/dist/internal/portable-dereference.d.ts +2 -2
  11. package/dist/internal/portable-dereference.js +1 -1
  12. package/dist/mod.cjs +206 -17
  13. package/dist/mod.d.cts +7 -3
  14. package/dist/mod.d.ts +7 -3
  15. package/dist/mod.js +205 -18
  16. package/dist/{portable-ilkFPcXa.d.cts → portable-Cux3IA20.d.cts} +1 -1
  17. package/dist/{portable-DRtvOxTb.d.ts → portable-DBA1FS_L.d.ts} +1 -1
  18. package/dist/tests/{body-CNwDKYQs.mjs → body-D1z0X88T.mjs} +55 -2
  19. package/dist/tests/{body-DHAC8qhR.cjs → body-ORlui-Nx.cjs} +60 -1
  20. package/dist/tests/body.test.cjs +1 -1
  21. package/dist/tests/body.test.mjs +1 -1
  22. package/dist/tests/decimal.test.cjs +2 -2
  23. package/dist/tests/decimal.test.mjs +2 -2
  24. package/dist/tests/{docloader-DWkl3u7X.mjs → docloader-CUdtpDuf.mjs} +153 -19
  25. package/dist/tests/{docloader-DLkb9qAS.cjs → docloader-Vc3BC9ad.cjs} +164 -18
  26. package/dist/tests/docloader.test.cjs +266 -3
  27. package/dist/tests/docloader.test.mjs +267 -4
  28. package/dist/tests/internal/portable-dereference.test.cjs +2 -2
  29. package/dist/tests/internal/portable-dereference.test.mjs +2 -2
  30. package/dist/tests/{jsonld-cache-BO-QXxIc.cjs → jsonld-cache-BQMn_ILF.cjs} +1 -1
  31. package/dist/tests/{jsonld-cache-B7PKo3Lu.mjs → jsonld-cache-DUmuWRwp.mjs} +1 -1
  32. package/dist/tests/jsonld-cache.test.cjs +2 -2
  33. package/dist/tests/jsonld-cache.test.mjs +2 -2
  34. package/dist/tests/{request-Dm3ye46t.cjs → request-C5_3S-un.cjs} +1 -1
  35. package/dist/tests/{request-Cg8MQByW.mjs → request-IIntZ5kj.mjs} +1 -1
  36. package/dist/tests/request.test.cjs +1 -1
  37. package/dist/tests/request.test.mjs +1 -1
  38. package/dist/tests/{url-gLKqAO1H.cjs → url-B8xyTwP-.cjs} +5 -1
  39. package/dist/tests/{url-D7qRF-dF.mjs → url-DfS-cvwr.mjs} +5 -1
  40. package/dist/tests/url.test.cjs +2 -2
  41. package/dist/tests/url.test.mjs +2 -2
  42. package/dist/{url-BHZYe8Vu.js → url-Be3moMor.js} +5 -1
  43. package/dist/{url-P6l9s9dN.cjs → url-DDVweCXr.cjs} +5 -1
  44. package/package.json +1 -1
  45. package/src/body.ts +57 -0
  46. package/src/docloader.test.ts +341 -3
  47. package/src/docloader.ts +205 -5
  48. package/src/mod.ts +2 -0
  49. package/src/url.test.ts +7 -1
  50. package/src/url.ts +6 -2
package/src/docloader.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import { getLogger } from "@logtape/logtape";
2
2
  import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
3
3
  import metadata from "../deno.json" with { type: "json" };
4
- import { BodyTooLargeError, MAX_BODY_SIZE, readBoundedText } from "./body.ts";
4
+ import {
5
+ BodyTooLargeError,
6
+ MAX_BODY_SIZE,
7
+ readBoundedBytes,
8
+ readBoundedText,
9
+ } from "./body.ts";
5
10
  import preloadedContexts from "./contexts.ts";
6
11
  import { HttpHeaderLink } from "./link.ts";
7
12
  import {
@@ -15,6 +20,11 @@ import { UrlError, validatePublicUrl } from "./url.ts";
15
20
  const logger = getLogger(["fedify", "runtime", "docloader"]);
16
21
  const DEFAULT_MAX_REDIRECTION = 20;
17
22
  const MAX_HTML_SIZE = 1024 * 1024; // 1MB
23
+ const MAX_ERROR_BODY_SIZE = 1024 * 1024; // 1MB
24
+ const DEFAULT_TIMEOUT = 10_000; // 10 seconds
25
+ // The maximum delay setTimeout() accepts; larger values fire immediately
26
+ // on some runtimes:
27
+ const MAX_TIMEOUT = 2_147_483_647;
18
28
 
19
29
  /**
20
30
  * A remote JSON-LD document and its context fetched by
@@ -104,6 +114,29 @@ export interface DocumentLoaderFactoryOptions {
104
114
  * @since 2.2.0
105
115
  */
106
116
  maxRedirection?: number;
117
+
118
+ /**
119
+ * The timeout in milliseconds for each call of the created document
120
+ * loader. The timeout is shared by all the steps of a call, including
121
+ * URL validation, every HTTP redirect and alternate document link it
122
+ * follows, retries, and reading the response body; it does not restart
123
+ * for each of them. It does not interrupt synchronous work such as
124
+ * parsing a document that has already been received.
125
+ *
126
+ * When a call times out, the loader throws a {@link FetchError} without
127
+ * a {@link FetchError.response}, whose `cause` is a `DOMException` named
128
+ * `"TimeoutError"`. An `AbortSignal` passed through
129
+ * {@link DocumentLoaderOptions.signal} still cancels a call; in that case
130
+ * the loader throws the signal's reason as before.
131
+ *
132
+ * Fractional values are rounded up. Set it to `null` to turn off the
133
+ * timeout.
134
+ * @default `10000` (10 seconds)
135
+ * @throws {RangeError} If the value is not a positive finite number or is
136
+ * greater than 2,147,483,647 (about 24.8 days).
137
+ * @since 2.4.0
138
+ */
139
+ timeout?: number | null;
107
140
  }
108
141
 
109
142
  /**
@@ -129,6 +162,61 @@ function createResponseMetadata(response: Response): Response {
129
162
  });
130
163
  }
131
164
 
165
+ const NULL_BODY_STATUSES: ReadonlySet<number> = new Set([204, 205, 304]);
166
+
167
+ /**
168
+ * Reads the body of an error response while the document loader is still
169
+ * running, so that its timeout and `AbortSignal` also bound the read, and
170
+ * nothing reading {@link FetchError.response} later waits on the network.
171
+ * The body is kept byte for byte, unless it is too large or cannot be read;
172
+ * then only the status and headers are kept. A response whose status
173
+ * the `Response` constructor does not accept (e.g., 999) is kept as a clone
174
+ * whose body has been read in full; if its body is too large or cannot be
175
+ * read, no response is kept at all.
176
+ */
177
+ async function bufferErrorResponse(
178
+ response: Response,
179
+ url: string,
180
+ signal?: AbortSignal,
181
+ ): Promise<Response | undefined> {
182
+ if (response.status < 200 || response.status > 599) {
183
+ // Such a response cannot be rebuilt, so keep a clone instead, and read
184
+ // the original to the end so that the clone's body is buffered too:
185
+ const clone = response.clone();
186
+ try {
187
+ await readBoundedBytes(response, MAX_ERROR_BODY_SIZE, url);
188
+ } catch (error) {
189
+ await clone.body?.cancel().catch(() => {});
190
+ if (signal?.aborted) throw error;
191
+ logger.debug(
192
+ "Failed to read the error response body from {url}: {error}",
193
+ { url, error },
194
+ );
195
+ return undefined;
196
+ }
197
+ return clone;
198
+ }
199
+ if (response.body == null || NULL_BODY_STATUSES.has(response.status)) {
200
+ return createResponseMetadata(response);
201
+ }
202
+ let body: Uint8Array<ArrayBuffer>;
203
+ try {
204
+ body = await readBoundedBytes(response, MAX_ERROR_BODY_SIZE, url);
205
+ } catch (error) {
206
+ if (signal?.aborted) throw error;
207
+ logger.debug(
208
+ "Failed to read the error response body from {url}: {error}",
209
+ { url, error },
210
+ );
211
+ return createResponseMetadata(response);
212
+ }
213
+ return new Response(body, {
214
+ headers: response.headers,
215
+ status: response.status,
216
+ statusText: response.statusText,
217
+ });
218
+ }
219
+
132
220
  /**
133
221
  * Gets a {@link RemoteDocument} from the given response.
134
222
  * @param url The URL of the document to load.
@@ -174,7 +262,7 @@ export async function getRemoteDocument(
174
262
  throw new FetchError(
175
263
  documentUrl,
176
264
  `HTTP ${response.status}: ${documentUrl}`,
177
- response.clone(),
265
+ await bufferErrorResponse(response, documentUrl, options?.signal),
178
266
  );
179
267
  }
180
268
  const contentType = response.headers.get("Content-Type");
@@ -311,6 +399,104 @@ export async function getRemoteDocument(
311
399
  return { contextUrl, document, documentUrl };
312
400
  }
313
401
 
402
+ /**
403
+ * Resolves {@link DocumentLoaderFactoryOptions.timeout} into milliseconds.
404
+ * @param timeout The timeout option. `undefined` means the default timeout,
405
+ * and `null` means no timeout.
406
+ * @returns The timeout in milliseconds, or `null` if it is turned off.
407
+ * @throws {RangeError} If the timeout is invalid.
408
+ * @internal
409
+ */
410
+ export function resolveDocumentLoaderTimeout(
411
+ timeout: number | null | undefined,
412
+ ): number | null {
413
+ if (timeout === undefined) return DEFAULT_TIMEOUT;
414
+ if (timeout === null) return null;
415
+ if (
416
+ typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0
417
+ ) {
418
+ throw new RangeError(
419
+ `The document loader timeout must be a positive finite number of ` +
420
+ `milliseconds, but got ${String(timeout)}.`,
421
+ );
422
+ }
423
+ const ms = Math.ceil(timeout);
424
+ if (ms > MAX_TIMEOUT) {
425
+ throw new RangeError(
426
+ `The document loader timeout must not be greater than ${MAX_TIMEOUT} ` +
427
+ `milliseconds, but got ${timeout}.`,
428
+ );
429
+ }
430
+ return ms;
431
+ }
432
+
433
+ /**
434
+ * Bounds each call of the given document loader by the given timeout.
435
+ * The timeout is combined with the caller's `signal`, and the combined
436
+ * signal is passed to the loader. The call settles no later than the
437
+ * timeout even if the loader is stuck in a step that cannot be aborted,
438
+ * e.g., a DNS lookup.
439
+ *
440
+ * A timed-out call throws a {@link FetchError} without a response, whose
441
+ * `cause` is a `DOMException` named `"TimeoutError"`. If the caller's
442
+ * signal is aborted, its reason is thrown instead.
443
+ * @param loader The document loader to bound.
444
+ * @param timeout The timeout in milliseconds, or `null` for no timeout.
445
+ * It is assumed to have been resolved by
446
+ * {@link resolveDocumentLoaderTimeout}.
447
+ * @returns The bounded document loader.
448
+ * @internal
449
+ */
450
+ export function withDocumentLoaderTimeout(
451
+ loader: DocumentLoader,
452
+ timeout: number | null,
453
+ ): DocumentLoader {
454
+ if (timeout == null) return loader;
455
+ return async (url, options) => {
456
+ const callerSignal = options?.signal;
457
+ callerSignal?.throwIfAborted();
458
+ const controller = new AbortController();
459
+ const timeoutReason = new DOMException(
460
+ `The document loader timed out after ${timeout} ms.`,
461
+ "TimeoutError",
462
+ );
463
+ let timedOut = false;
464
+ const timer = setTimeout(() => {
465
+ timedOut = true;
466
+ controller.abort(timeoutReason);
467
+ }, timeout);
468
+ const onCallerAbort = () => controller.abort(callerSignal?.reason);
469
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
470
+ let onAbort: (() => void) | undefined;
471
+ const aborted = new Promise<never>((_, reject) => {
472
+ onAbort = () => reject(controller.signal.reason);
473
+ controller.signal.addEventListener("abort", onAbort, { once: true });
474
+ });
475
+ const loading = loader(url, { ...options, signal: controller.signal });
476
+ // If the abort wins the race, the loader's late rejection is ignored:
477
+ loading.catch(() => {});
478
+ try {
479
+ return await Promise.race([loading, aborted]);
480
+ } catch (error) {
481
+ if (callerSignal?.aborted) throw callerSignal.reason;
482
+ if (!timedOut) throw error;
483
+ logger[options?.suppressError ? "warn" : "error"](
484
+ "Timed out after {timeout} ms while fetching document: {url}",
485
+ { timeout, url },
486
+ );
487
+ const fetchError = new FetchError(url, `Timed out after ${timeout} ms`);
488
+ fetchError.cause = timeoutReason;
489
+ throw fetchError;
490
+ } finally {
491
+ clearTimeout(timer);
492
+ callerSignal?.removeEventListener("abort", onCallerAbort);
493
+ if (onAbort != null) {
494
+ controller.signal.removeEventListener("abort", onAbort);
495
+ }
496
+ }
497
+ };
498
+ }
499
+
314
500
  /**
315
501
  * Options for {@link getDocumentLoader}.
316
502
  * @since 1.3.0
@@ -326,6 +512,8 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
326
512
  * Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
327
513
  * At most 20 HTTP redirects and alternate document links are followed in total
328
514
  * per call. Revisiting a URL within that chain throws a {@link FetchError}.
515
+ * Each call times out after 10 seconds by default; see
516
+ * {@link DocumentLoaderFactoryOptions.timeout}.
329
517
  *
330
518
  * The created loader preloads the below frequently used contexts by default
331
519
  * (unless `options.skipPreloadedContexts` is set to `true`):
@@ -346,9 +534,15 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
346
534
  * @since 1.3.0
347
535
  */
348
536
  export function getDocumentLoader(
349
- { allowPrivateAddress, maxRedirection, skipPreloadedContexts, userAgent }:
350
- GetDocumentLoaderOptions = {},
537
+ {
538
+ allowPrivateAddress,
539
+ maxRedirection,
540
+ skipPreloadedContexts,
541
+ timeout,
542
+ userAgent,
543
+ }: GetDocumentLoaderOptions = {},
351
544
  ): DocumentLoader {
545
+ const resolvedTimeout = resolveDocumentLoaderTimeout(timeout);
352
546
  const tracerProvider = trace.getTracerProvider();
353
547
  const tracer = tracerProvider.getTracer(metadata.name, metadata.version);
354
548
  const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
@@ -391,6 +585,9 @@ export function getDocumentLoader(
391
585
  }
392
586
  throw error;
393
587
  }
588
+ // The DNS lookup cannot be aborted, so do not go on if the call was
589
+ // aborted or timed out in the meantime:
590
+ options?.signal?.throwIfAborted();
394
591
  }
395
592
  visited.add(currentUrl);
396
593
 
@@ -488,5 +685,8 @@ export function getDocumentLoader(
488
685
  },
489
686
  );
490
687
  }
491
- return (url, options) => load(url, options);
688
+ return withDocumentLoaderTimeout(
689
+ (url, options) => load(url, options),
690
+ resolvedTimeout,
691
+ );
492
692
  }
package/src/mod.ts CHANGED
@@ -15,6 +15,8 @@ export {
15
15
  type GetDocumentLoaderOptions,
16
16
  getRemoteDocument,
17
17
  type RemoteDocument,
18
+ resolveDocumentLoaderTimeout,
19
+ withDocumentLoaderTimeout,
18
20
  } from "./docloader.ts";
19
21
  export {
20
22
  type DidKeyVerificationMethod,
package/src/url.test.ts CHANGED
@@ -659,7 +659,13 @@ test("parseGatewayUrl() accepts only HTTP(S) base URIs", () => {
659
659
  "https://server.example/#fragment",
660
660
  ]
661
661
  ) {
662
- throws(() => parseGatewayUrl(url), TypeError);
662
+ // Inboxes tell a malformed gateway from other errors by this prefix:
663
+ throws(
664
+ () => parseGatewayUrl(url),
665
+ (error) =>
666
+ error instanceof TypeError &&
667
+ error.message.startsWith("Invalid FEP-ef61 gateway: "),
668
+ );
663
669
  ok(!isGatewayUrl(new URL(url)));
664
670
  }
665
671
  });
package/src/url.ts CHANGED
@@ -348,13 +348,17 @@ export function isGatewayUrl(url: URL): boolean {
348
348
 
349
349
  /**
350
350
  * Parses and validates an FEP-ef61 gateway base URI.
351
+ * @throws {TypeError} If the URI is malformed, or is not an HTTP(S) base URI
352
+ * with no credentials, path, query, or fragment. In the
353
+ * latter case, the message starts with
354
+ * `Invalid FEP-ef61 gateway:`.
351
355
  */
352
356
  export function parseGatewayUrl(url: string): URL {
353
357
  const parsed = parseIri(url);
354
358
  if (!isGatewayUrl(parsed)) {
355
359
  throw new TypeError(
356
- "FEP-ef61 gateways must be HTTP(S) base URIs with no credentials, " +
357
- "path, query, or fragment.",
360
+ `Invalid FEP-ef61 gateway: ${url}. FEP-ef61 gateways must be HTTP(S) ` +
361
+ "base URIs with no credentials, path, query, or fragment.",
358
362
  );
359
363
  }
360
364
  return parsed;