@fedify/vocab-runtime 2.0.29-dev.2121 → 2.0.29-dev.2143

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/deno.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab-runtime",
3
- "version": "2.0.29-dev.2121+c46fb9dd",
3
+ "version": "2.0.29-dev.2143+bbbfea55",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./src/mod.ts",
package/dist/mod.cjs CHANGED
@@ -4460,7 +4460,7 @@ const preloadedContexts = {
4460
4460
  //#endregion
4461
4461
  //#region deno.json
4462
4462
  var name = "@fedify/vocab-runtime";
4463
- var version = "2.0.29-dev.2121+c46fb9dd";
4463
+ var version = "2.0.29-dev.2143+bbbfea55";
4464
4464
  //#endregion
4465
4465
  //#region src/request.ts
4466
4466
  /**
@@ -5088,6 +5088,8 @@ async function getRemoteDocument(url, response, fetch) {
5088
5088
  }
5089
5089
  /**
5090
5090
  * Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
5091
+ * At most 20 HTTP redirects and alternate document links are followed in total
5092
+ * per call. Revisiting a URL within that chain throws a {@link FetchError}.
5091
5093
  *
5092
5094
  * The created loader preloads the below frequently used contexts by default
5093
5095
  * (unless `options.skipPreloadedContexts` is set to `true`):
@@ -5164,7 +5166,12 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
5164
5166
  }
5165
5167
  return await load(redirectUrl, options, redirected + 1, visited);
5166
5168
  }
5167
- const result = await getRemoteDocument(currentUrl, response, load);
5169
+ const result = await getRemoteDocument(currentUrl, response, async (alternateUrl) => {
5170
+ options?.signal?.throwIfAborted();
5171
+ if (redirected >= DEFAULT_MAX_REDIRECTION) throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
5172
+ if (visited.has(alternateUrl)) throw new FetchError(currentUrl, `Redirect loop detected: ${alternateUrl}`);
5173
+ return await load(alternateUrl, options, redirected + 1, visited);
5174
+ });
5168
5175
  span.setAttribute("docloader.document_url", result.documentUrl);
5169
5176
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
5170
5177
  return result;
@@ -5180,7 +5187,7 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
5180
5187
  }
5181
5188
  });
5182
5189
  }
5183
- return load;
5190
+ return (url, options) => load(url, options);
5184
5191
  }
5185
5192
  //#endregion
5186
5193
  //#region src/internal/multicodec.ts
package/dist/mod.d.cts CHANGED
@@ -169,6 +169,8 @@ interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
169
169
  }
170
170
  /**
171
171
  * Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
172
+ * At most 20 HTTP redirects and alternate document links are followed in total
173
+ * per call. Revisiting a URL within that chain throws a {@link FetchError}.
172
174
  *
173
175
  * The created loader preloads the below frequently used contexts by default
174
176
  * (unless `options.skipPreloadedContexts` is set to `true`):
package/dist/mod.d.ts CHANGED
@@ -168,6 +168,8 @@ interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
168
168
  }
169
169
  /**
170
170
  * Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
171
+ * At most 20 HTTP redirects and alternate document links are followed in total
172
+ * per call. Revisiting a URL within that chain throws a {@link FetchError}.
171
173
  *
172
174
  * The created loader preloads the below frequently used contexts by default
173
175
  * (unless `options.skipPreloadedContexts` is set to `true`):
package/dist/mod.js CHANGED
@@ -4455,7 +4455,7 @@ const preloadedContexts = {
4455
4455
  //#endregion
4456
4456
  //#region deno.json
4457
4457
  var name = "@fedify/vocab-runtime";
4458
- var version = "2.0.29-dev.2121+c46fb9dd";
4458
+ var version = "2.0.29-dev.2143+bbbfea55";
4459
4459
  //#endregion
4460
4460
  //#region src/request.ts
4461
4461
  /**
@@ -5083,6 +5083,8 @@ async function getRemoteDocument(url, response, fetch) {
5083
5083
  }
5084
5084
  /**
5085
5085
  * Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
5086
+ * At most 20 HTTP redirects and alternate document links are followed in total
5087
+ * per call. Revisiting a URL within that chain throws a {@link FetchError}.
5086
5088
  *
5087
5089
  * The created loader preloads the below frequently used contexts by default
5088
5090
  * (unless `options.skipPreloadedContexts` is set to `true`):
@@ -5159,7 +5161,12 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
5159
5161
  }
5160
5162
  return await load(redirectUrl, options, redirected + 1, visited);
5161
5163
  }
5162
- const result = await getRemoteDocument(currentUrl, response, load);
5164
+ const result = await getRemoteDocument(currentUrl, response, async (alternateUrl) => {
5165
+ options?.signal?.throwIfAborted();
5166
+ if (redirected >= DEFAULT_MAX_REDIRECTION) throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
5167
+ if (visited.has(alternateUrl)) throw new FetchError(currentUrl, `Redirect loop detected: ${alternateUrl}`);
5168
+ return await load(alternateUrl, options, redirected + 1, visited);
5169
+ });
5163
5170
  span.setAttribute("docloader.document_url", result.documentUrl);
5164
5171
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
5165
5172
  return result;
@@ -5175,7 +5182,7 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
5175
5182
  }
5176
5183
  });
5177
5184
  }
5178
- return load;
5185
+ return (url, options) => load(url, options);
5179
5186
  }
5180
5187
  //#endregion
5181
5188
  //#region src/internal/multicodec.ts
@@ -1,4 +1,4 @@
1
- import { t as FetchError } from "./request-xX62gFVA.mjs";
1
+ import { t as FetchError } from "./request-BLx2P5jT.mjs";
2
2
  import { getLogger } from "@logtape/logtape";
3
3
  //#region src/body.ts
4
4
  /** Decoded JSON body limit: 16 MiB. @internal */
@@ -1,5 +1,5 @@
1
1
  require("./chunk-Do9eywBl.cjs");
2
- const require_request = require("./request-95zjqfR8.cjs");
2
+ const require_request = require("./request-CgKa-B3U.cjs");
3
3
  let _logtape_logtape = require("@logtape/logtape");
4
4
  //#region src/body.ts
5
5
  /** Decoded JSON body limit: 16 MiB. @internal */
@@ -1,5 +1,5 @@
1
1
  require("./chunk-Do9eywBl.cjs");
2
- const require_body = require("./body-qyCffsxW.cjs");
2
+ const require_body = require("./body-DTUtJQdb.cjs");
3
3
  let node_assert_strict = require("node:assert/strict");
4
4
  let node_test = require("node:test");
5
5
  let node_zlib = require("node:zlib");
@@ -1,4 +1,4 @@
1
- import { r as readBoundedText, t as BodyTooLargeError } from "./body-DNQsDk-P.mjs";
1
+ import { r as readBoundedText, t as BodyTooLargeError } from "./body-CwOvjuHk.mjs";
2
2
  import { deepStrictEqual, ok, rejects } from "node:assert/strict";
3
3
  import { test } from "node:test";
4
4
  import { gunzipSync, gzipSync } from "node:zlib";
@@ -1,6 +1,6 @@
1
1
  const require_chunk = require("./chunk-Do9eywBl.cjs");
2
- const require_request = require("./request-95zjqfR8.cjs");
3
- const require_body = require("./body-qyCffsxW.cjs");
2
+ const require_request = require("./request-CgKa-B3U.cjs");
3
+ const require_body = require("./body-DTUtJQdb.cjs");
4
4
  const require_link = require("./link-FguCydMA.cjs");
5
5
  const require_url = require("./url-Qk1svKZU.cjs");
6
6
  let node_test = require("node:test");
@@ -5752,6 +5752,8 @@ async function getRemoteDocument(url, response, fetch) {
5752
5752
  }
5753
5753
  /**
5754
5754
  * Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
5755
+ * At most 20 HTTP redirects and alternate document links are followed in total
5756
+ * per call. Revisiting a URL within that chain throws a {@link FetchError}.
5755
5757
  *
5756
5758
  * The created loader preloads the below frequently used contexts by default
5757
5759
  * (unless `options.skipPreloadedContexts` is set to `true`):
@@ -5828,7 +5830,12 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
5828
5830
  }
5829
5831
  return await load(redirectUrl, options, redirected + 1, visited);
5830
5832
  }
5831
- const result = await getRemoteDocument(currentUrl, response, load);
5833
+ const result = await getRemoteDocument(currentUrl, response, async (alternateUrl) => {
5834
+ options?.signal?.throwIfAborted();
5835
+ if (redirected >= DEFAULT_MAX_REDIRECTION) throw new require_request.FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
5836
+ if (visited.has(alternateUrl)) throw new require_request.FetchError(currentUrl, `Redirect loop detected: ${alternateUrl}`);
5837
+ return await load(alternateUrl, options, redirected + 1, visited);
5838
+ });
5832
5839
  span.setAttribute("docloader.document_url", result.documentUrl);
5833
5840
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
5834
5841
  return result;
@@ -5844,7 +5851,7 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
5844
5851
  }
5845
5852
  });
5846
5853
  }
5847
- return load;
5854
+ return (url, options) => load(url, options);
5848
5855
  }
5849
5856
  //#endregion
5850
5857
  //#region src/docloader.test.ts
@@ -6365,4 +6372,150 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
6365
6372
  }
6366
6373
  });
6367
6374
  });
6375
+ (0, node_test.test)("getDocumentLoader() bounds alternate document chains", async (t) => {
6376
+ const base = "https://example.com/alternate-chain/";
6377
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
6378
+ for (const mode of [
6379
+ "header",
6380
+ "html",
6381
+ "mixed"
6382
+ ]) for (const hops of [20, 21]) await t.test(`${mode}: ${hops} hops`, async () => {
6383
+ esm_default.mockGlobal();
6384
+ let requests = 0;
6385
+ esm_default.get(`begin:${base}`, ({ url }) => {
6386
+ requests++;
6387
+ const index = Number(new URL(url).pathname.split("/").at(-1));
6388
+ if (index === hops || requests > 30) return Response.json({ done: true });
6389
+ const next = `${base}${index + 1}`;
6390
+ if (mode === "mixed" && index % 2 === 1) return Response.redirect(next, 302);
6391
+ return alternate(next, mode === "html");
6392
+ });
6393
+ try {
6394
+ if (hops === 20) (0, node_assert.deepStrictEqual)((await loader(`${base}0`)).document, { done: true });
6395
+ else await (0, node_assert.rejects)(loader(`${base}0`), (error) => {
6396
+ (0, node_assert.ok)(error instanceof require_request.FetchError);
6397
+ (0, node_assert.ok)(error.message.includes("Too many redirections (21)"));
6398
+ return true;
6399
+ });
6400
+ (0, node_assert.deepStrictEqual)(requests, 21);
6401
+ } finally {
6402
+ esm_default.hardReset();
6403
+ }
6404
+ });
6405
+ for (const mode of [
6406
+ "header",
6407
+ "html",
6408
+ "alternate-redirect",
6409
+ "redirect-alternate",
6410
+ "redirect-intermediate"
6411
+ ]) await t.test(`${mode}: cycle`, async () => {
6412
+ esm_default.mockGlobal();
6413
+ let requests = 0;
6414
+ esm_default.get(`begin:${base}`, ({ url }) => {
6415
+ requests++;
6416
+ if (requests > 30) return Response.json({ stopped: true });
6417
+ const index = Number(new URL(url).pathname.split("/").at(-1));
6418
+ const next = `${base}${mode === "redirect-intermediate" ? index === 0 ? 1 : index === 1 ? 2 : 1 : 1 - index}`;
6419
+ if (mode === "alternate-redirect" && index === 1 || (mode === "redirect-alternate" || mode === "redirect-intermediate") && index === 0) return Response.redirect(next, 302);
6420
+ return alternate(next, mode === "html");
6421
+ });
6422
+ try {
6423
+ await (0, node_assert.rejects)(loader(`${base}0`), (error) => {
6424
+ (0, node_assert.ok)(error instanceof require_request.FetchError);
6425
+ (0, node_assert.ok)(error.message.includes("Redirect loop detected:"));
6426
+ return true;
6427
+ });
6428
+ (0, node_assert.deepStrictEqual)(requests, mode === "redirect-intermediate" ? 3 : 2);
6429
+ } finally {
6430
+ esm_default.hardReset();
6431
+ }
6432
+ });
6433
+ await t.test("alternate followed by 20 redirects shares the limit", async () => {
6434
+ esm_default.mockGlobal();
6435
+ let requests = 0;
6436
+ esm_default.get(`begin:${base}`, ({ url }) => {
6437
+ requests++;
6438
+ const index = Number(new URL(url).pathname.split("/").at(-1));
6439
+ if (index === 21) return Response.json({ done: true });
6440
+ return index === 0 ? alternate(`${base}1`, false) : Response.redirect(`${base}${index + 1}`, 302);
6441
+ });
6442
+ try {
6443
+ await (0, node_assert.rejects)(loader(`${base}0`), (error) => {
6444
+ (0, node_assert.ok)(error instanceof require_request.FetchError);
6445
+ (0, node_assert.ok)(error.message.includes("Too many redirections (21)"));
6446
+ return true;
6447
+ });
6448
+ (0, node_assert.deepStrictEqual)(requests, 21);
6449
+ } finally {
6450
+ esm_default.hardReset();
6451
+ }
6452
+ });
6453
+ await t.test("relative alternate after redirect and isolated calls", async () => {
6454
+ esm_default.mockGlobal();
6455
+ esm_default.get(`${base}start`, Response.redirect(`${base}html`, 302));
6456
+ esm_default.get(`${base}html`, alternate("./document", true));
6457
+ esm_default.get(`${base}document`, Response.json({ done: true }));
6458
+ try {
6459
+ for (let i = 0; i < 2; i++) {
6460
+ const results = await Promise.all([loader(`${base}start`), loader(`${base}start`)]);
6461
+ for (const result of results) {
6462
+ (0, node_assert.deepStrictEqual)(result.document, { done: true });
6463
+ (0, node_assert.deepStrictEqual)(result.documentUrl, `${base}document`);
6464
+ }
6465
+ }
6466
+ } finally {
6467
+ esm_default.hardReset();
6468
+ }
6469
+ });
6470
+ function alternate(next, html) {
6471
+ return html ? new Response(`<link rel="alternate" type="application/activity+json" href="${next}">`, { headers: { "Content-Type": "text/html" } }) : new Response("not JSON", { headers: {
6472
+ "Content-Type": "text/plain",
6473
+ Link: `<${next}>; rel="alternate"; type="application/activity+json"`
6474
+ } });
6475
+ }
6476
+ });
6477
+ (0, node_test.test)("getDocumentLoader() preserves cancellation across alternates", async (t) => {
6478
+ const base = "https://example.com/alternate-abort/";
6479
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
6480
+ for (const html of [false, true]) await t.test(html ? "HTML" : "Link header", async () => {
6481
+ esm_default.mockGlobal();
6482
+ const controller = new AbortController();
6483
+ let requests = 0;
6484
+ esm_default.get(`begin:${base}`, ({ url }) => {
6485
+ requests++;
6486
+ const index = Number(new URL(url).pathname.split("/").at(-1));
6487
+ if (index === 2) return Response.json({ done: true });
6488
+ if (index === 1) controller.abort();
6489
+ const next = `${base}${index + 1}`;
6490
+ return new Response(html ? `<link rel="alternate" type="application/activity+json" href="${next}">` : "not JSON", { headers: html ? { "Content-Type": "text/html" } : {
6491
+ "Content-Type": "text/plain",
6492
+ Link: `<${next}>; rel="alternate"; type="application/activity+json"`
6493
+ } });
6494
+ });
6495
+ try {
6496
+ await (0, node_assert.rejects)(loader(`${base}0`, { signal: controller.signal }), { name: "AbortError" });
6497
+ (0, node_assert.deepStrictEqual)(requests, 2);
6498
+ } finally {
6499
+ esm_default.hardReset();
6500
+ }
6501
+ });
6502
+ });
6503
+ (0, node_test.test)("getDocumentLoader() rejects cancellation before fetching", async () => {
6504
+ esm_default.mockGlobal();
6505
+ let requests = 0;
6506
+ const url = "https://example.com/pre-aborted-alternate";
6507
+ esm_default.get(url, () => {
6508
+ requests++;
6509
+ return Response.json({ done: true });
6510
+ });
6511
+ try {
6512
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
6513
+ const controller = new AbortController();
6514
+ controller.abort();
6515
+ await (0, node_assert.rejects)(loader(url, { signal: controller.signal }), { name: "AbortError" });
6516
+ (0, node_assert.deepStrictEqual)(requests, 0);
6517
+ } finally {
6518
+ esm_default.hardReset();
6519
+ }
6520
+ });
6368
6521
  //#endregion
@@ -1,5 +1,5 @@
1
- import { a as name, i as logRequest, n as createActivityPubRequest, o as version, t as FetchError } from "./request-xX62gFVA.mjs";
2
- import { n as MAX_BODY_SIZE, r as readBoundedText } from "./body-DNQsDk-P.mjs";
1
+ import { a as name, i as logRequest, n as createActivityPubRequest, o as version, t as FetchError } from "./request-BLx2P5jT.mjs";
2
+ import { n as MAX_BODY_SIZE, r as readBoundedText } from "./body-CwOvjuHk.mjs";
3
3
  import { t as HttpHeaderLink } from "./link-NUUWCdnK.mjs";
4
4
  import { o as validatePublicUrl, t as UrlError } from "./url-DzlMS01z.mjs";
5
5
  import { test } from "node:test";
@@ -5770,6 +5770,8 @@ async function getRemoteDocument(url, response, fetch) {
5770
5770
  }
5771
5771
  /**
5772
5772
  * Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
5773
+ * At most 20 HTTP redirects and alternate document links are followed in total
5774
+ * per call. Revisiting a URL within that chain throws a {@link FetchError}.
5773
5775
  *
5774
5776
  * The created loader preloads the below frequently used contexts by default
5775
5777
  * (unless `options.skipPreloadedContexts` is set to `true`):
@@ -5846,7 +5848,12 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
5846
5848
  }
5847
5849
  return await load(redirectUrl, options, redirected + 1, visited);
5848
5850
  }
5849
- const result = await getRemoteDocument(currentUrl, response, load);
5851
+ const result = await getRemoteDocument(currentUrl, response, async (alternateUrl) => {
5852
+ options?.signal?.throwIfAborted();
5853
+ if (redirected >= DEFAULT_MAX_REDIRECTION) throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
5854
+ if (visited.has(alternateUrl)) throw new FetchError(currentUrl, `Redirect loop detected: ${alternateUrl}`);
5855
+ return await load(alternateUrl, options, redirected + 1, visited);
5856
+ });
5850
5857
  span.setAttribute("docloader.document_url", result.documentUrl);
5851
5858
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
5852
5859
  return result;
@@ -5862,7 +5869,7 @@ function getDocumentLoader({ allowPrivateAddress, skipPreloadedContexts, userAge
5862
5869
  }
5863
5870
  });
5864
5871
  }
5865
- return load;
5872
+ return (url, options) => load(url, options);
5866
5873
  }
5867
5874
  //#endregion
5868
5875
  //#region src/docloader.test.ts
@@ -6383,5 +6390,151 @@ test("getDocumentLoader() logs DNS failures as such", async (t) => {
6383
6390
  }
6384
6391
  });
6385
6392
  });
6393
+ test("getDocumentLoader() bounds alternate document chains", async (t) => {
6394
+ const base = "https://example.com/alternate-chain/";
6395
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
6396
+ for (const mode of [
6397
+ "header",
6398
+ "html",
6399
+ "mixed"
6400
+ ]) for (const hops of [20, 21]) await t.test(`${mode}: ${hops} hops`, async () => {
6401
+ esm_default.mockGlobal();
6402
+ let requests = 0;
6403
+ esm_default.get(`begin:${base}`, ({ url }) => {
6404
+ requests++;
6405
+ const index = Number(new URL(url).pathname.split("/").at(-1));
6406
+ if (index === hops || requests > 30) return Response.json({ done: true });
6407
+ const next = `${base}${index + 1}`;
6408
+ if (mode === "mixed" && index % 2 === 1) return Response.redirect(next, 302);
6409
+ return alternate(next, mode === "html");
6410
+ });
6411
+ try {
6412
+ if (hops === 20) deepStrictEqual((await loader(`${base}0`)).document, { done: true });
6413
+ else await rejects(loader(`${base}0`), (error) => {
6414
+ ok(error instanceof FetchError);
6415
+ ok(error.message.includes("Too many redirections (21)"));
6416
+ return true;
6417
+ });
6418
+ deepStrictEqual(requests, 21);
6419
+ } finally {
6420
+ esm_default.hardReset();
6421
+ }
6422
+ });
6423
+ for (const mode of [
6424
+ "header",
6425
+ "html",
6426
+ "alternate-redirect",
6427
+ "redirect-alternate",
6428
+ "redirect-intermediate"
6429
+ ]) await t.test(`${mode}: cycle`, async () => {
6430
+ esm_default.mockGlobal();
6431
+ let requests = 0;
6432
+ esm_default.get(`begin:${base}`, ({ url }) => {
6433
+ requests++;
6434
+ if (requests > 30) return Response.json({ stopped: true });
6435
+ const index = Number(new URL(url).pathname.split("/").at(-1));
6436
+ const next = `${base}${mode === "redirect-intermediate" ? index === 0 ? 1 : index === 1 ? 2 : 1 : 1 - index}`;
6437
+ if (mode === "alternate-redirect" && index === 1 || (mode === "redirect-alternate" || mode === "redirect-intermediate") && index === 0) return Response.redirect(next, 302);
6438
+ return alternate(next, mode === "html");
6439
+ });
6440
+ try {
6441
+ await rejects(loader(`${base}0`), (error) => {
6442
+ ok(error instanceof FetchError);
6443
+ ok(error.message.includes("Redirect loop detected:"));
6444
+ return true;
6445
+ });
6446
+ deepStrictEqual(requests, mode === "redirect-intermediate" ? 3 : 2);
6447
+ } finally {
6448
+ esm_default.hardReset();
6449
+ }
6450
+ });
6451
+ await t.test("alternate followed by 20 redirects shares the limit", async () => {
6452
+ esm_default.mockGlobal();
6453
+ let requests = 0;
6454
+ esm_default.get(`begin:${base}`, ({ url }) => {
6455
+ requests++;
6456
+ const index = Number(new URL(url).pathname.split("/").at(-1));
6457
+ if (index === 21) return Response.json({ done: true });
6458
+ return index === 0 ? alternate(`${base}1`, false) : Response.redirect(`${base}${index + 1}`, 302);
6459
+ });
6460
+ try {
6461
+ await rejects(loader(`${base}0`), (error) => {
6462
+ ok(error instanceof FetchError);
6463
+ ok(error.message.includes("Too many redirections (21)"));
6464
+ return true;
6465
+ });
6466
+ deepStrictEqual(requests, 21);
6467
+ } finally {
6468
+ esm_default.hardReset();
6469
+ }
6470
+ });
6471
+ await t.test("relative alternate after redirect and isolated calls", async () => {
6472
+ esm_default.mockGlobal();
6473
+ esm_default.get(`${base}start`, Response.redirect(`${base}html`, 302));
6474
+ esm_default.get(`${base}html`, alternate("./document", true));
6475
+ esm_default.get(`${base}document`, Response.json({ done: true }));
6476
+ try {
6477
+ for (let i = 0; i < 2; i++) {
6478
+ const results = await Promise.all([loader(`${base}start`), loader(`${base}start`)]);
6479
+ for (const result of results) {
6480
+ deepStrictEqual(result.document, { done: true });
6481
+ deepStrictEqual(result.documentUrl, `${base}document`);
6482
+ }
6483
+ }
6484
+ } finally {
6485
+ esm_default.hardReset();
6486
+ }
6487
+ });
6488
+ function alternate(next, html) {
6489
+ return html ? new Response(`<link rel="alternate" type="application/activity+json" href="${next}">`, { headers: { "Content-Type": "text/html" } }) : new Response("not JSON", { headers: {
6490
+ "Content-Type": "text/plain",
6491
+ Link: `<${next}>; rel="alternate"; type="application/activity+json"`
6492
+ } });
6493
+ }
6494
+ });
6495
+ test("getDocumentLoader() preserves cancellation across alternates", async (t) => {
6496
+ const base = "https://example.com/alternate-abort/";
6497
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
6498
+ for (const html of [false, true]) await t.test(html ? "HTML" : "Link header", async () => {
6499
+ esm_default.mockGlobal();
6500
+ const controller = new AbortController();
6501
+ let requests = 0;
6502
+ esm_default.get(`begin:${base}`, ({ url }) => {
6503
+ requests++;
6504
+ const index = Number(new URL(url).pathname.split("/").at(-1));
6505
+ if (index === 2) return Response.json({ done: true });
6506
+ if (index === 1) controller.abort();
6507
+ const next = `${base}${index + 1}`;
6508
+ return new Response(html ? `<link rel="alternate" type="application/activity+json" href="${next}">` : "not JSON", { headers: html ? { "Content-Type": "text/html" } : {
6509
+ "Content-Type": "text/plain",
6510
+ Link: `<${next}>; rel="alternate"; type="application/activity+json"`
6511
+ } });
6512
+ });
6513
+ try {
6514
+ await rejects(loader(`${base}0`, { signal: controller.signal }), { name: "AbortError" });
6515
+ deepStrictEqual(requests, 2);
6516
+ } finally {
6517
+ esm_default.hardReset();
6518
+ }
6519
+ });
6520
+ });
6521
+ test("getDocumentLoader() rejects cancellation before fetching", async () => {
6522
+ esm_default.mockGlobal();
6523
+ let requests = 0;
6524
+ const url = "https://example.com/pre-aborted-alternate";
6525
+ esm_default.get(url, () => {
6526
+ requests++;
6527
+ return Response.json({ done: true });
6528
+ });
6529
+ try {
6530
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
6531
+ const controller = new AbortController();
6532
+ controller.abort();
6533
+ await rejects(loader(url, { signal: controller.signal }), { name: "AbortError" });
6534
+ deepStrictEqual(requests, 0);
6535
+ } finally {
6536
+ esm_default.hardReset();
6537
+ }
6538
+ });
6386
6539
  //#endregion
6387
6540
  export {};
@@ -1,7 +1,7 @@
1
1
  import process from "node:process";
2
2
  //#region deno.json
3
3
  var name = "@fedify/vocab-runtime";
4
- var version = "2.0.29-dev.2121+c46fb9dd";
4
+ var version = "2.0.29-dev.2143+bbbfea55";
5
5
  //#endregion
6
6
  //#region src/request.ts
7
7
  /**
@@ -3,7 +3,7 @@ let node_process = require("node:process");
3
3
  node_process = require_chunk.__toESM(node_process);
4
4
  //#region deno.json
5
5
  var name = "@fedify/vocab-runtime";
6
- var version = "2.0.29-dev.2121+c46fb9dd";
6
+ var version = "2.0.29-dev.2143+bbbfea55";
7
7
  //#endregion
8
8
  //#region src/request.ts
9
9
  /**
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("./chunk-Do9eywBl.cjs");
2
- const require_request = require("./request-95zjqfR8.cjs");
2
+ const require_request = require("./request-CgKa-B3U.cjs");
3
3
  let node_test = require("node:test");
4
4
  let node_process = require("node:process");
5
5
  node_process = require_chunk.__toESM(node_process);
@@ -1,4 +1,4 @@
1
- import { o as version, r as getUserAgent } from "./request-xX62gFVA.mjs";
1
+ import { o as version, r as getUserAgent } from "./request-BLx2P5jT.mjs";
2
2
  import { test } from "node:test";
3
3
  import process from "node:process";
4
4
  import { deepStrictEqual } from "node:assert";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab-runtime",
3
- "version": "2.0.29-dev.2121+c46fb9dd",
3
+ "version": "2.0.29-dev.2143+bbbfea55",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -724,3 +724,208 @@ test("getDocumentLoader() logs DNS failures as such", async (t) => {
724
724
  });
725
725
  }
726
726
  });
727
+
728
+ test("getDocumentLoader() bounds alternate document chains", async (t) => {
729
+ const base = "https://example.com/alternate-chain/";
730
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
731
+ for (const mode of ["header", "html", "mixed"] as const) {
732
+ for (const hops of [20, 21]) {
733
+ await t.test(`${mode}: ${hops} hops`, async () => {
734
+ fetchMock.mockGlobal();
735
+ let requests = 0;
736
+ fetchMock.get(`begin:${base}`, ({ url }) => {
737
+ requests++;
738
+ const index = Number(new URL(url).pathname.split("/").at(-1));
739
+ // Keep the unpatched regression bounded too.
740
+ if (index === hops || requests > 30) {
741
+ return Response.json({ done: true });
742
+ }
743
+ const next = `${base}${index + 1}`;
744
+ if (mode === "mixed" && index % 2 === 1) {
745
+ return Response.redirect(next, 302);
746
+ }
747
+ return alternate(next, mode === "html");
748
+ });
749
+ try {
750
+ if (hops === 20) {
751
+ deepStrictEqual((await loader(`${base}0`)).document, {
752
+ done: true,
753
+ });
754
+ } else {
755
+ await rejects(loader(`${base}0`), (error: unknown) => {
756
+ ok(error instanceof FetchError);
757
+ ok(error.message.includes("Too many redirections (21)"));
758
+ return true;
759
+ });
760
+ }
761
+ deepStrictEqual(requests, 21);
762
+ } finally {
763
+ fetchMock.hardReset();
764
+ }
765
+ });
766
+ }
767
+ }
768
+
769
+ for (
770
+ const mode of [
771
+ "header",
772
+ "html",
773
+ "alternate-redirect",
774
+ "redirect-alternate",
775
+ "redirect-intermediate",
776
+ ]
777
+ ) {
778
+ await t.test(`${mode}: cycle`, async () => {
779
+ fetchMock.mockGlobal();
780
+ let requests = 0;
781
+ fetchMock.get(`begin:${base}`, ({ url }) => {
782
+ requests++;
783
+ if (requests > 30) return Response.json({ stopped: true });
784
+ const index = Number(new URL(url).pathname.split("/").at(-1));
785
+ const next = `${base}${
786
+ mode === "redirect-intermediate"
787
+ ? (index === 0 ? 1 : index === 1 ? 2 : 1)
788
+ : 1 - index
789
+ }`;
790
+ if (
791
+ (mode === "alternate-redirect" && index === 1) ||
792
+ ((mode === "redirect-alternate" ||
793
+ mode === "redirect-intermediate") && index === 0)
794
+ ) {
795
+ return Response.redirect(next, 302);
796
+ }
797
+ return alternate(next, mode === "html");
798
+ });
799
+ try {
800
+ await rejects(loader(`${base}0`), (error: unknown) => {
801
+ ok(error instanceof FetchError);
802
+ ok(error.message.includes("Redirect loop detected:"));
803
+ return true;
804
+ });
805
+ deepStrictEqual(requests, mode === "redirect-intermediate" ? 3 : 2);
806
+ } finally {
807
+ fetchMock.hardReset();
808
+ }
809
+ });
810
+ }
811
+
812
+ await t.test("alternate followed by 20 redirects shares the limit", async () => {
813
+ fetchMock.mockGlobal();
814
+ let requests = 0;
815
+ fetchMock.get(`begin:${base}`, ({ url }) => {
816
+ requests++;
817
+ const index = Number(new URL(url).pathname.split("/").at(-1));
818
+ if (index === 21) return Response.json({ done: true });
819
+ return index === 0
820
+ ? alternate(`${base}1`, false)
821
+ : Response.redirect(`${base}${index + 1}`, 302);
822
+ });
823
+ try {
824
+ await rejects(loader(`${base}0`), (error: unknown) => {
825
+ ok(error instanceof FetchError);
826
+ ok(error.message.includes("Too many redirections (21)"));
827
+ return true;
828
+ });
829
+ deepStrictEqual(requests, 21);
830
+ } finally {
831
+ fetchMock.hardReset();
832
+ }
833
+ });
834
+
835
+ await t.test("relative alternate after redirect and isolated calls", async () => {
836
+ fetchMock.mockGlobal();
837
+ fetchMock.get(`${base}start`, Response.redirect(`${base}html`, 302));
838
+ fetchMock.get(`${base}html`, alternate("./document", true));
839
+ fetchMock.get(`${base}document`, Response.json({ done: true }));
840
+ try {
841
+ for (let i = 0; i < 2; i++) {
842
+ const results = await Promise.all([
843
+ loader(`${base}start`),
844
+ loader(`${base}start`),
845
+ ]);
846
+ for (const result of results) {
847
+ deepStrictEqual(result.document, { done: true });
848
+ deepStrictEqual(result.documentUrl, `${base}document`);
849
+ }
850
+ }
851
+ } finally {
852
+ fetchMock.hardReset();
853
+ }
854
+ });
855
+
856
+ function alternate(next: string, html: boolean): Response {
857
+ return html
858
+ ? new Response(
859
+ `<link rel="alternate" type="application/activity+json" href="${next}">`,
860
+ {
861
+ headers: { "Content-Type": "text/html" },
862
+ },
863
+ )
864
+ : new Response("not JSON", {
865
+ headers: {
866
+ "Content-Type": "text/plain",
867
+ Link: `<${next}>; rel="alternate"; type="application/activity+json"`,
868
+ },
869
+ });
870
+ }
871
+ });
872
+
873
+ test("getDocumentLoader() preserves cancellation across alternates", async (t) => {
874
+ const base = "https://example.com/alternate-abort/";
875
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
876
+ for (const html of [false, true]) {
877
+ await t.test(html ? "HTML" : "Link header", async () => {
878
+ fetchMock.mockGlobal();
879
+ const controller = new AbortController();
880
+ let requests = 0;
881
+ fetchMock.get(`begin:${base}`, ({ url }) => {
882
+ requests++;
883
+ const index = Number(new URL(url).pathname.split("/").at(-1));
884
+ if (index === 2) return Response.json({ done: true });
885
+ if (index === 1) controller.abort();
886
+ const next = `${base}${index + 1}`;
887
+ return new Response(
888
+ html
889
+ ? `<link rel="alternate" type="application/activity+json" href="${next}">`
890
+ : "not JSON",
891
+ {
892
+ headers: html ? { "Content-Type": "text/html" } : {
893
+ "Content-Type": "text/plain",
894
+ Link:
895
+ `<${next}>; rel="alternate"; type="application/activity+json"`,
896
+ },
897
+ },
898
+ );
899
+ });
900
+ try {
901
+ await rejects(loader(`${base}0`, { signal: controller.signal }), {
902
+ name: "AbortError",
903
+ });
904
+ deepStrictEqual(requests, 2);
905
+ } finally {
906
+ fetchMock.hardReset();
907
+ }
908
+ });
909
+ }
910
+ });
911
+
912
+ test("getDocumentLoader() rejects cancellation before fetching", async () => {
913
+ fetchMock.mockGlobal();
914
+ let requests = 0;
915
+ const url = "https://example.com/pre-aborted-alternate";
916
+ fetchMock.get(url, () => {
917
+ requests++;
918
+ return Response.json({ done: true });
919
+ });
920
+ try {
921
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
922
+ const controller = new AbortController();
923
+ controller.abort();
924
+ await rejects(loader(url, { signal: controller.signal }), {
925
+ name: "AbortError",
926
+ });
927
+ deepStrictEqual(requests, 0);
928
+ } finally {
929
+ fetchMock.hardReset();
930
+ }
931
+ });
package/src/docloader.ts CHANGED
@@ -266,6 +266,8 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
266
266
 
267
267
  /**
268
268
  * Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
269
+ * At most 20 HTTP redirects and alternate document links are followed in total
270
+ * per call. Revisiting a URL within that chain throws a {@link FetchError}.
269
271
  *
270
272
  * The created loader preloads the below frequently used contexts by default
271
273
  * (unless `options.skipPreloadedContexts` is set to `true`):
@@ -384,7 +386,26 @@ export function getDocumentLoader(
384
386
  return await load(redirectUrl, options, redirected + 1, visited);
385
387
  }
386
388
 
387
- const result = await getRemoteDocument(currentUrl, response, load);
389
+ const result = await getRemoteDocument(
390
+ currentUrl,
391
+ response,
392
+ async (alternateUrl) => {
393
+ options?.signal?.throwIfAborted();
394
+ if (redirected >= DEFAULT_MAX_REDIRECTION) {
395
+ throw new FetchError(
396
+ currentUrl,
397
+ `Too many redirections (${redirected + 1})`,
398
+ );
399
+ }
400
+ if (visited.has(alternateUrl)) {
401
+ throw new FetchError(
402
+ currentUrl,
403
+ `Redirect loop detected: ${alternateUrl}`,
404
+ );
405
+ }
406
+ return await load(alternateUrl, options, redirected + 1, visited);
407
+ },
408
+ );
388
409
  span.setAttribute("docloader.document_url", result.documentUrl);
389
410
  if (result.contextUrl != null) {
390
411
  span.setAttribute("docloader.context_url", result.contextUrl);
@@ -403,5 +424,5 @@ export function getDocumentLoader(
403
424
  },
404
425
  );
405
426
  }
406
- return load;
427
+ return (url, options) => load(url, options);
407
428
  }