@fedify/vocab-runtime 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.
Files changed (32) 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.d.cts +1 -1
  5. package/dist/internal/jsonld-cache.d.ts +1 -1
  6. package/dist/internal/portable-dereference.d.cts +2 -2
  7. package/dist/internal/portable-dereference.d.ts +2 -2
  8. package/dist/mod.cjs +205 -16
  9. package/dist/mod.d.cts +3 -3
  10. package/dist/mod.d.ts +3 -3
  11. package/dist/mod.js +204 -17
  12. package/dist/{portable-ilkFPcXa.d.cts → portable-Cux3IA20.d.cts} +1 -1
  13. package/dist/{portable-DRtvOxTb.d.ts → portable-DBA1FS_L.d.ts} +1 -1
  14. package/dist/tests/{body-DHAC8qhR.cjs → body-CmZdqiw3.cjs} +60 -1
  15. package/dist/tests/{body-CNwDKYQs.mjs → body-DMNbCBhz.mjs} +55 -2
  16. package/dist/tests/body.test.cjs +1 -1
  17. package/dist/tests/body.test.mjs +1 -1
  18. package/dist/tests/decimal.test.cjs +1 -1
  19. package/dist/tests/decimal.test.mjs +1 -1
  20. package/dist/tests/{docloader-DWkl3u7X.mjs → docloader-6iodlx7G.mjs} +152 -18
  21. package/dist/tests/{docloader-DLkb9qAS.cjs → docloader-B0omnjRS.cjs} +163 -17
  22. package/dist/tests/docloader.test.cjs +265 -2
  23. package/dist/tests/docloader.test.mjs +266 -3
  24. package/dist/tests/{request-Dm3ye46t.cjs → request-BU0mCgOL.cjs} +1 -1
  25. package/dist/tests/{request-Cg8MQByW.mjs → request-CGmX5n4v.mjs} +1 -1
  26. package/dist/tests/request.test.cjs +1 -1
  27. package/dist/tests/request.test.mjs +1 -1
  28. package/package.json +1 -1
  29. package/src/body.ts +57 -0
  30. package/src/docloader.test.ts +341 -3
  31. package/src/docloader.ts +205 -5
  32. package/src/mod.ts +2 -0
@@ -1,13 +1,22 @@
1
1
  import { configure, type LogRecord, reset } from "@logtape/logtape";
2
2
  import fetchMock from "fetch-mock";
3
- import { deepStrictEqual, ok, rejects } from "node:assert";
3
+ import { deepStrictEqual, ok, rejects, throws } from "node:assert";
4
4
  import dns from "node:dns/promises";
5
5
  import { test } from "node:test";
6
- import { createServer } from "node:http";
6
+ import {
7
+ createServer,
8
+ type IncomingMessage,
9
+ type ServerResponse,
10
+ } from "node:http";
7
11
  import { gzipSync } from "node:zlib";
8
12
  import preloadedContexts from "./contexts.ts";
9
13
  import cidV1Context from "./contexts/cid-v1.json" with { type: "json" };
10
- import { getDocumentLoader, getRemoteDocument } from "./docloader.ts";
14
+ import {
15
+ getDocumentLoader,
16
+ getRemoteDocument,
17
+ resolveDocumentLoaderTimeout,
18
+ withDocumentLoaderTimeout,
19
+ } from "./docloader.ts";
11
20
  import { FetchError } from "./request.ts";
12
21
  import { UrlError } from "./url.ts";
13
22
 
@@ -1118,3 +1127,332 @@ test("getDocumentLoader() rejects cancellation before fetching", async () => {
1118
1127
  fetchMock.hardReset();
1119
1128
  }
1120
1129
  });
