@fedify/relay 2.4.0-dev.2169 → 2.4.0-dev.2190

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.
@@ -1,7 +1,7 @@
1
1
  import "temporal-polyfill";
2
2
  import "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
- import { n as exportSpki, r as getDocumentLoader, t as isRelayFollowerData } from "./types-CUSXp8T2.js";
4
+ import { n as exportSpki, r as getDocumentLoader, t as isRelayFollowerData } from "./types-CKyeugDD.js";
5
5
  import { MemoryKvStore, signRequest } from "@fedify/fedify";
6
6
  import { createRelay } from "@fedify/relay";
7
7
  import { deepStrictEqual, ok, strictEqual } from "node:assert";
@@ -1,7 +1,7 @@
1
1
  import "temporal-polyfill";
2
2
  import "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
- import { n as exportSpki, r as getDocumentLoader, t as isRelayFollowerData } from "./types-CUSXp8T2.js";
4
+ import { n as exportSpki, r as getDocumentLoader, t as isRelayFollowerData } from "./types-CKyeugDD.js";
5
5
  import { MemoryKvStore, signJsonLd, signRequest } from "@fedify/fedify";
6
6
  import { createRelay } from "@fedify/relay";
7
7
  import { deepStrictEqual, ok, strictEqual } from "node:assert";
package/dist/mod.d.cts CHANGED
@@ -1,7 +1,7 @@
1
1
  /// <reference lib="esnext.temporal" />
2
2
  import { Context, KvStore, MessageQueue } from "@fedify/fedify";
3
3
  import { Actor } from "@fedify/vocab";
4
- //#region ../vocab-runtime/dist/docloader-ClQraSWr.d.ts
4
+ //#region ../vocab-runtime/dist/docloader-C63enuyr.d.ts
5
5
  /**
6
6
  * Options for making `User-Agent` string.
7
7
  * @see {@link getUserAgent}
@@ -100,6 +100,28 @@ interface DocumentLoaderFactoryOptions {
100
100
  * @since 2.2.0
101
101
  */
102
102
  maxRedirection?: number;
103
+ /**
104
+ * The timeout in milliseconds for each call of the created document
105
+ * loader. The timeout is shared by all the steps of a call, including
106
+ * URL validation, every HTTP redirect and alternate document link it
107
+ * follows, retries, and reading the response body; it does not restart
108
+ * for each of them. It does not interrupt synchronous work such as
109
+ * parsing a document that has already been received.
110
+ *
111
+ * When a call times out, the loader throws a {@link FetchError} without
112
+ * a {@link FetchError.response}, whose `cause` is a `DOMException` named
113
+ * `"TimeoutError"`. An `AbortSignal` passed through
114
+ * {@link DocumentLoaderOptions.signal} still cancels a call; in that case
115
+ * the loader throws the signal's reason as before.
116
+ *
117
+ * Fractional values are rounded up. Set it to `null` to turn off the
118
+ * timeout.
119
+ * @default `10000` (10 seconds)
120
+ * @throws {RangeError} If the value is not a positive finite number or is
121
+ * greater than 2,147,483,647 (about 24.8 days).
122
+ * @since 2.4.0
123
+ */
124
+ timeout?: number | null;
103
125
  }
104
126
  /**
105
127
  * A factory function that creates an authenticated {@link DocumentLoader} for
package/dist/mod.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /// <reference lib="esnext.temporal" />
2
2
  import { Context, KvStore, MessageQueue } from "@fedify/fedify";
3
3
  import { Actor } from "@fedify/vocab";
4
- //#region ../vocab-runtime/dist/docloader-ClQraSWr.d.ts
4
+ //#region ../vocab-runtime/dist/docloader-C63enuyr.d.ts
5
5
  /**
6
6
  * Options for making `User-Agent` string.
7
7
  * @see {@link getUserAgent}
@@ -100,6 +100,28 @@ interface DocumentLoaderFactoryOptions {
100
100
  * @since 2.2.0
101
101
  */
102
102
  maxRedirection?: number;
