@fedify/vocab-runtime 2.5.0-dev.2446 → 2.5.0-dev.2492
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 +5 -2
- package/dist/{docloader-BBnBpbsG.d.cts → docloader-DD6binpN.d.cts} +4 -8
- package/dist/{docloader-BBnBpbsG.d.ts → docloader-DD6binpN.d.ts} +4 -8
- 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 +72 -7
- package/dist/internal/portable-dereference.d.cts +63 -3
- package/dist/internal/portable-dereference.d.ts +63 -3
- package/dist/internal/portable-dereference.js +72 -8
- package/dist/internal.cjs +6 -0
- package/dist/internal.d.cts +108 -0
- package/dist/internal.d.ts +108 -0
- package/dist/internal.js +3 -0
- package/dist/mod.cjs +104 -38
- package/dist/mod.d.cts +3 -336
- package/dist/mod.d.ts +4 -336
- package/dist/mod.js +104 -38
- package/dist/{portable-BDydjp5H.d.ts → portable-1VSnSJFj.d.ts} +1 -1
- package/dist/{portable-Dfvcg_Wy.d.cts → portable-Cr7NwZwb.d.cts} +1 -1
- package/dist/public-fetch-Dvfvawei.cjs +527 -0
- package/dist/public-fetch-ziZ39L5I.js +510 -0
- package/dist/tests/{body-Ddx6WgR5.cjs → body-C8d1PmDa.cjs} +1 -1
- package/dist/tests/{body-CQi2bVi5.mjs → body-HSLxhexN.mjs} +1 -1
- package/dist/tests/body.test.cjs +1 -1
- package/dist/tests/body.test.mjs +1 -1
- package/dist/tests/decimal.test.cjs +4 -4
- package/dist/tests/decimal.test.mjs +4 -4
- package/dist/tests/{docloader-C3vDXwn1.cjs → docloader-D1fNCGam.cjs} +111 -39
- package/dist/tests/{docloader-CrVannnU.mjs → docloader-DkEUFJui.mjs} +106 -40
- package/dist/tests/docloader.test.cjs +531 -1293
- package/dist/tests/docloader.test.mjs +472 -1252
- package/dist/tests/esm-Ds-eHTfY.mjs +1229 -0
- package/dist/tests/esm-Q2tdh5f3.cjs +1215 -0
- package/dist/tests/internal/portable-dereference.test.cjs +3 -3
- package/dist/tests/internal/portable-dereference.test.mjs +3 -3
- package/dist/tests/{jsonld-cache-BEXiiM-V.cjs → jsonld-cache-DKIWKxEX.cjs} +1 -1
- package/dist/tests/{jsonld-cache-DFnWsLuZ.mjs → jsonld-cache-KH40z9_7.mjs} +1 -1
- package/dist/tests/jsonld-cache.test.cjs +2 -2
- package/dist/tests/jsonld-cache.test.mjs +2 -2
- package/dist/tests/node-CbBR-th-.mjs +84 -0
- package/dist/tests/node-Ch29PDXR.cjs +89 -0
- package/dist/tests/pin-grant-CPrpk1l4.cjs +70 -0
- package/dist/tests/pin-grant-H5XhM1Xs.mjs +53 -0
- package/dist/tests/pin-grant.test.cjs +87 -0
- package/dist/tests/pin-grant.test.d.cts +1 -0
- package/dist/tests/pin-grant.test.d.mts +1 -0
- package/dist/tests/pin-grant.test.mjs +88 -0
- package/dist/tests/pinners/bun.test.cjs +166 -0
- package/dist/tests/pinners/bun.test.d.cts +1 -0
- package/dist/tests/pinners/bun.test.d.mts +1 -0
- package/dist/tests/pinners/bun.test.mjs +167 -0
- package/dist/tests/pinners/node.test.cjs +269 -0
- package/dist/tests/pinners/node.test.d.cts +1 -0
- package/dist/tests/pinners/node.test.d.mts +1 -0
- package/dist/tests/pinners/node.test.mjs +268 -0
- package/dist/tests/pinners/pinners.test.cjs +196 -0
- package/dist/tests/pinners/pinners.test.d.cts +1 -0
- package/dist/tests/pinners/pinners.test.d.mts +1 -0
- package/dist/tests/pinners/pinners.test.mjs +197 -0
- package/dist/tests/pinners/testdata/node-child.cjs +135 -0
- package/dist/tests/pinners/testdata/node-child.d.cts +1 -0
- package/dist/tests/pinners/testdata/node-child.d.mts +1 -0
- package/dist/tests/pinners/testdata/node-child.mjs +134 -0
- package/dist/tests/pinners-DLH_npLf.cjs +220 -0
- package/dist/tests/pinners-tGcl49uF.mjs +203 -0
- package/dist/tests/{portable-dereference-DaTBBvCj.cjs → portable-dereference-BUoDzMxk.cjs} +2 -2
- package/dist/tests/{portable-dereference-sGRs_qdT.mjs → portable-dereference-Du2k3ngE.mjs} +2 -2
- package/dist/tests/{portable-media-BJf_gGQz.cjs → portable-media-BM9zgj9g.cjs} +3 -3
- package/dist/tests/{portable-media-Cc58dUeE.mjs → portable-media-CTxGibcj.mjs} +3 -3
- package/dist/tests/portable-media.test.cjs +1 -1
- package/dist/tests/portable-media.test.mjs +1 -1
- package/dist/tests/portable-workers.test.cjs +2 -2
- package/dist/tests/portable-workers.test.mjs +2 -2
- package/dist/tests/public-fetch-BGj5pzUD.cjs +186 -0
- package/dist/tests/public-fetch-Cxo3Yzdf.mjs +170 -0
- package/dist/tests/public-fetch.test.cjs +1073 -0
- package/dist/tests/public-fetch.test.d.cts +1 -0
- package/dist/tests/public-fetch.test.d.mts +1 -0
- package/dist/tests/public-fetch.test.mjs +1074 -0
- package/dist/tests/{request-glvxfx2U.cjs → request-7q-Zfe1p.cjs} +2 -2
- package/dist/tests/{request-1pPyYz3e.mjs → request-D5uGtmmq.mjs} +2 -2
- package/dist/tests/request.test.cjs +2 -2
- package/dist/tests/request.test.mjs +2 -2
- package/dist/tests/support-Bj44xlQu.mjs +272 -0
- package/dist/tests/support-BrdlbLJk.cjs +363 -0
- package/dist/tests/{url-BNakuZ8k.cjs → url-Bdj-rTcC.cjs} +88 -12
- package/dist/tests/{url-DMxmp7ZG.mjs → url-COj5Q8mb.mjs} +65 -13
- package/dist/tests/url.test.cjs +4 -1
- package/dist/tests/url.test.mjs +4 -1
- package/dist/url-4EE56sNP.d.cts +352 -0
- package/dist/url-4EE56sNP.d.ts +352 -0
- package/dist/{url-DrGTR8yv.cjs → url-DIKZnaJv.cjs} +88 -12
- package/dist/{url-Dzyp-NsC.js → url-mWveR5GA.js} +65 -13
- package/package.json +14 -3
- package/src/docloader.test.ts +696 -48
- package/src/docloader.ts +186 -75
- package/src/internal/portable-dereference.ts +116 -6
- package/src/internal.ts +13 -0
- package/src/pin-grant.test.ts +118 -0
- package/src/pin-grant.ts +99 -0
- package/src/pinners/bun.test.ts +278 -0
- package/src/pinners/bun.ts +223 -0
- package/src/pinners/mod.ts +59 -0
- package/src/pinners/node.test.ts +415 -0
- package/src/pinners/node.ts +144 -0
- package/src/pinners/pinners.test.ts +316 -0
- package/src/pinners/testdata/node-child.ts +147 -0
- package/src/pinners/testdata/proxy-child.ts +82 -0
- package/src/pinners/testdata/support.ts +343 -0
- package/src/pinners/testdata/tls-cert.pem +12 -0
- package/src/pinners/testdata/tls-child.ts +88 -0
- package/src/pinners/testdata/tls-key.pem +5 -0
- package/src/pinners/types.ts +48 -0
- package/src/public-fetch.test.ts +1286 -0
- package/src/public-fetch.ts +338 -0
- package/src/testdata/public-fetch-deno-worker.ts +74 -0
- package/src/url.test.ts +11 -0
- package/src/url.ts +113 -22
- package/tsdown.config.ts +7 -2
package/src/docloader.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getLogger } from "@logtape/logtape";
|
|
2
|
-
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
2
|
+
import { type Span, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
3
3
|
import metadata from "../deno.json" with { type: "json" };
|
|
4
4
|
import {
|
|
5
5
|
BodyTooLargeError,
|
|
@@ -9,13 +9,18 @@ import {
|
|
|
9
9
|
} from "./body.ts";
|
|
10
10
|
import preloadedContexts from "./contexts.ts";
|
|
11
11
|
import { HttpHeaderLink } from "./link.ts";
|
|
12
|
+
import {
|
|
13
|
+
openDirectFetch,
|
|
14
|
+
openPublicFetch,
|
|
15
|
+
type PublicFetchSession,
|
|
16
|
+
} from "./public-fetch.ts";
|
|
12
17
|
import {
|
|
13
18
|
createActivityPubRequest,
|
|
14
19
|
FetchError,
|
|
15
20
|
type GetUserAgentOptions,
|
|
16
21
|
logRequest,
|
|
17
22
|
} from "./request.ts";
|
|
18
|
-
import { UrlError
|
|
23
|
+
import { UrlError } from "./url.ts";
|
|
19
24
|
|
|
20
25
|
const logger = getLogger(["fedify", "runtime", "docloader"]);
|
|
21
26
|
const DEFAULT_MAX_REDIRECTION = 20;
|
|
@@ -187,7 +192,8 @@ async function bufferErrorResponse(
|
|
|
187
192
|
try {
|
|
188
193
|
await readBoundedBytes(response, MAX_ERROR_BODY_SIZE, url);
|
|
189
194
|
} catch (error) {
|
|
190
|
-
|
|
195
|
+
// Cancelling a tee branch must not block scoped session cleanup.
|
|
196
|
+
clone.body?.cancel().catch(() => {});
|
|
191
197
|
if (signal?.aborted) throw error;
|
|
192
198
|
logger.debug(
|
|
193
199
|
"Failed to read the error response body from {url}: {error}",
|
|
@@ -222,7 +228,9 @@ async function bufferErrorResponse(
|
|
|
222
228
|
* Gets a {@link RemoteDocument} from the given response.
|
|
223
229
|
* @param url The URL of the document to load.
|
|
224
230
|
* @param response The response to get the document from.
|
|
225
|
-
* @param fetch The function to fetch the document.
|
|
231
|
+
* @param fetch The function to fetch the document. It is called with the URL
|
|
232
|
+
* of an alternate document, if any, after the response body has
|
|
233
|
+
* been either read or its cancellation has been started.
|
|
226
234
|
* @param options The options for loading the document.
|
|
227
235
|
* @returns The loaded remote document.
|
|
228
236
|
* @throws {FetchError} If the response is not OK.
|
|
@@ -237,7 +245,55 @@ export async function getRemoteDocument(
|
|
|
237
245
|
) => Promise<RemoteDocument>,
|
|
238
246
|
options?: DocumentLoaderOptions,
|
|
239
247
|
): Promise<RemoteDocument> {
|
|
240
|
-
const
|
|
248
|
+
const result = await parseRemoteDocument(url, response, options);
|
|
249
|
+
if ("alternate" in result) return await fetch(result.alternate, options);
|
|
250
|
+
return result.document;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Starts cancelling the body of a response without waiting for it to settle.
|
|
255
|
+
* The cancellation of a branch of a tee (e.g., a body cloned by a fetch mock)
|
|
256
|
+
* settles only after the other branch is cancelled too, per the Streams
|
|
257
|
+
* standard, so awaiting it could block forever. Failures are only logged.
|
|
258
|
+
* @param response The response whose body is cancelled.
|
|
259
|
+
* @param url The URL of the response, for logging.
|
|
260
|
+
*/
|
|
261
|
+
function cancelResponseBody(response: Response, url: string): void {
|
|
262
|
+
response.body?.cancel().catch((error) => {
|
|
263
|
+
logger.debug(
|
|
264
|
+
"Failed to cancel the response body from {url}: {error}",
|
|
265
|
+
{ url, error },
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The result of {@link parseRemoteDocument}: either the loaded document, or
|
|
272
|
+
* the URL of an alternate document to load instead.
|
|
273
|
+
*/
|
|
274
|
+
type ParsedRemoteDocument =
|
|
275
|
+
| { readonly document: RemoteDocument }
|
|
276
|
+
| { readonly alternate: string };
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Gets a {@link RemoteDocument} from the given response, or the URL of an
|
|
280
|
+
* alternate document to load instead. When an alternate document is found,
|
|
281
|
+
* the response body has been either read or its cancellation has been started
|
|
282
|
+
* (without waiting for it), so that the alternate document can be loaded after
|
|
283
|
+
* the response's session is closed.
|
|
284
|
+
* @param url The URL of the document to load.
|
|
285
|
+
* @param response The response to get the document from.
|
|
286
|
+
* @returns The loaded remote document, or the alternate document URL.
|
|
287
|
+
* @throws {FetchError} If the response is not OK.
|
|
288
|
+
*/
|
|
289
|
+
async function parseRemoteDocument(
|
|
290
|
+
url: string,
|
|
291
|
+
response: Response,
|
|
292
|
+
options?: DocumentLoaderOptions,
|
|
293
|
+
): Promise<ParsedRemoteDocument> {
|
|
294
|
+
// Since the response URL is an IP address URL on runtimes that pin
|
|
295
|
+
// connections, the request URL is used instead:
|
|
296
|
+
const documentUrl = url;
|
|
241
297
|
const docUrl = new URL(documentUrl);
|
|
242
298
|
if (!response.ok) {
|
|
243
299
|
if (options?.suppressError) {
|
|
@@ -308,7 +364,8 @@ export async function getRemoteDocument(
|
|
|
308
364
|
"Found alternate document: {alternateUrl} from {url}",
|
|
309
365
|
{ alternateUrl: altUri.href, url: documentUrl },
|
|
310
366
|
);
|
|
311
|
-
|
|
367
|
+
cancelResponseBody(response, documentUrl);
|
|
368
|
+
return { alternate: altUri.href };
|
|
312
369
|
}
|
|
313
370
|
}
|
|
314
371
|
}
|
|
@@ -370,7 +427,7 @@ export async function getRemoteDocument(
|
|
|
370
427
|
"Found alternate document: {alternateUrl} from {url}",
|
|
371
428
|
{ alternateUrl: attribs.href, url: documentUrl },
|
|
372
429
|
);
|
|
373
|
-
return
|
|
430
|
+
return { alternate: new URL(attribs.href, docUrl).href };
|
|
374
431
|
}
|
|
375
432
|
}
|
|
376
433
|
try {
|
|
@@ -397,7 +454,7 @@ export async function getRemoteDocument(
|
|
|
397
454
|
headers: Object.fromEntries(response.headers.entries()),
|
|
398
455
|
},
|
|
399
456
|
);
|
|
400
|
-
return { contextUrl, document, documentUrl };
|
|
457
|
+
return { document: { contextUrl, document, documentUrl } };
|
|
401
458
|
}
|
|
402
459
|
|
|
403
460
|
/**
|
|
@@ -537,6 +594,37 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
|
|
|
537
594
|
* @since 1.3.0
|
|
538
595
|
*/
|
|
539
596
|
export function getDocumentLoader(
|
|
597
|
+
options: GetDocumentLoaderOptions = {},
|
|
598
|
+
): DocumentLoader {
|
|
599
|
+
return createDocumentLoader(options);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* The dependencies of {@link createDocumentLoader}.
|
|
604
|
+
*/
|
|
605
|
+
export interface DocumentLoaderDependencies {
|
|
606
|
+
/**
|
|
607
|
+
* Opens the fetch session for a single hop, instead of
|
|
608
|
+
* {@link openPublicFetch} (or {@link openDirectFetch} if private addresses
|
|
609
|
+
* are allowed).
|
|
610
|
+
*/
|
|
611
|
+
readonly openHop?: (url: string) => Promise<PublicFetchSession>;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* The outcome of a single hop of {@link createDocumentLoader}, decided while
|
|
616
|
+
* its fetch session is open.
|
|
617
|
+
*/
|
|
618
|
+
type HopOutcome = ParsedRemoteDocument | { readonly location: string };
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The implementation of {@link getDocumentLoader}, with injectable
|
|
622
|
+
* dependencies. This is not exported from any entry point.
|
|
623
|
+
* @param options Options for the document loader.
|
|
624
|
+
* @param deps The dependencies.
|
|
625
|
+
* @returns The document loader.
|
|
626
|
+
*/
|
|
627
|
+
export function createDocumentLoader(
|
|
540
628
|
{
|
|
541
629
|
allowPrivateAddress,
|
|
542
630
|
maxRedirection,
|
|
@@ -544,12 +632,76 @@ export function getDocumentLoader(
|
|
|
544
632
|
timeout,
|
|
545
633
|
userAgent,
|
|
546
634
|
}: GetDocumentLoaderOptions = {},
|
|
635
|
+
deps: DocumentLoaderDependencies = {},
|
|
547
636
|
): DocumentLoader {
|
|
548
637
|
const resolvedTimeout = resolveDocumentLoaderTimeout(timeout);
|
|
549
638
|
const tracerProvider = trace.getTracerProvider();
|
|
550
639
|
const tracer = tracerProvider.getTracer(metadata.name, metadata.version);
|
|
551
640
|
const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
|
|
552
641
|
|
|
642
|
+
async function openHop(
|
|
643
|
+
url: string,
|
|
644
|
+
options?: DocumentLoaderOptions,
|
|
645
|
+
): Promise<PublicFetchSession> {
|
|
646
|
+
try {
|
|
647
|
+
const session = deps.openHop != null
|
|
648
|
+
? await deps.openHop(url)
|
|
649
|
+
: allowPrivateAddress
|
|
650
|
+
? await openDirectFetch(url)
|
|
651
|
+
: await openPublicFetch(url, { signal: options?.signal });
|
|
652
|
+
if (options?.signal?.aborted) {
|
|
653
|
+
await session.close();
|
|
654
|
+
options.signal.throwIfAborted();
|
|
655
|
+
}
|
|
656
|
+
return session;
|
|
657
|
+
} catch (error) {
|
|
658
|
+
if (error instanceof UrlError) {
|
|
659
|
+
if (error.reason === "dns") {
|
|
660
|
+
logger.debug("DNS lookup failed for {url}", { url, error });
|
|
661
|
+
} else {
|
|
662
|
+
logger[options?.suppressError ? "warn" : "error"](
|
|
663
|
+
"Disallowed private URL: {url}",
|
|
664
|
+
{ url, error },
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
throw error;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function fetchHop(
|
|
673
|
+
session: PublicFetchSession,
|
|
674
|
+
url: string,
|
|
675
|
+
span: Span,
|
|
676
|
+
options?: DocumentLoaderOptions,
|
|
677
|
+
): Promise<HopOutcome> {
|
|
678
|
+
try {
|
|
679
|
+
const request = createActivityPubRequest(url, { userAgent });
|
|
680
|
+
logRequest(logger, request);
|
|
681
|
+
const response = await session.fetch(request, {
|
|
682
|
+
// Since Bun has a bug that ignores the `Request.redirect` option,
|
|
683
|
+
// to work around it we specify `redirect: "manual"` here too:
|
|
684
|
+
// https://github.com/oven-sh/bun/issues/10754
|
|
685
|
+
redirect: "manual",
|
|
686
|
+
signal: options?.signal,
|
|
687
|
+
});
|
|
688
|
+
span.setAttribute("http.response.status_code", response.status);
|
|
689
|
+
|
|
690
|
+
// Follow redirects manually to get the final URL:
|
|
691
|
+
if (
|
|
692
|
+
response.status >= 300 && response.status < 400 &&
|
|
693
|
+
response.headers.has("Location")
|
|
694
|
+
) {
|
|
695
|
+
const location = response.headers.get("Location")!;
|
|
696
|
+
cancelResponseBody(response, url);
|
|
697
|
+
return { location };
|
|
698
|
+
}
|
|
699
|
+
return await parseRemoteDocument(url, response, options);
|
|
700
|
+
} finally {
|
|
701
|
+
await session.close();
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
553
705
|
async function load(
|
|
554
706
|
url: string,
|
|
555
707
|
options?: DocumentLoaderOptions,
|
|
@@ -575,32 +727,9 @@ export function getDocumentLoader(
|
|
|
575
727
|
documentUrl: currentUrl,
|
|
576
728
|
};
|
|
577
729
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
} catch (error) {
|
|
582
|
-
if (error instanceof UrlError) {
|
|
583
|
-
if (error.reason === "dns") {
|
|
584
|
-
logger.debug("DNS lookup failed for {url}", {
|
|
585
|
-
url: currentUrl,
|
|
586
|
-
error,
|
|
587
|
-
});
|
|
588
|
-
} else {
|
|
589
|
-
logger[options?.suppressError ? "warn" : "error"](
|
|
590
|
-
"Disallowed private URL: {url}",
|
|
591
|
-
{
|
|
592
|
-
url: currentUrl,
|
|
593
|
-
error,
|
|
594
|
-
},
|
|
595
|
-
);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
throw error;
|
|
599
|
-
}
|
|
600
|
-
// The DNS lookup cannot be aborted, so do not go on if the call was
|
|
601
|
-
// aborted or timed out in the meantime:
|
|
602
|
-
options?.signal?.throwIfAborted();
|
|
603
|
-
}
|
|
730
|
+
// Opening the session validates the URL; the session is closed by
|
|
731
|
+
// fetchHop() before the next hop is opened:
|
|
732
|
+
const session = await openHop(currentUrl, options);
|
|
604
733
|
visited.add(currentUrl);
|
|
605
734
|
|
|
606
735
|
return await tracer.startActiveSpan(
|
|
@@ -613,22 +742,8 @@ export function getDocumentLoader(
|
|
|
613
742
|
},
|
|
614
743
|
async (span) => {
|
|
615
744
|
try {
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
const response = await fetch(request, {
|
|
619
|
-
// Since Bun has a bug that ignores the `Request.redirect` option,
|
|
620
|
-
// to work around it we specify `redirect: "manual"` here too:
|
|
621
|
-
// https://github.com/oven-sh/bun/issues/10754
|
|
622
|
-
redirect: "manual",
|
|
623
|
-
signal: options?.signal,
|
|
624
|
-
});
|
|
625
|
-
span.setAttribute("http.response.status_code", response.status);
|
|
626
|
-
|
|
627
|
-
// Follow redirects manually to get the final URL:
|
|
628
|
-
if (
|
|
629
|
-
response.status >= 300 && response.status < 400 &&
|
|
630
|
-
response.headers.has("Location")
|
|
631
|
-
) {
|
|
745
|
+
const outcome = await fetchHop(session, currentUrl, span, options);
|
|
746
|
+
if ("location" in outcome) {
|
|
632
747
|
if (redirected >= maximumRedirection) {
|
|
633
748
|
logger[options?.suppressError ? "warn" : "error"](
|
|
634
749
|
"Too many redirections ({redirections}) while fetching document.",
|
|
@@ -639,10 +754,7 @@ export function getDocumentLoader(
|
|
|
639
754
|
`Too many redirections (${redirected + 1})`,
|
|
640
755
|
);
|
|
641
756
|
}
|
|
642
|
-
const redirectUrl = new URL(
|
|
643
|
-
response.headers.get("Location")!,
|
|
644
|
-
response.url === "" ? currentUrl : response.url,
|
|
645
|
-
).href;
|
|
757
|
+
const redirectUrl = new URL(outcome.location, currentUrl).href;
|
|
646
758
|
span.setAttribute("http.redirect.url", redirectUrl);
|
|
647
759
|
if (visited.has(redirectUrl)) {
|
|
648
760
|
logger[options?.suppressError ? "warn" : "error"](
|
|
@@ -658,27 +770,26 @@ export function getDocumentLoader(
|
|
|
658
770
|
return await load(redirectUrl, options, redirected + 1, visited);
|
|
659
771
|
}
|
|
660
772
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
);
|
|
773
|
+
let result: RemoteDocument;
|
|
774
|
+
if ("alternate" in outcome) {
|
|
775
|
+
const alternateUrl = outcome.alternate;
|
|
776
|
+
options?.signal?.throwIfAborted();
|
|
777
|
+
if (redirected >= DEFAULT_MAX_REDIRECTION) {
|
|
778
|
+
throw new FetchError(
|
|
779
|
+
currentUrl,
|
|
780
|
+
`Too many redirections (${redirected + 1})`,
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
if (visited.has(alternateUrl)) {
|
|
784
|
+
throw new FetchError(
|
|
785
|
+
currentUrl,
|
|
786
|
+
`Redirect loop detected: ${alternateUrl}`,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
result = await load(alternateUrl, options, redirected + 1, visited);
|
|
790
|
+
} else {
|
|
791
|
+
result = outcome.document;
|
|
792
|
+
}
|
|
682
793
|
span.setAttribute("docloader.document_url", result.documentUrl);
|
|
683
794
|
if (result.contextUrl != null) {
|
|
684
795
|
span.setAttribute("docloader.context_url", result.contextUrl);
|
|
@@ -705,6 +705,61 @@ export interface DereferencePortableIriOptions<T> {
|
|
|
705
705
|
options: { contextLoader: DocumentLoader; baseUrl: URL },
|
|
706
706
|
) => Promise<T>;
|
|
707
707
|
span?: Span;
|
|
708
|
+
/**
|
|
709
|
+
* Observes each candidate document, and may refuse a candidate that was
|
|
710
|
+
* otherwise acceptable, in which case the next gateway is tried.
|
|
711
|
+
*/
|
|
712
|
+
observer?: DereferenceObserver<T>;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* A candidate document that a lookup or a dereference was about to return.
|
|
717
|
+
*
|
|
718
|
+
* @internal Not part of the public API contract.
|
|
719
|
+
*/
|
|
720
|
+
export interface DereferenceCandidate<T> {
|
|
721
|
+
/** The document as it was retrieved. */
|
|
722
|
+
readonly document: unknown;
|
|
723
|
+
/** The URL the document was finally retrieved from. */
|
|
724
|
+
readonly documentUrl: string;
|
|
725
|
+
/** The `baseUrl` option the document was parsed with. */
|
|
726
|
+
readonly baseUrl: URL;
|
|
727
|
+
/**
|
|
728
|
+
* The context loader the document was parsed with. It keeps serving the
|
|
729
|
+
* same context documents until the {@link DereferenceObserver.accept}
|
|
730
|
+
* call settles.
|
|
731
|
+
*/
|
|
732
|
+
readonly contextLoader: DocumentLoader;
|
|
733
|
+
/** The parsed object. */
|
|
734
|
+
readonly object: T;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Observes the candidates and the swallowed failures of a lookup or
|
|
739
|
+
* a dereference.
|
|
740
|
+
*
|
|
741
|
+
* @internal Not part of the public API contract.
|
|
742
|
+
*/
|
|
743
|
+
export interface DereferenceObserver<T> {
|
|
744
|
+
/**
|
|
745
|
+
* Called for each candidate whose parse succeeded, right before it would be
|
|
746
|
+
* returned. If it resolves to `false`, the candidate is refused, and the
|
|
747
|
+
* next one is tried. An error it throws is treated as a refusal, and is
|
|
748
|
+
* reported to {@link failed}.
|
|
749
|
+
*/
|
|
750
|
+
accept?(candidate: DereferenceCandidate<T>): Promise<boolean>;
|
|
751
|
+
/** Called for each failure that does not propagate by itself. */
|
|
752
|
+
failed?(failure: DereferenceFailure): void;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* A failure observed by {@link DereferenceObserver.failed}.
|
|
757
|
+
*
|
|
758
|
+
* @internal Not part of the public API contract.
|
|
759
|
+
*/
|
|
760
|
+
export interface DereferenceFailure {
|
|
761
|
+
readonly operation: "fetch" | "verify" | "parse" | "origin" | "accept";
|
|
762
|
+
readonly error?: unknown;
|
|
708
763
|
}
|
|
709
764
|
|
|
710
765
|
/**
|
|
@@ -725,6 +780,34 @@ type Attempt =
|
|
|
725
780
|
| { readonly type: "rejected"; readonly source: string }
|
|
726
781
|
| { readonly type: "error"; readonly error: unknown };
|
|
727
782
|
|
|
783
|
+
/**
|
|
784
|
+
* Asks an observer whether to accept a candidate. A refusal, including an
|
|
785
|
+
* error thrown by the observer, is reported to the observer as an `accept`
|
|
786
|
+
* failure.
|
|
787
|
+
* @returns `true` if there is no observer, or it accepted the candidate.
|
|
788
|
+
* @internal Not part of the public API contract.
|
|
789
|
+
*/
|
|
790
|
+
export async function acceptCandidate<T>(
|
|
791
|
+
observer: DereferenceObserver<T> | undefined,
|
|
792
|
+
candidate: DereferenceCandidate<T>,
|
|
793
|
+
): Promise<boolean> {
|
|
794
|
+
if (observer?.accept == null) return true;
|
|
795
|
+
let accepted: boolean;
|
|
796
|
+
try {
|
|
797
|
+
accepted = await observer.accept(candidate);
|
|
798
|
+
} catch (error) {
|
|
799
|
+
observer.failed?.({ operation: "accept", error });
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
if (!accepted) observer.failed?.({ operation: "accept" });
|
|
803
|
+
return accepted;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** Wraps an error that has already been reported to the observer. */
|
|
807
|
+
class ObservedFailure {
|
|
808
|
+
constructor(readonly error: unknown) {}
|
|
809
|
+
}
|
|
810
|
+
|
|
728
811
|
class PortableObjectRejection {
|
|
729
812
|
constructor(
|
|
730
813
|
readonly message: string,
|
|
@@ -852,6 +935,7 @@ export async function dereferencePortableIri<T extends { id: URL | null }>(
|
|
|
852
935
|
requestUrl,
|
|
853
936
|
error,
|
|
854
937
|
});
|
|
938
|
+
options.observer?.failed?.({ operation: "fetch", error });
|
|
855
939
|
attempts.push({ type: "error", error });
|
|
856
940
|
continue;
|
|
857
941
|
}
|
|
@@ -887,11 +971,29 @@ export async function dereferencePortableIri<T extends { id: URL | null }>(
|
|
|
887
971
|
{ result },
|
|
888
972
|
);
|
|
889
973
|
}
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
974
|
+
let object: T;
|
|
975
|
+
try {
|
|
976
|
+
object = await options.parse(document, {
|
|
977
|
+
contextLoader,
|
|
978
|
+
baseUrl: url,
|
|
979
|
+
});
|
|
980
|
+
} catch (error) {
|
|
981
|
+
options.observer?.failed?.({ operation: "parse", error });
|
|
982
|
+
throw new ObservedFailure(error);
|
|
983
|
+
}
|
|
894
984
|
signal?.throwIfAborted();
|
|
985
|
+
if (
|
|
986
|
+
!await acceptCandidate(options.observer, {
|
|
987
|
+
document,
|
|
988
|
+
documentUrl: remoteDocument.documentUrl,
|
|
989
|
+
baseUrl: url,
|
|
990
|
+
contextLoader,
|
|
991
|
+
object,
|
|
992
|
+
})
|
|
993
|
+
) {
|
|
994
|
+
attempts.push({ type: "rejected", source: requestUrl });
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
895
997
|
if (object != null && typeof object === "object") {
|
|
896
998
|
provenances.set(object, {
|
|
897
999
|
acceptance: result.unsecured === true ? "unsecured" : "verified",
|
|
@@ -913,15 +1015,23 @@ export async function dereferencePortableIri<T extends { id: URL | null }>(
|
|
|
913
1015
|
"because " + error.message,
|
|
914
1016
|
{ ...error.values, url: lookupUrl, requestUrl },
|
|
915
1017
|
);
|
|
1018
|
+
options.observer?.failed?.({
|
|
1019
|
+
operation: "verify",
|
|
1020
|
+
error: error.values.error,
|
|
1021
|
+
});
|
|
916
1022
|
attempts.push({ type: "rejected", source: requestUrl });
|
|
917
1023
|
continue;
|
|
918
1024
|
}
|
|
1025
|
+
const cause = error instanceof ObservedFailure ? error.error : error;
|
|
1026
|
+
if (!(error instanceof ObservedFailure)) {
|
|
1027
|
+
options.observer?.failed?.({ operation: "verify", error });
|
|
1028
|
+
}
|
|
919
1029
|
logger.debug("Failed to parse {url} from {requestUrl}: {error}", {
|
|
920
1030
|
url: lookupUrl,
|
|
921
1031
|
requestUrl,
|
|
922
|
-
error,
|
|
1032
|
+
error: cause,
|
|
923
1033
|
});
|
|
924
|
-
attempts.push({ type: "error", error });
|
|
1034
|
+
attempts.push({ type: "error", error: cause });
|
|
925
1035
|
}
|
|
926
1036
|
}
|
|
927
1037
|
} finally {
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal APIs shared among Fedify packages. They are not part of the public
|
|
3
|
+
* API and have no semver guarantees; do not use them outside Fedify.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
export {
|
|
8
|
+
openDirectFetch,
|
|
9
|
+
openPublicFetch,
|
|
10
|
+
type PublicFetchOptions,
|
|
11
|
+
type PublicFetchSession,
|
|
12
|
+
withPublicFetch,
|
|
13
|
+
} from "./public-fetch.ts";
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { test } from "@fedify/fixture";
|
|
2
|
+
import { deepStrictEqual, ok, strictEqual, throws } from "node:assert";
|
|
3
|
+
import type { LookupAddress } from "node:dns";
|
|
4
|
+
import {
|
|
5
|
+
issuePinGrant,
|
|
6
|
+
type PinGrant,
|
|
7
|
+
redeemPinGrant,
|
|
8
|
+
revokePinGrant,
|
|
9
|
+
} from "./pin-grant.ts";
|
|
10
|
+
|
|
11
|
+
const addresses: readonly LookupAddress[] = [
|
|
12
|
+
{ address: "8.8.8.8", family: 4 },
|
|
13
|
+
{ address: "2001:4860:4860::8888", family: 6 },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
test("redeemPinGrant() returns what the grant was issued for", () => {
|
|
17
|
+
const url = new URL("https://pinned-host.invalid:8443/path?query");
|
|
18
|
+
const input = addresses.map((address) => ({ ...address }));
|
|
19
|
+
const grant = issuePinGrant(url, input);
|
|
20
|
+
const record = redeemPinGrant(grant, url);
|
|
21
|
+
deepStrictEqual(record.origin, "https://pinned-host.invalid:8443");
|
|
22
|
+
deepStrictEqual(record.hostname, "pinned-host.invalid");
|
|
23
|
+
deepStrictEqual(record.addresses, addresses);
|
|
24
|
+
|
|
25
|
+
// The record holds a frozen copy of the addresses:
|
|
26
|
+
input[0].address = "127.0.0.1";
|
|
27
|
+
input.push({ address: "10.0.0.1", family: 4 });
|
|
28
|
+
deepStrictEqual(redeemPinGrant(grant, url).addresses, addresses);
|
|
29
|
+
ok(Object.isFrozen(record));
|
|
30
|
+
ok(Object.isFrozen(record.addresses));
|
|
31
|
+
ok(Object.isFrozen(record.addresses[0]));
|
|
32
|
+
|
|
33
|
+
// Other paths and queries of the same origin are accepted:
|
|
34
|
+
strictEqual(
|
|
35
|
+
redeemPinGrant(grant, new URL("https://pinned-host.invalid:8443/other")),
|
|
36
|
+
record,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("redeemPinGrant() strips brackets from IPv6 hostnames", () => {
|
|
41
|
+
const url = new URL("http://[2001:4860:4860::8888]/");
|
|
42
|
+
const grant = issuePinGrant(url, [addresses[1]]);
|
|
43
|
+
const record = redeemPinGrant(grant, url);
|
|
44
|
+
deepStrictEqual(record.hostname, "2001:4860:4860::8888");
|
|
45
|
+
deepStrictEqual(record.origin, "http://[2001:4860:4860::8888]");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("redeemPinGrant() rejects grants not issued by issuePinGrant()", () => {
|
|
49
|
+
const url = new URL("https://pinned-host.invalid/");
|
|
50
|
+
const issued = issuePinGrant(url, addresses);
|
|
51
|
+
const forgeries: Record<string, unknown> = {
|
|
52
|
+
"object literal": {},
|
|
53
|
+
"null-prototype object": Object.create(null),
|
|
54
|
+
"frozen null-prototype object": Object.freeze(Object.create(null)),
|
|
55
|
+
"copy of a grant": { ...issued },
|
|
56
|
+
"structured clone of a grant": structuredClone(issued),
|
|
57
|
+
"object inheriting from a grant": Object.create(issued),
|
|
58
|
+
"null": null,
|
|
59
|
+
"undefined": undefined,
|
|
60
|
+
"string": "grant",
|
|
61
|
+
};
|
|
62
|
+
for (const [name, forgery] of Object.entries(forgeries)) {
|
|
63
|
+
throws(
|
|
64
|
+
() => redeemPinGrant(forgery as PinGrant, url),
|
|
65
|
+
TypeError,
|
|
66
|
+
name,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
// The issued grant itself still works:
|
|
70
|
+
ok(redeemPinGrant(issued, url));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("redeemPinGrant() rejects URLs of other origins", () => {
|
|
74
|
+
const grant = issuePinGrant(
|
|
75
|
+
new URL("https://pinned-host.invalid/"),
|
|
76
|
+
addresses,
|
|
77
|
+
);
|
|
78
|
+
for (
|
|
79
|
+
const url of [
|
|
80
|
+
"http://pinned-host.invalid/",
|
|
81
|
+
"https://other-host.invalid/",
|
|
82
|
+
"https://sub.pinned-host.invalid/",
|
|
83
|
+
"https://pinned-host.invalid:8443/",
|
|
84
|
+
"https://8.8.8.8/",
|
|
85
|
+
]
|
|
86
|
+
) {
|
|
87
|
+
throws(() => redeemPinGrant(grant, new URL(url)), TypeError, url);
|
|
88
|
+
}
|
|
89
|
+
// The default port is the same origin:
|
|
90
|
+
ok(redeemPinGrant(grant, new URL("https://pinned-host.invalid:443/")));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("redeemPinGrant() rejects revoked grants", () => {
|
|
94
|
+
const url = new URL("https://pinned-host.invalid/");
|
|
95
|
+
const grant = issuePinGrant(url, addresses);
|
|
96
|
+
const other = issuePinGrant(url, addresses);
|
|
97
|
+
revokePinGrant(grant);
|
|
98
|
+
throws(() => redeemPinGrant(grant, url), TypeError);
|
|
99
|
+
// Revoking is idempotent, and does not affect other grants:
|
|
100
|
+
revokePinGrant(grant);
|
|
101
|
+
throws(() => redeemPinGrant(grant, url), TypeError);
|
|
102
|
+
ok(redeemPinGrant(other, url));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("revokePinGrant() ignores unknown objects", () => {
|
|
106
|
+
revokePinGrant({} as PinGrant);
|
|
107
|
+
revokePinGrant(Object.create(null) as PinGrant);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("issuePinGrant() returns a distinct frozen object every time", () => {
|
|
111
|
+
const url = new URL("https://pinned-host.invalid/");
|
|
112
|
+
const a = issuePinGrant(url, addresses);
|
|
113
|
+
const b = issuePinGrant(url, addresses);
|
|
114
|
+
ok(a !== b);
|
|
115
|
+
ok(Object.isFrozen(a));
|
|
116
|
+
strictEqual(Object.getPrototypeOf(a), null);
|
|
117
|
+
deepStrictEqual(Reflect.ownKeys(a), []);
|
|
118
|
+
});
|