@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.
- package/deno.json +1 -1
- package/dist/{docloader-ClQraSWr.d.cts → docloader-C63enuyr.d.cts} +53 -1
- package/dist/{docloader-ClQraSWr.d.ts → docloader-C63enuyr.d.ts} +53 -1
- package/dist/internal/jsonld-cache.d.cts +1 -1
- package/dist/internal/jsonld-cache.d.ts +1 -1
- package/dist/internal/portable-dereference.d.cts +2 -2
- package/dist/internal/portable-dereference.d.ts +2 -2
- package/dist/mod.cjs +205 -16
- package/dist/mod.d.cts +3 -3
- package/dist/mod.d.ts +3 -3
- package/dist/mod.js +204 -17
- package/dist/{portable-ilkFPcXa.d.cts → portable-Cux3IA20.d.cts} +1 -1
- package/dist/{portable-DRtvOxTb.d.ts → portable-DBA1FS_L.d.ts} +1 -1
- package/dist/tests/{body-DHAC8qhR.cjs → body-CmZdqiw3.cjs} +60 -1
- package/dist/tests/{body-CNwDKYQs.mjs → body-DMNbCBhz.mjs} +55 -2
- package/dist/tests/body.test.cjs +1 -1
- package/dist/tests/body.test.mjs +1 -1
- package/dist/tests/decimal.test.cjs +1 -1
- package/dist/tests/decimal.test.mjs +1 -1
- package/dist/tests/{docloader-DWkl3u7X.mjs → docloader-6iodlx7G.mjs} +152 -18
- package/dist/tests/{docloader-DLkb9qAS.cjs → docloader-B0omnjRS.cjs} +163 -17
- package/dist/tests/docloader.test.cjs +265 -2
- package/dist/tests/docloader.test.mjs +266 -3
- package/dist/tests/{request-Dm3ye46t.cjs → request-BU0mCgOL.cjs} +1 -1
- package/dist/tests/{request-Cg8MQByW.mjs → request-CGmX5n4v.mjs} +1 -1
- package/dist/tests/request.test.cjs +1 -1
- package/dist/tests/request.test.mjs +1 -1
- package/package.json +1 -1
- package/src/body.ts +57 -0
- package/src/docloader.test.ts +341 -3
- package/src/docloader.ts +205 -5
- package/src/mod.ts +2 -0
package/src/docloader.test.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
{
|
|
350
|
-
|
|
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 (
|
|
688
|
+
return withDocumentLoaderTimeout(
|
|
689
|
+
(url, options) => load(url, options),
|
|
690
|
+
resolvedTimeout,
|
|
691
|
+
);
|
|
492
692
|
}
|