103
+ /**
104
+ * The timeout in milliseconds for each call of the created document
105
+ * loader. The timeout is shared by all the steps of a call, including
106
+ * URL validation, every HTTP redirect and alternate document link it
107
+ * follows, retries, and reading the response body; it does not restart
108
+ * for each of them. It does not interrupt synchronous work such as
109
+ * parsing a document that has already been received.
110
+ *
111
+ * When a call times out, the loader throws a {@link FetchError} without
112
+ * a {@link FetchError.response}, whose `cause` is a `DOMException` named
113
+ * `"TimeoutError"`. An `AbortSignal` passed through
114
+ * {@link DocumentLoaderOptions.signal} still cancels a call; in that case
115
+ * the loader throws the signal's reason as before.
116
+ *
117
+ * Fractional values are rounded up. Set it to `null` to turn off the
118
+ * timeout.
119
+ * @default `10000` (10 seconds)
120
+ * @throws {RangeError} If the value is not a positive finite number or is
121
+ * greater than 2,147,483,647 (about 24.8 days).
122
+ * @since 2.4.0
123
+ */
124
+ timeout?: number | null;
103
125
  }
104
126
  /**
105
127
  * A factory function that creates an authenticated {@link DocumentLoader} for
@@ -26114,7 +26114,7 @@ var require_src = /* @__PURE__ */ __commonJSMin(((exports, module) => {
26114
26114
  require_build();
26115
26115
  var import_src = /* @__PURE__ */ __toESM(require_src(), 1);
26116
26116
  var name = "@fedify/vocab-runtime";
26117
- var version = "2.4.0-dev.2169+0a45df07";
26117
+ var version = "2.4.0-dev.2190+be3a8be0";
26118
26118
  /**
26119
26119
  * Error thrown when fetching a JSON-LD document failed.
26120
26120
  */
@@ -26261,6 +26261,59 @@ async function readBoundedText(message, maxBytes, url) {
26261
26261
  reader?.releaseLock();
26262
26262
  }
26263
26263
  }
26264
+ /**
26265
+ * Reads raw bytes while limiting bytes received from the body stream.
26266
+ * Unlike {@link readBoundedText}, the bytes are returned as they were
26267
+ * received, without decoding.
26268
+ * @param message The response or request to read.
26269
+ * @param maxBytes The maximum number of decoded bytes to read.
26270
+ * @param url The URL to include in errors and logs.
26271
+ * @returns The body bytes.
26272
+ * @throws {BodyTooLargeError} If the body exceeds the limit.
26273
+ * @internal
26274
+ */
26275
+ async function readBoundedBytes(message, maxBytes, url) {
26276
+ validateBodySizeLimit(maxBytes);
26277
+ const reader = message.body?.getReader();
26278
+ const tooLarge = () => {
26279
+ getLogger([
26280
+ "fedify",
26281
+ "runtime",
26282
+ "body"
26283
+ ]).warn("Body from {url} exceeds the limit of {maxBytes} bytes.", {
26284
+ url: url.toString(),
26285
+ maxBytes
26286
+ });
26287
+ throw new BodyTooLargeError(url, maxBytes);
26288
+ };
26289
+ try {
26290
+ const length = message.headers.get("Content-Length");
26291
+ const encoding = message.headers.get("Content-Encoding");
26292
+ if ((encoding == null || encoding.toLowerCase() === "identity") && length != null && /^\d+$/.test(length) && Number(length) > maxBytes) tooLarge();
26293
+ if (reader == null) return /* @__PURE__ */ new Uint8Array(0);
26294
+ const chunks = [];
26295
+ let size = 0;
26296
+ while (true) {
26297
+ const { done, value } = await reader.read();
26298
+ if (done) break;
26299
+ size += value.byteLength;
26300
+ if (size > maxBytes) tooLarge();
26301
+ chunks.push(value);
26302
+ }
26303
+ const bytes = new Uint8Array(size);
26304
+ let offset = 0;
26305
+ for (const chunk of chunks) {
26306
+ bytes.set(chunk, offset);
26307
+ offset += chunk.byteLength;
26308
+ }
26309
+ return bytes;
26310
+ } catch (error) {
26311
+ if (reader != null) reader.cancel(error).catch(() => {});
26312
+ throw error;
26313
+ } finally {
26314
+ reader?.releaseLock();
26315
+ }
26316
+ }
26264
26317
  const parametersNeedLowerCase = ["rel", "type"];
26265
26318
  const regexpLinkWhitespace = /[\n\r\s\t]/;
26266
26319
  function validateURI(uri) {
@@ -26447,6 +26500,9 @@ const logger = getLogger([
26447
26500
  ]);
26448
26501
  const DEFAULT_MAX_REDIRECTION = 20;
26449
26502
  const MAX_HTML_SIZE = 1024 * 1024;
26503
+ const MAX_ERROR_BODY_SIZE = 1024 * 1024;
26504
+ const DEFAULT_TIMEOUT = 1e4;
26505
+ const MAX_TIMEOUT = 2147483647;
26450
26506
  function createResponseMetadata(response) {
26451
26507
  return new Response(null, {
26452
26508
  headers: response.headers,
@@ -26454,6 +26510,55 @@ function createResponseMetadata(response) {
26454
26510
  statusText: response.statusText
26455
26511
  });
26456
26512
  }
26513
+ const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
26514
+ 204,
26515
+ 205,
26516
+ 304
26517
+ ]);
26518
+ /**
26519
+ * Reads the body of an error response while the document loader is still
26520
+ * running, so that its timeout and `AbortSignal` also bound the read, and
26521
+ * nothing reading {@link FetchError.response} later waits on the network.
26522
+ * The body is kept byte for byte, unless it is too large or cannot be read;
26523
+ * then only the status and headers are kept. A response whose status
26524
+ * the `Response` constructor does not accept (e.g., 999) is kept as a clone
26525
+ * whose body has been read in full; if its body is too large or cannot be
26526
+ * read, no response is kept at all.
26527
+ */
26528
+ async function bufferErrorResponse(response, url, signal) {
26529
+ if (response.status < 200 || response.status > 599) {
26530
+ const clone = response.clone();
26531
+ try {
26532
+ await readBoundedBytes(response, MAX_ERROR_BODY_SIZE, url);
26533
+ } catch (error) {
26534
+ await clone.body?.cancel().catch(() => {});
26535
+ if (signal?.aborted) throw error;
26536
+ logger.debug("Failed to read the error response body from {url}: {error}", {
26537
+ url,
26538
+ error
26539
+ });
26540
+ return;
26541
+ }
26542
+ return clone;
26543
+ }
26544
+ if (response.body == null || NULL_BODY_STATUSES.has(response.status)) return createResponseMetadata(response);
26545
+ let body;
26546
+ try {
26547
+ body = await readBoundedBytes(response, MAX_ERROR_BODY_SIZE, url);
26548
+ } catch (error) {
26549
+ if (signal?.aborted) throw error;
26550
+ logger.debug("Failed to read the error response body from {url}: {error}", {
26551
+ url,
26552
+ error
26553
+ });
26554
+ return createResponseMetadata(response);
26555
+ }
26556
+ return new Response(body, {
26557
+ headers: response.headers,
26558
+ status: response.status,
26559
+ statusText: response.statusText
26560
+ });
26561
+ }
26457
26562
  /**
26458
26563
  * Gets a {@link RemoteDocument} from the given response.
26459
26564
  * @param url The URL of the document to load.
@@ -26478,7 +26583,7 @@ async function getRemoteDocument(url, response, fetch, options) {
26478
26583
  url: documentUrl,
26479
26584
  headers: Object.fromEntries(response.headers.entries())
26480
26585
  });
26481
- throw new FetchError(documentUrl, `HTTP ${response.status}: ${documentUrl}`, response.clone());
26586
+ throw new FetchError(documentUrl, `HTTP ${response.status}: ${documentUrl}`, await bufferErrorResponse(response, documentUrl, options?.signal));
26482
26587
  }
26483
26588
  const contentType = response.headers.get("Content-Type");
26484
26589
  const jsonLd = contentType == null || contentType === "application/activity+json" || contentType.startsWith("application/activity+json;") || contentType === "application/ld+json" || contentType.startsWith("application/ld+json;");
@@ -26565,9 +26670,87 @@ async function getRemoteDocument(url, response, fetch, options) {
26565
26670
  };
26566
26671
  }
26567
26672
  /**
26673
+ * Resolves {@link DocumentLoaderFactoryOptions.timeout} into milliseconds.
26674
+ * @param timeout The timeout option. `undefined` means the default timeout,
26675
+ * and `null` means no timeout.
26676
+ * @returns The timeout in milliseconds, or `null` if it is turned off.
26677
+ * @throws {RangeError} If the timeout is invalid.
26678
+ * @internal
26679
+ */
26680
+ function resolveDocumentLoaderTimeout(timeout) {
26681
+ if (timeout === void 0) return DEFAULT_TIMEOUT;
26682
+ if (timeout === null) return null;
26683
+ if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) throw new RangeError(`The document loader timeout must be a positive finite number of milliseconds, but got ${String(timeout)}.`);
26684
+ const ms = Math.ceil(timeout);
26685
+ if (ms > MAX_TIMEOUT) throw new RangeError(`The document loader timeout must not be greater than ${MAX_TIMEOUT} milliseconds, but got ${timeout}.`);
26686
+ return ms;
26687
+ }
26688
+ /**
26689
+ * Bounds each call of the given document loader by the given timeout.
26690
+ * The timeout is combined with the caller's `signal`, and the combined
26691
+ * signal is passed to the loader. The call settles no later than the
26692
+ * timeout even if the loader is stuck in a step that cannot be aborted,
26693
+ * e.g., a DNS lookup.
26694
+ *
26695
+ * A timed-out call throws a {@link FetchError} without a response, whose
26696
+ * `cause` is a `DOMException` named `"TimeoutError"`. If the caller's
26697
+ * signal is aborted, its reason is thrown instead.
26698
+ * @param loader The document loader to bound.
26699
+ * @param timeout The timeout in milliseconds, or `null` for no timeout.
26700
+ * It is assumed to have been resolved by
26701
+ * {@link resolveDocumentLoaderTimeout}.
26702
+ * @returns The bounded document loader.
26703
+ * @internal
26704
+ */
26705
+ function withDocumentLoaderTimeout(loader, timeout) {
26706
+ if (timeout == null) return loader;
26707
+ return async (url, options) => {
26708
+ const callerSignal = options?.signal;
26709
+ callerSignal?.throwIfAborted();
26710
+ const controller = new AbortController();
26711
+ const timeoutReason = new DOMException(`The document loader timed out after ${timeout} ms.`, "TimeoutError");
26712
+ let timedOut = false;
26713
+ const timer = setTimeout(() => {
26714
+ timedOut = true;
26715
+ controller.abort(timeoutReason);
26716
+ }, timeout);
26717
+ const onCallerAbort = () => controller.abort(callerSignal?.reason);
26718
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
26719
+ let onAbort;
26720
+ const aborted = new Promise((_, reject) => {
26721
+ onAbort = () => reject(controller.signal.reason);
26722
+ controller.signal.addEventListener("abort", onAbort, { once: true });
26723
+ });
26724
+ const loading = loader(url, {
26725
+ ...options,
26726
+ signal: controller.signal
26727
+ });
26728
+ loading.catch(() => {});
26729
+ try {
26730
+ return await Promise.race([loading, aborted]);
26731
+ } catch (error) {
26732
+ if (callerSignal?.aborted) throw callerSignal.reason;
26733
+ if (!timedOut) throw error;
26734
+ logger[options?.suppressError ? "warn" : "error"]("Timed out after {timeout} ms while fetching document: {url}", {
26735
+ timeout,
26736
+ url
26737
+ });
26738
+ const fetchError = new FetchError(url, `Timed out after ${timeout} ms`);
26739
+ fetchError.cause = timeoutReason;
26740
+ throw fetchError;
26741
+ } finally {
26742
+ clearTimeout(timer);
26743
+ callerSignal?.removeEventListener("abort", onCallerAbort);
26744
+ if (onAbort != null) controller.signal.removeEventListener("abort", onAbort);
26745
+ }
26746
+ };
26747
+ }
26748
+ /**
26568
26749
  * Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
26569
26750
  * At most 20 HTTP redirects and alternate document links are followed in total
26570
26751
  * per call. Revisiting a URL within that chain throws a {@link FetchError}.
26752
+ * Each call times out after 10 seconds by default; see
26753
+ * {@link DocumentLoaderFactoryOptions.timeout}.
26571
26754
  *
26572
26755
  * The created loader preloads the below frequently used contexts by default
26573
26756
  * (unless `options.skipPreloadedContexts` is set to `true`):
@@ -26587,7 +26770,8 @@ async function getRemoteDocument(url, response, fetch, options) {
26587
26770
  * @returns The document loader.
26588
26771
  * @since 1.3.0
26589
26772
  */
26590
- function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedContexts, userAgent } = {}) {
26773
+ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedContexts, timeout, userAgent } = {}) {
26774
+ const resolvedTimeout = resolveDocumentLoaderTimeout(timeout);
26591
26775
  const tracer = trace.getTracerProvider().getTracer(name, version);
26592
26776
  const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
26593
26777
  async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
@@ -26601,18 +26785,21 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
26601
26785
  documentUrl: currentUrl
26602
26786
  };