1130
+
1131
+ async function withServer<T>(
1132
+ handler: (request: IncomingMessage, response: ServerResponse) => void,
1133
+ callback: (baseUrl: string) => Promise<T>,
1134
+ ): Promise<T> {
1135
+ const server = createServer(handler);
1136
+ await new Promise<void>((resolve, reject) => {
1137
+ server.once("error", reject);
1138
+ server.listen(0, "127.0.0.1", resolve);
1139
+ });
1140
+ try {
1141
+ const address = server.address();
1142
+ ok(address != null && typeof address !== "string");
1143
+ return await callback(`http://127.0.0.1:${address.port}`);
1144
+ } finally {
1145
+ await new Promise<void>((resolve, reject) => {
1146
+ server.close((error) => error == null ? resolve() : reject(error));
1147
+ server.closeAllConnections();
1148
+ });
1149
+ }
1150
+ }
1151
+
1152
+ function isTimeoutFetchError(error: unknown, url: string): boolean {
1153
+ ok(error instanceof FetchError, String(error));
1154
+ deepStrictEqual(error.url.href, url);
1155
+ deepStrictEqual(error.response, undefined);
1156
+ ok(error.cause instanceof DOMException);
1157
+ deepStrictEqual(error.cause.name, "TimeoutError");
1158
+ return true;
1159
+ }
1160
+
1161
+ test("resolveDocumentLoaderTimeout() validates timeouts", () => {
1162
+ deepStrictEqual(resolveDocumentLoaderTimeout(undefined), 10_000);
1163
+ deepStrictEqual(resolveDocumentLoaderTimeout(null), null);
1164
+ deepStrictEqual(resolveDocumentLoaderTimeout(1500), 1500);
1165
+ deepStrictEqual(resolveDocumentLoaderTimeout(0.5), 1);
1166
+ deepStrictEqual(resolveDocumentLoaderTimeout(2_147_483_647), 2_147_483_647);
1167
+ for (const invalid of [0, -1, NaN, Infinity, 2_147_483_648]) {
1168
+ throws(() => resolveDocumentLoaderTimeout(invalid), RangeError);
1169
+ }
1170
+ throws(() => getDocumentLoader({ timeout: 0 }), RangeError);
1171
+ });
1172
+
1173
+ test("getDocumentLoader() times out a request without a response", async () => {
1174
+ await withServer(() => {
1175
+ // Never respond.
1176
+ }, async (baseUrl) => {
1177
+ const url = `${baseUrl}/hang`;
1178
+ const loader = getDocumentLoader({
1179
+ allowPrivateAddress: true,
1180
+ timeout: 100,
1181
+ });
1182
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1183
+ });
1184
+ });
1185
+
1186
+ test("getDocumentLoader() times out a stalled response body", async () => {
1187
+ for (const status of [200, 404]) {
1188
+ await withServer((_, response) => {
1189
+ response.writeHead(status, {
1190
+ "Content-Type": "application/activity+json",
1191
+ });
1192
+ response.write('{"id":');
1193
+ // Never finish the body.
1194
+ }, async (baseUrl) => {
1195
+ const url = `${baseUrl}/stalled`;
1196
+ const loader = getDocumentLoader({
1197
+ allowPrivateAddress: true,
1198
+ timeout: 200,
1199
+ });
1200
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1201
+ });
1202
+ }
1203
+ });
1204
+
1205
+ test("getDocumentLoader() shares the timeout across redirects", async () => {
1206
+ let requests = 0;
1207
+ await withServer((request, response) => {
1208
+ requests++;
1209
+ const index = Number(request.url?.split("/").at(-1));
1210
+ setTimeout(() => {
1211
+ if (index >= 15) {
1212
+ response.writeHead(200, {
1213
+ "Content-Type": "application/activity+json",
1214
+ });
1215
+ response.end('{"done":true}');
1216
+ return;
1217
+ }
1218
+ response.writeHead(302, { Location: `/redirect/${index + 1}` });
1219
+ response.end();
1220
+ }, 50);
1221
+ }, async (baseUrl) => {
1222
+ const url = `${baseUrl}/redirect/0`;
1223
+ // Each hop is well within the timeout, but the whole chain is not:
1224
+ const loader = getDocumentLoader({
1225
+ allowPrivateAddress: true,
1226
+ timeout: 300,
1227
+ });
1228
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1229
+ ok(requests < 16, `requests: ${requests}`);
1230
+ // Without a timeout, the chain completes:
1231
+ const unbounded = getDocumentLoader({
1232
+ allowPrivateAddress: true,
1233
+ timeout: null,
1234
+ });
1235
+ deepStrictEqual((await unbounded(url)).document, { done: true });
1236
+ });
1237
+ });
1238
+
1239
+ test("getDocumentLoader() shares the timeout across alternates", async (t) => {
1240
+ for (const html of [false, true]) {
1241
+ await t.test(html ? "HTML" : "Link header", async () => {
1242
+ await withServer((request, response) => {
1243
+ const index = Number(request.url?.split("/").at(-1));
1244
+ setTimeout(() => {
1245
+ if (index >= 15) {
1246
+ response.writeHead(200, {
1247
+ "Content-Type": "application/activity+json",
1248
+ });
1249
+ response.end('{"done":true}');
1250
+ return;
1251
+ }
1252
+ const next = `/alternate/${index + 1}`;
1253
+ response.writeHead(
1254
+ 200,
1255
+ html ? { "Content-Type": "text/html" } : {
1256
+ "Content-Type": "text/plain",
1257
+ Link:
1258
+ `<${next}>; rel="alternate"; type="application/activity+json"`,
1259
+ },
1260
+ );
1261
+ response.end(
1262
+ html
1263
+ ? `<link rel="alternate" type="application/activity+json" href="${next}">`
1264
+ : "not JSON",
1265
+ );
1266
+ }, 50);
1267
+ }, async (baseUrl) => {
1268
+ const url = `${baseUrl}/alternate/0`;
1269
+ const loader = getDocumentLoader({
1270
+ allowPrivateAddress: true,
1271
+ timeout: 300,
1272
+ });
1273
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1274
+ });
1275
+ });
1276
+ }
1277
+ });
1278
+
1279
+ test("getDocumentLoader() lets the caller's signal abort a request", async () => {
1280
+ await withServer(() => {
1281
+ // Never respond.
1282
+ }, async (baseUrl) => {
1283
+ const url = `${baseUrl}/hang`;
1284
+ for (const timeout of [undefined, 60_000, null]) {
1285
+ const loader = getDocumentLoader({ allowPrivateAddress: true, timeout });
1286
+ const controller = new AbortController();
1287
+ const reason = new Error("Canceled by the caller");
1288
+ setTimeout(() => controller.abort(reason), 50);
1289
+ await rejects(loader(url, { signal: controller.signal }), (e) => {
1290
+ deepStrictEqual(e, reason);
1291
+ return true;
1292
+ });
1293
+ }
1294
+ // The caller's signal wins even if the timeout fires too:
1295
+ const loader = getDocumentLoader({
1296
+ allowPrivateAddress: true,
1297
+ timeout: 50,
1298
+ });
1299
+ const controller = new AbortController();
1300
+ const reason = new Error("Canceled by the caller");
1301
+ controller.abort(reason);
1302
+ await rejects(loader(url, { signal: controller.signal }), (e) => {
1303
+ deepStrictEqual(e, reason);
1304
+ return true;
1305
+ });
1306
+ });
1307
+ });
1308
+
1309
+ test("getDocumentLoader() bounds DNS lookups that ignore the timeout", async (t) => {
1310
+ // The validator skips DNS when Deno has no network permission.
1311
+ if (
1312
+ "Deno" in globalThis &&
1313
+ (await Deno.permissions.query({ name: "net" })).state !== "granted"
1314
+ ) {
1315
+ t.skip("requires the net permission");
1316
+ return;
1317
+ }
1318
+ fetchMock.mockGlobal();
1319
+ let requests = 0;
1320
+ const url = "https://slow-dns.example/object";
1321
+ fetchMock.get(url, () => {
1322
+ requests++;
1323
+ return Response.json({ id: url });
1324
+ });
1325
+ // Stubbing works only because url.ts uses the default node:dns/promises
1326
+ // import; see the FIXME there.
1327
+ const originalLookup = dns.lookup;
1328
+ let resolveLookup: () => void = () => {};
1329
+ const lookedUp = new Promise<void>((resolve) => resolveLookup = resolve);
1330
+ dns.lookup = (() =>
1331
+ new Promise((resolve) =>
1332
+ setTimeout(() => {
1333
+ resolve([{ address: "93.184.215.14", family: 4 }]);
1334
+ resolveLookup();
1335
+ }, 200)
1336
+ )) as typeof dns.lookup;
1337
+ try {
1338
+ const loader = getDocumentLoader({ timeout: 50 });
1339
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1340
+ // A late DNS answer must not start the request anymore:
1341
+ await lookedUp;
1342
+ await new Promise((resolve) => setTimeout(resolve, 10));
1343
+ deepStrictEqual(requests, 0);
1344
+ } finally {
1345
+ dns.lookup = originalLookup;
1346
+ fetchMock.hardReset();
1347
+ }
1348
+ });
1349
+
1350
+ test("withDocumentLoaderTimeout() cleans up after each call", async () => {
1351
+ const signals: AbortSignal[] = [];
1352
+ const loader = withDocumentLoaderTimeout((url, options) => {
1353
+ signals.push(options!.signal!);
1354
+ return Promise.resolve({
1355
+ contextUrl: null,
1356
+ document: {},
1357
+ documentUrl: url,
1358
+ });
1359
+ }, 50);
1360
+ const results = await Promise.all([
1361
+ loader("https://example.com/a"),
1362
+ loader("https://example.com/b"),
1363
+ ]);
1364
+ deepStrictEqual(results.map((r) => r.documentUrl), [
1365
+ "https://example.com/a",
1366
+ "https://example.com/b",
1367
+ ]);
1368
+ ok(signals[0] !== signals[1]);
1369
+ // The timers are cleared, so the signals are never aborted:
1370
+ await new Promise((resolve) => setTimeout(resolve, 100));
1371
+ ok(signals.every((signal) => !signal.aborted));
1372
+ // A loader that is given no timeout is returned as is:
1373
+ const inner = () => Promise.reject(new Error("unused"));
1374
+ ok(withDocumentLoaderTimeout(inner, null) === inner);
1375
+ });
1376
+
1377
+ test("getRemoteDocument() keeps error bodies byte for byte", async () => {
1378
+ const bytes = new Uint8Array([0xef, 0xbb, 0xbf, 0x4e, 0x6f, 0xff, 0xfe]);
1379
+ const url = "https://example.com/error";
1380
+ let error: unknown;
1381
+ await rejects(
1382
+ getRemoteDocument(
1383
+ url,
1384
+ new Response(bytes, { status: 404 }),
1385
+ () => Promise.reject(new Error("unused")),
1386
+ ),
1387
+ (e) => {
1388
+ error = e;
1389
+ return true;
1390
+ },
1391
+ );
1392
+ ok(error instanceof FetchError);
1393
+ ok(error.response != null);
1394
+ deepStrictEqual(error.response.status, 404);
1395
+ deepStrictEqual(new Uint8Array(await error.response.arrayBuffer()), bytes);
1396
+ });
1397
+
1398
+ test("getRemoteDocument() keeps metadata of bodiless or large errors", async () => {
1399
+ const url = "https://example.com/error";
1400
+ for (
1401
+ const response of [
1402
+ new Response(null, { status: 304, headers: { ETag: '"a"' } }),
1403
+ new Response(new Uint8Array(1024 * 1024 + 1), {
1404
+ status: 500,
1405
+ headers: { ETag: '"a"' },
1406
+ }),
1407
+ ]
1408
+ ) {
1409
+ await rejects(
1410
+ getRemoteDocument(
1411
+ url,
1412
+ response,
1413
+ () => Promise.reject(new Error("unused")),
1414
+ ),
1415
+ (e) => {
1416
+ ok(e instanceof FetchError);
1417
+ ok(e.response != null);
1418
+ deepStrictEqual(e.response.status, response.status);
1419
+ deepStrictEqual(e.response.headers.get("ETag"), '"a"');
1420
+ deepStrictEqual(e.response.body, null);
1421
+ return true;
1422
+ },
1423
+ );
1424
+ }
1425
+ });
1426
+
1427
+ test("getDocumentLoader() reports statuses that Response cannot hold", async () => {
1428
+ await withServer((_, response) => {
1429
+ response.writeHead(999);
1430
+ response.end("Request denied");
1431
+ }, async (baseUrl) => {
1432
+ const url = `${baseUrl}/denied`;
1433
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
1434
+ let error: unknown;
1435
+ await rejects(loader(url), (e) => {
1436
+ error = e;
1437
+ return true;
1438
+ });
1439
+ ok(error instanceof FetchError, String(error));
1440
+ deepStrictEqual(error.response?.status, 999);
1441
+ deepStrictEqual(await error.response.text(), "Request denied");
1442
+ });
1443
+ });
1444
+
1445
+ test("getDocumentLoader() times out a stalled body of such a status", async () => {
1446
+ await withServer((_, response) => {
1447
+ response.writeHead(999);
1448
+ response.write("Request");
1449
+ // Never finish the body.
1450
+ }, async (baseUrl) => {
1451
+ const url = `${baseUrl}/stalled-denied`;
1452
+ const loader = getDocumentLoader({
1453
+ allowPrivateAddress: true,
1454
+ timeout: 200,
1455
+ });
1456
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1457
+ });
1458
+ });
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,