@fedify/vocab-runtime 2.4.0-dev.2169 → 2.4.0-dev.2193
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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.cjs +1 -1
- package/dist/internal/jsonld-cache.d.cts +1 -1
- package/dist/internal/jsonld-cache.d.ts +1 -1
- package/dist/internal/jsonld-cache.js +1 -1
- package/dist/internal/portable-dereference.cjs +1 -1
- package/dist/internal/portable-dereference.d.cts +2 -2
- package/dist/internal/portable-dereference.d.ts +2 -2
- package/dist/internal/portable-dereference.js +1 -1
- package/dist/mod.cjs +206 -17
- package/dist/mod.d.cts +7 -3
- package/dist/mod.d.ts +7 -3
- package/dist/mod.js +205 -18
- 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-CNwDKYQs.mjs → body-D1z0X88T.mjs} +55 -2
- package/dist/tests/{body-DHAC8qhR.cjs → body-ORlui-Nx.cjs} +60 -1
- package/dist/tests/body.test.cjs +1 -1
- package/dist/tests/body.test.mjs +1 -1
- package/dist/tests/decimal.test.cjs +2 -2
- package/dist/tests/decimal.test.mjs +2 -2
- package/dist/tests/{docloader-DWkl3u7X.mjs → docloader-CUdtpDuf.mjs} +153 -19
- package/dist/tests/{docloader-DLkb9qAS.cjs → docloader-Vc3BC9ad.cjs} +164 -18
- package/dist/tests/docloader.test.cjs +266 -3
- package/dist/tests/docloader.test.mjs +267 -4
- package/dist/tests/internal/portable-dereference.test.cjs +2 -2
- package/dist/tests/internal/portable-dereference.test.mjs +2 -2
- package/dist/tests/{jsonld-cache-BO-QXxIc.cjs → jsonld-cache-BQMn_ILF.cjs} +1 -1
- package/dist/tests/{jsonld-cache-B7PKo3Lu.mjs → jsonld-cache-DUmuWRwp.mjs} +1 -1
- package/dist/tests/jsonld-cache.test.cjs +2 -2
- package/dist/tests/jsonld-cache.test.mjs +2 -2
- package/dist/tests/{request-Dm3ye46t.cjs → request-C5_3S-un.cjs} +1 -1
- package/dist/tests/{request-Cg8MQByW.mjs → request-IIntZ5kj.mjs} +1 -1
- package/dist/tests/request.test.cjs +1 -1
- package/dist/tests/request.test.mjs +1 -1
- package/dist/tests/{url-gLKqAO1H.cjs → url-B8xyTwP-.cjs} +5 -1
- package/dist/tests/{url-D7qRF-dF.mjs → url-DfS-cvwr.mjs} +5 -1
- package/dist/tests/url.test.cjs +2 -2
- package/dist/tests/url.test.mjs +2 -2
- package/dist/{url-BHZYe8Vu.js → url-Be3moMor.js} +5 -1
- package/dist/{url-P6l9s9dN.cjs → url-DDVweCXr.cjs} +5 -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/url.test.ts +7 -1
- package/src/url.ts +6 -2
package/src/docloader.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { getLogger } from "@logtape/logtape";
|
|
2
2
|
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
3
3
|
import metadata from "../deno.json" with { type: "json" };
|
|
4
|
-
import {
|
|
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
|
}
|
package/src/mod.ts
CHANGED
package/src/url.test.ts
CHANGED
|
@@ -659,7 +659,13 @@ test("parseGatewayUrl() accepts only HTTP(S) base URIs", () => {
|
|
|
659
659
|
"https://server.example/#fragment",
|
|
660
660
|
]
|
|
661
661
|
) {
|
|
662
|
-
|
|
662
|
+
// Inboxes tell a malformed gateway from other errors by this prefix:
|
|
663
|
+
throws(
|
|
664
|
+
() => parseGatewayUrl(url),
|
|
665
|
+
(error) =>
|
|
666
|
+
error instanceof TypeError &&
|
|
667
|
+
error.message.startsWith("Invalid FEP-ef61 gateway: "),
|
|
668
|
+
);
|
|
663
669
|
ok(!isGatewayUrl(new URL(url)));
|
|
664
670
|
}
|
|
665
671
|
});
|
package/src/url.ts
CHANGED
|
@@ -348,13 +348,17 @@ export function isGatewayUrl(url: URL): boolean {
|
|
|
348
348
|
|
|
349
349
|
/**
|
|
350
350
|
* Parses and validates an FEP-ef61 gateway base URI.
|
|
351
|
+
* @throws {TypeError} If the URI is malformed, or is not an HTTP(S) base URI
|
|
352
|
+
* with no credentials, path, query, or fragment. In the
|
|
353
|
+
* latter case, the message starts with
|
|
354
|
+
* `Invalid FEP-ef61 gateway:`.
|
|
351
355
|
*/
|
|
352
356
|
export function parseGatewayUrl(url: string): URL {
|
|
353
357
|
const parsed = parseIri(url);
|
|
354
358
|
if (!isGatewayUrl(parsed)) {
|
|
355
359
|
throw new TypeError(
|
|
356
|
-
|
|
357
|
-
"path, query, or fragment.",
|
|
360
|
+
`Invalid FEP-ef61 gateway: ${url}. FEP-ef61 gateways must be HTTP(S) ` +
|
|
361
|
+
"base URIs with no credentials, path, query, or fragment.",
|
|
358
362
|
);
|
|
359
363
|
}
|
|
360
364
|
return parsed;
|