26603
26787
  }
26604
- if (!allowPrivateAddress) try {
26605
- await validatePublicUrl(currentUrl);
26606
- } catch (error) {
26607
- if (error instanceof UrlError) if (error.reason === "dns") logger.debug("DNS lookup failed for {url}", {
26608
- url: currentUrl,
26609
- error
26610
- });
26611
- else logger[options?.suppressError ? "warn" : "error"]("Disallowed private URL: {url}", {
26612
- url: currentUrl,
26613
- error
26614
- });
26615
- throw error;
26788
+ if (!allowPrivateAddress) {
26789
+ try {
26790
+ await validatePublicUrl(currentUrl);
26791
+ } catch (error) {
26792
+ if (error instanceof UrlError) if (error.reason === "dns") logger.debug("DNS lookup failed for {url}", {
26793
+ url: currentUrl,
26794
+ error
26795
+ });
26796
+ else logger[options?.suppressError ? "warn" : "error"]("Disallowed private URL: {url}", {
26797
+ url: currentUrl,
26798
+ error
26799
+ });
26800
+ throw error;
26801
+ }
26802
+ options?.signal?.throwIfAborted();
26616
26803
  }
26617
26804
  visited.add(currentUrl);
26618
26805
  return await tracer.startActiveSpan("activitypub.fetch_document", {
@@ -26667,7 +26854,7 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
26667
26854
  }
26668
26855
  });
26669
26856
  }
26670
- return (url, options) => load(url, options);
26857
+ return withDocumentLoaderTimeout((url, options) => load(url, options), resolvedTimeout);
26671
26858
  }
26672
26859
  function validateCryptoKey(key, type) {
26673
26860
  if (type != null && key.type !== type) throw new TypeError(`The key is not a ${type} key.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/relay",
3
- "version": "2.4.0-dev.2169+0a45df07",
3
+ "version": "2.4.0-dev.2190+be3a8be0",
4
4
  "description": "ActivityPub relay support for Fedify",
5
5
  "keywords": [
6
6
  "Fedify",
@@ -47,14 +47,14 @@
47
47
  "dependencies": {
48
48
  "@logtape/logtape": "^2.2.0",
49
49
  "temporal-polyfill": "^1.0.1",
50
- "@fedify/vocab": "2.4.0-dev.2169+0a45df07",
51
- "@fedify/fedify": "^2.4.0-dev.2169+0a45df07"
50
+ "@fedify/fedify": "^2.4.0-dev.2190+be3a8be0",
51
+ "@fedify/vocab": "2.4.0-dev.2190+be3a8be0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "tsdown": "^0.22.0",
55
55
  "typescript": "^6.0.0",
56
56
  "urlpattern-polyfill": "^10.1.0",
57
- "@fedify/vocab-runtime": "^2.4.0-dev.2169+0a45df07"
57
+ "@fedify/vocab-runtime": "^2.4.0-dev.2190+be3a8be0"
58
58
  },
59
59
  "scripts": {
60
60
  "build:self": "tsdown",