@fedify/vocab-runtime 2.5.0-dev.2448 → 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.
Files changed (120) hide show
  1. package/deno.json +5 -2
  2. package/dist/{docloader-BBnBpbsG.d.cts → docloader-DD6binpN.d.cts} +4 -8
  3. package/dist/{docloader-BBnBpbsG.d.ts → docloader-DD6binpN.d.ts} +4 -8
  4. package/dist/internal/jsonld-cache.cjs +1 -1
  5. package/dist/internal/jsonld-cache.d.cts +1 -1
  6. package/dist/internal/jsonld-cache.d.ts +1 -1
  7. package/dist/internal/jsonld-cache.js +1 -1
  8. package/dist/internal/portable-dereference.cjs +1 -1
  9. package/dist/internal/portable-dereference.d.cts +2 -2
  10. package/dist/internal/portable-dereference.d.ts +2 -2
  11. package/dist/internal/portable-dereference.js +1 -1
  12. package/dist/internal.cjs +6 -0
  13. package/dist/internal.d.cts +108 -0
  14. package/dist/internal.d.ts +108 -0
  15. package/dist/internal.js +3 -0
  16. package/dist/mod.cjs +104 -38
  17. package/dist/mod.d.cts +3 -336
  18. package/dist/mod.d.ts +4 -336
  19. package/dist/mod.js +104 -38
  20. package/dist/{portable-BDydjp5H.d.ts → portable-1VSnSJFj.d.ts} +1 -1
  21. package/dist/{portable-Dfvcg_Wy.d.cts → portable-Cr7NwZwb.d.cts} +1 -1
  22. package/dist/public-fetch-Dvfvawei.cjs +527 -0
  23. package/dist/public-fetch-ziZ39L5I.js +510 -0
  24. package/dist/tests/{body-DP2u2wUX.cjs → body-C8d1PmDa.cjs} +1 -1
  25. package/dist/tests/{body-oV99TVdA.mjs → body-HSLxhexN.mjs} +1 -1
  26. package/dist/tests/body.test.cjs +1 -1
  27. package/dist/tests/body.test.mjs +1 -1
  28. package/dist/tests/decimal.test.cjs +4 -4
  29. package/dist/tests/decimal.test.mjs +4 -4
  30. package/dist/tests/{docloader-CwxZguqN.cjs → docloader-D1fNCGam.cjs} +111 -39
  31. package/dist/tests/{docloader-D3mA7aPJ.mjs → docloader-DkEUFJui.mjs} +106 -40
  32. package/dist/tests/docloader.test.cjs +531 -1293
  33. package/dist/tests/docloader.test.mjs +472 -1252
  34. package/dist/tests/esm-Ds-eHTfY.mjs +1229 -0
  35. package/dist/tests/esm-Q2tdh5f3.cjs +1215 -0
  36. package/dist/tests/internal/portable-dereference.test.cjs +3 -3
  37. package/dist/tests/internal/portable-dereference.test.mjs +3 -3
  38. package/dist/tests/{jsonld-cache-BEXiiM-V.cjs → jsonld-cache-DKIWKxEX.cjs} +1 -1
  39. package/dist/tests/{jsonld-cache-DFnWsLuZ.mjs → jsonld-cache-KH40z9_7.mjs} +1 -1
  40. package/dist/tests/jsonld-cache.test.cjs +2 -2
  41. package/dist/tests/jsonld-cache.test.mjs +2 -2
  42. package/dist/tests/node-CbBR-th-.mjs +84 -0
  43. package/dist/tests/node-Ch29PDXR.cjs +89 -0
  44. package/dist/tests/pin-grant-CPrpk1l4.cjs +70 -0
  45. package/dist/tests/pin-grant-H5XhM1Xs.mjs +53 -0
  46. package/dist/tests/pin-grant.test.cjs +87 -0
  47. package/dist/tests/pin-grant.test.d.cts +1 -0
  48. package/dist/tests/pin-grant.test.d.mts +1 -0
  49. package/dist/tests/pin-grant.test.mjs +88 -0
  50. package/dist/tests/pinners/bun.test.cjs +166 -0
  51. package/dist/tests/pinners/bun.test.d.cts +1 -0
  52. package/dist/tests/pinners/bun.test.d.mts +1 -0
  53. package/dist/tests/pinners/bun.test.mjs +167 -0
  54. package/dist/tests/pinners/node.test.cjs +269 -0
  55. package/dist/tests/pinners/node.test.d.cts +1 -0
  56. package/dist/tests/pinners/node.test.d.mts +1 -0
  57. package/dist/tests/pinners/node.test.mjs +268 -0
  58. package/dist/tests/pinners/pinners.test.cjs +196 -0
  59. package/dist/tests/pinners/pinners.test.d.cts +1 -0
  60. package/dist/tests/pinners/pinners.test.d.mts +1 -0
  61. package/dist/tests/pinners/pinners.test.mjs +197 -0
  62. package/dist/tests/pinners/testdata/node-child.cjs +135 -0
  63. package/dist/tests/pinners/testdata/node-child.d.cts +1 -0
  64. package/dist/tests/pinners/testdata/node-child.d.mts +1 -0
  65. package/dist/tests/pinners/testdata/node-child.mjs +134 -0
  66. package/dist/tests/pinners-DLH_npLf.cjs +220 -0
  67. package/dist/tests/pinners-tGcl49uF.mjs +203 -0
  68. package/dist/tests/{portable-dereference-DaTBBvCj.cjs → portable-dereference-BUoDzMxk.cjs} +2 -2
  69. package/dist/tests/{portable-dereference-sGRs_qdT.mjs → portable-dereference-Du2k3ngE.mjs} +2 -2
  70. package/dist/tests/{portable-media-D7ClSjFO.cjs → portable-media-BM9zgj9g.cjs} +3 -3
  71. package/dist/tests/{portable-media-B-oCeotd.mjs → portable-media-CTxGibcj.mjs} +3 -3
  72. package/dist/tests/portable-media.test.cjs +1 -1
  73. package/dist/tests/portable-media.test.mjs +1 -1
  74. package/dist/tests/portable-workers.test.cjs +2 -2
  75. package/dist/tests/portable-workers.test.mjs +2 -2
  76. package/dist/tests/public-fetch-BGj5pzUD.cjs +186 -0
  77. package/dist/tests/public-fetch-Cxo3Yzdf.mjs +170 -0
  78. package/dist/tests/public-fetch.test.cjs +1073 -0
  79. package/dist/tests/public-fetch.test.d.cts +1 -0
  80. package/dist/tests/public-fetch.test.d.mts +1 -0
  81. package/dist/tests/public-fetch.test.mjs +1074 -0
  82. package/dist/tests/{request-B77_oZnk.cjs → request-7q-Zfe1p.cjs} +2 -2
  83. package/dist/tests/{request-BlLYI4x3.mjs → request-D5uGtmmq.mjs} +2 -2
  84. package/dist/tests/request.test.cjs +2 -2
  85. package/dist/tests/request.test.mjs +2 -2
  86. package/dist/tests/support-Bj44xlQu.mjs +272 -0
  87. package/dist/tests/support-BrdlbLJk.cjs +363 -0
  88. package/dist/tests/{url-BNakuZ8k.cjs → url-Bdj-rTcC.cjs} +88 -12
  89. package/dist/tests/{url-DMxmp7ZG.mjs → url-COj5Q8mb.mjs} +65 -13
  90. package/dist/tests/url.test.cjs +4 -1
  91. package/dist/tests/url.test.mjs +4 -1
  92. package/dist/url-4EE56sNP.d.cts +352 -0
  93. package/dist/url-4EE56sNP.d.ts +352 -0
  94. package/dist/{url-DrGTR8yv.cjs → url-DIKZnaJv.cjs} +88 -12
  95. package/dist/{url-Dzyp-NsC.js → url-mWveR5GA.js} +65 -13
  96. package/package.json +14 -3
  97. package/src/docloader.test.ts +696 -48
  98. package/src/docloader.ts +186 -75
  99. package/src/internal.ts +13 -0
  100. package/src/pin-grant.test.ts +118 -0
  101. package/src/pin-grant.ts +99 -0
  102. package/src/pinners/bun.test.ts +278 -0
  103. package/src/pinners/bun.ts +223 -0
  104. package/src/pinners/mod.ts +59 -0
  105. package/src/pinners/node.test.ts +415 -0
  106. package/src/pinners/node.ts +144 -0
  107. package/src/pinners/pinners.test.ts +316 -0
  108. package/src/pinners/testdata/node-child.ts +147 -0
  109. package/src/pinners/testdata/proxy-child.ts +82 -0
  110. package/src/pinners/testdata/support.ts +343 -0
  111. package/src/pinners/testdata/tls-cert.pem +12 -0
  112. package/src/pinners/testdata/tls-child.ts +88 -0
  113. package/src/pinners/testdata/tls-key.pem +5 -0
  114. package/src/pinners/types.ts +48 -0
  115. package/src/public-fetch.test.ts +1286 -0
  116. package/src/public-fetch.ts +338 -0
  117. package/src/testdata/public-fetch-deno-worker.ts +74 -0
  118. package/src/url.test.ts +11 -0
  119. package/src/url.ts +113 -22
  120. package/tsdown.config.ts +7 -2
@@ -0,0 +1,1286 @@
1
+ import { test } from "@fedify/fixture";
2
+ import { configure, type LogRecord, reset } from "@logtape/logtape";
3
+ import fetchMock from "fetch-mock";
4
+ import { deepStrictEqual, ok, rejects, strictEqual, throws } from "node:assert";
5
+ import type { LookupAddress } from "node:dns";
6
+ import { createServer, type Server } from "node:http";
7
+ import type { AddressInfo } from "node:net";
8
+ import { type PinGrant, redeemPinGrant } from "./pin-grant.ts";
9
+ import { type ConnectionPinner, detectRuntime } from "./pinners/mod.ts";
10
+ import {
11
+ createPublicFetch,
12
+ openDirectFetch,
13
+ openPublicFetch,
14
+ type PublicFetchSession,
15
+ } from "./public-fetch.ts";
16
+ import { UrlError } from "./url.ts";
17
+
18
+ interface PinnedCall {
19
+ readonly url: string;
20
+ readonly method: string;
21
+ readonly redirect: RequestRedirect;
22
+ readonly addresses: readonly LookupAddress[];
23
+ }
24
+
25
+ function createRecordingPinner(
26
+ respond: (request: Request, index: number) => Response | Promise<Response> =
27
+ () => new Response("ok"),
28
+ ) {
29
+ const calls: PinnedCall[] = [];
30
+ const grants: PinGrant[] = [];
31
+ const released: PinGrant[] = [];
32
+ let releaseError: unknown;
33
+ const pinner: ConnectionPinner = {
34
+ supports(url) {
35
+ return url.protocol === "http:" || url.protocol === "https:";
36
+ },
37
+ async fetch(grant, request) {
38
+ const { addresses } = redeemPinGrant(grant, new URL(request.url));
39
+ grants.push(grant);
40
+ const index = calls.length;
41
+ calls.push({
42
+ url: request.url,
43
+ method: request.method,
44
+ redirect: request.redirect,
45
+ addresses,
46
+ });
47
+ return await respond(request, index);
48
+ },
49
+ release(grant) {
50
+ released.push(grant);
51
+ if (releaseError != null) throw releaseError;
52
+ },
53
+ };
54
+ return {
55
+ pinner,
56
+ calls,
57
+ grants,
58
+ released,
59
+ failRelease(error: unknown) {
60
+ releaseError = error;
61
+ },
62
+ };
63
+ }
64
+
65
+ type LookupResult = readonly LookupAddress[] | Error;
66
+
67
+ function createRecordingLookup(...results: LookupResult[]) {
68
+ const hostnames: string[] = [];
69
+ function lookup(hostname: string): Promise<readonly LookupAddress[]> {
70
+ hostnames.push(hostname);
71
+ const result = results[Math.min(hostnames.length, results.length) - 1];
72
+ return result instanceof Error
73
+ ? Promise.reject(result)
74
+ : Promise.resolve(result);
75
+ }
76
+ return { lookup, hostnames };
77
+ }
78
+
79
+ const publicAddresses: readonly LookupAddress[] = [
80
+ { address: "8.8.8.8", family: 4 },
81
+ ];
82
+
83
+ function failingLookup(): Promise<readonly LookupAddress[]> {
84
+ return Promise.reject(new Error("The default lookup must not be used."));
85
+ }
86
+
87
+ function createTrackedBody(
88
+ options: { text?: string; cancelError?: Error } = {},
89
+ ) {
90
+ const state = { cancelled: 0, cancelReasons: [] as unknown[] };
91
+ let sent = false;
92
+ const stream = new ReadableStream<Uint8Array>({
93
+ pull(controller) {
94
+ if (sent) {
95
+ controller.close();
96
+ return;
97
+ }
98
+ sent = true;
99
+ controller.enqueue(new TextEncoder().encode(options.text ?? "body"));
100
+ },
101
+ cancel(reason) {
102
+ state.cancelled++;
103
+ state.cancelReasons.push(reason);
104
+ if (options.cancelError != null) throw options.cancelError;
105
+ },
106
+ });
107
+ return { stream, state };
108
+ }
109
+
110
+ async function captureLogs<T>(
111
+ fn: () => Promise<T>,
112
+ ): Promise<{ result: T; records: LogRecord[] }> {
113
+ const records: LogRecord[] = [];
114
+ await configure({
115
+ sinks: { buffer: (record) => records.push(record) },
116
+ loggers: [
117
+ {
118
+ category: ["fedify", "runtime", "fetch"],
119
+ sinks: ["buffer"],
120
+ lowestLevel: "debug",
121
+ },
122
+ { category: ["logtape", "meta"], sinks: [] },
123
+ ],
124
+ reset: true,
125
+ });
126
+ try {
127
+ const result = await fn();
128
+ return { result, records };
129
+ } finally {
130
+ await reset();
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Waits until the rejections of body cancellations started without being
136
+ * awaited have been handled.
137
+ */
138
+ function settleCancellations(): Promise<void> {
139
+ return new Promise((resolve) => setTimeout(resolve, 0));
140
+ }
141
+
142
+ /**
143
+ * Fails instead of blocking the test if the given promise does not settle
144
+ * within the given time.
145
+ */
146
+ async function settlesWithin<T>(
147
+ promise: Promise<T>,
148
+ milliseconds = 1000,
149
+ ): Promise<T> {
150
+ let timer: ReturnType<typeof setTimeout> | undefined;
151
+ try {
152
+ return await Promise.race([
153
+ promise,
154
+ new Promise<never>((_, reject) => {
155
+ timer = setTimeout(
156
+ () => reject(new Error(`Did not settle in ${milliseconds} ms.`)),
157
+ milliseconds,
158
+ );
159
+ }),
160
+ ]);
161
+ } finally {
162
+ clearTimeout(timer);
163
+ }
164
+ }
165
+
166
+ function cancelFailureLogs(
167
+ records: readonly LogRecord[],
168
+ ): [string, unknown, unknown][] {
169
+ return records
170
+ .filter((record) =>
171
+ record.rawMessage ===
172
+ "Failed to cancel a response body from {url}: {error}"
173
+ )
174
+ .map((record) => [
175
+ record.level,
176
+ record.properties.url,
177
+ record.properties.error,
178
+ ]);
179
+ }
180
+
181
+ async function withMockedGlobalFetch<T>(
182
+ fn: () => Promise<T>,
183
+ ): Promise<T> {
184
+ fetchMock.mockGlobal();
185
+ try {
186
+ return await fn();
187
+ } finally {
188
+ fetchMock.hardReset();
189
+ }
190
+ }
191
+
192
+ async function listen(server: Server, host = "127.0.0.1"): Promise<number> {
193
+ await new Promise<void>((resolve) => server.listen(0, host, resolve));
194
+ return (server.address() as AddressInfo).port;
195
+ }
196
+
197
+ async function closeServer(server: Server): Promise<void> {
198
+ server.closeAllConnections?.();
199
+ await new Promise<void>((resolve) => server.close(() => resolve()));
200
+ }
201
+
202
+ test("openPublicFetch() pins IP literal hostnames without lookups", async () => {
203
+ const { lookup, hostnames } = createRecordingLookup(publicAddresses);
204
+ const recorder = createRecordingPinner();
205
+ const { openPublicFetch } = createPublicFetch({
206
+ pinner: () => Promise.resolve(recorder.pinner),
207
+ lookup: failingLookup,
208
+ });
209
+ for (
210
+ const [url, address] of [
211
+ ["https://8.8.8.8/", { address: "8.8.8.8", family: 4 }],
212
+ [
213
+ "https://[2001:4860:4860::8888]:8443/",
214
+ { address: "2001:4860:4860::8888", family: 6 },
215
+ ],
216
+ ] as const
217
+ ) {
218
+ const session = await openPublicFetch(url, { lookup });
219
+ try {
220
+ const response = await session.fetch(url);
221
+ deepStrictEqual(await response.text(), "ok");
222
+ } finally {
223
+ await session.close();
224
+ }
225
+ deepStrictEqual(recorder.calls.at(-1)?.addresses, [address]);
226
+ }
227
+ deepStrictEqual(hostnames, []);
228
+ });
229
+
230
+ test("openPublicFetch() pins the addresses the lookup returned", async () => {
231
+ const { lookup, hostnames } = createRecordingLookup([
232
+ { address: "8.8.8.8", family: 4 },
233
+ // CNAME entries, as returned by Cloudflare Workers, are skipped:
234
+ { address: "cname-target.invalid", family: 0 } as LookupAddress,
235
+ { address: "2001:4860:4860::8888", family: 6 },
236
+ ]);
237
+ const recorder = createRecordingPinner();
238
+ const { openPublicFetch } = createPublicFetch({
239
+ pinner: () => Promise.resolve(recorder.pinner),
240
+ lookup: failingLookup,
241
+ });
242
+ const session = await openPublicFetch(
243
+ "https://PINNED-HOST.invalid/object",
244
+ { lookup },
245
+ );
246
+ try {
247
+ deepStrictEqual(session.url.href, "https://pinned-host.invalid/object");
248
+ // The URL is a copy:
249
+ session.url.pathname = "/changed";
250
+ deepStrictEqual(session.url.href, "https://pinned-host.invalid/object");
251
+ const response = await session.fetch("https://pinned-host.invalid/object");
252
+ deepStrictEqual(await response.text(), "ok");
253
+ } finally {
254
+ await session.close();
255
+ }
256
+ deepStrictEqual(hostnames, ["pinned-host.invalid"]);
257
+ deepStrictEqual(recorder.calls, [{
258
+ url: "https://pinned-host.invalid/object",
259
+ method: "GET",
260
+ redirect: "manual",
261
+ addresses: [
262
+ { address: "8.8.8.8", family: 4 },
263
+ { address: "2001:4860:4860::8888", family: 6 },
264
+ ],
265
+ }]);
266
+ deepStrictEqual(recorder.released, recorder.grants.slice(0, 1));
267
+ });
268
+
269
+ test("openPublicFetch() uses the default lookup without options", async () => {
270
+ const { lookup, hostnames } = createRecordingLookup(publicAddresses);
271
+ const recorder = createRecordingPinner();
272
+ const { openPublicFetch } = createPublicFetch({
273
+ pinner: () => Promise.resolve(recorder.pinner),
274
+ lookup,
275
+ });
276
+ const session = await openPublicFetch("https://pinned-host.invalid/");
277
+ await session.close();
278
+ deepStrictEqual(hostnames, ["pinned-host.invalid"]);
279
+ });
280
+
281
+ test("openPublicFetch() rejects disallowed URLs", async (t) => {
282
+ const cases: readonly {
283
+ readonly name: string;
284
+ readonly url: string;
285
+ readonly lookup?: LookupResult;
286
+ readonly reason: UrlError["reason"];
287
+ }[] = [
288
+ {
289
+ name: "protocol",
290
+ url: "ftp://pinned-host.invalid/",
291
+ reason: "disallowed",
292
+ },
293
+ { name: "localhost", url: "https://localhost/", reason: "disallowed" },
294
+ { name: "IPv4 loopback", url: "https://127.0.0.1/", reason: "disallowed" },
295
+ { name: "IPv6 loopback", url: "https://[::1]/", reason: "disallowed" },
296
+ {
297
+ name: "IPv4-mapped IPv6",
298
+ url: "https://[::ffff:7f00:1]/",
299
+ reason: "disallowed",
300
+ },
301
+ {
302
+ name: "credentials",
303
+ url: "https://user:password@pinned-host.invalid/",
304
+ lookup: publicAddresses,
305
+ reason: "disallowed",
306
+ },
307
+ {
308
+ name: "username",
309
+ url: "https://user@pinned-host.invalid/",
310
+ lookup: publicAddresses,
311
+ reason: "disallowed",
312
+ },
313
+ {
314
+ name: "private lookup result",
315
+ url: "https://pinned-host.invalid/",
316
+ lookup: [{ address: "10.0.0.1", family: 4 }],
317
+ reason: "disallowed",
318
+ },
319
+ {
320
+ name: "mixed lookup result",
321
+ url: "https://pinned-host.invalid/",
322
+ lookup: [
323
+ { address: "8.8.8.8", family: 4 },
324
+ { address: "127.0.0.1", family: 4 },
325
+ ],
326
+ reason: "disallowed",
327
+ },
328
+ {
329
+ name: "mixed IPv6 lookup result",
330
+ url: "https://pinned-host.invalid/",
331
+ lookup: [
332
+ { address: "2001:4860:4860::8888", family: 6 },
333
+ { address: "fd00::1", family: 6 },
334
+ ],
335
+ reason: "disallowed",
336
+ },
337
+ {
338
+ name: "lookup failure",
339
+ url: "https://pinned-host.invalid/",
340
+ lookup: Object.assign(new Error("getaddrinfo ENOTFOUND"), {
341
+ code: "ENOTFOUND",
342
+ }),
343
+ reason: "dns",
344
+ },
345
+ {
346
+ name: "empty lookup result",
347
+ url: "https://pinned-host.invalid/",
348
+ lookup: [],
349
+ reason: "dns",
350
+ },
351
+ {
352
+ name: "CNAME-only lookup result",
353
+ url: "https://pinned-host.invalid/",
354
+ lookup: [{ address: "cname-target.invalid", family: 0 }],
355
+ reason: "dns",
356
+ },
357
+ ];
358
+ for (const { name, url, lookup: result, reason } of cases) {
359
+ await t.step(name, async () => {
360
+ const { lookup } = createRecordingLookup(result ?? publicAddresses);
361
+ const recorder = createRecordingPinner();
362
+ let pinnerRequested = 0;
363
+ const { openPublicFetch } = createPublicFetch({
364
+ pinner: () => {
365
+ pinnerRequested++;
366
+ return Promise.resolve(recorder.pinner);
367
+ },
368
+ lookup: failingLookup,
369
+ });
370
+ await withMockedGlobalFetch(async () => {
371
+ fetchMock.catch(500);
372
+ await rejects(
373
+ () => openPublicFetch(url, { lookup }),
374
+ (error) => {
375
+ ok(error instanceof UrlError, String(error));
376
+ deepStrictEqual(error.reason, reason);
377
+ return true;
378
+ },
379
+ );
380
+ deepStrictEqual(fetchMock.callHistory.calls(), []);
381
+ });
382
+ deepStrictEqual(pinnerRequested, 0);
383
+ deepStrictEqual(recorder.calls, []);
384
+ deepStrictEqual(recorder.released, []);
385
+ });
386
+ }
387
+ });
388
+
389
+ test("openPublicFetch() resists DNS rebinding", async () => {
390
+ // The first lookup returns a public address, and every later one returns
391
+ // a private address, as a rebinding DNS server would:
392
+ const { lookup, hostnames } = createRecordingLookup(
393
+ [{ address: "8.8.8.8", family: 4 }],
394
+ [{ address: "127.0.0.1", family: 4 }],
395
+ );
396
+ const recorder = createRecordingPinner();
397
+ const { openPublicFetch, withPublicFetch } = createPublicFetch({
398
+ pinner: () => Promise.resolve(recorder.pinner),
399
+ lookup: failingLookup,
400
+ });
401
+ const session = await openPublicFetch("https://rebinding.invalid/", {
402
+ lookup,
403
+ });
404
+ try {
405
+ for (
406
+ const input of [
407
+ "https://rebinding.invalid/a",
408
+ new URL("https://rebinding.invalid/b"),
409
+ new Request("https://rebinding.invalid/c", { method: "POST" }),
410
+ "https://rebinding.invalid/a",
411
+ ]
412
+ ) {
413
+ const response = await session.fetch(input);
414
+ await response.body?.cancel();
415
+ }
416
+ } finally {
417
+ await session.close();
418
+ }
419
+ deepStrictEqual(hostnames, ["rebinding.invalid"]);
420
+ deepStrictEqual(
421
+ recorder.calls.map(({ url, method, addresses }) => [
422
+ url,
423
+ method,
424
+ addresses,
425
+ ]),
426
+ [
427
+ ["https://rebinding.invalid/a", "GET", [{
428
+ address: "8.8.8.8",
429
+ family: 4,
430
+ }]],
431
+ ["https://rebinding.invalid/b", "GET", [{
432
+ address: "8.8.8.8",
433
+ family: 4,
434
+ }]],
435
+ ["https://rebinding.invalid/c", "POST", [{
436
+ address: "8.8.8.8",
437
+ family: 4,
438
+ }]],
439
+ ["https://rebinding.invalid/a", "GET", [{
440
+ address: "8.8.8.8",
441
+ family: 4,
442
+ }]],
443
+ ],
444
+ );
445
+ // All requests of the session share a single grant:
446
+ deepStrictEqual(new Set(recorder.grants).size, 1);
447
+
448
+ // A new session looks the hostname up again, and rejects the private
449
+ // address:
450
+ await rejects(
451
+ () =>
452
+ withPublicFetch(
453
+ "https://rebinding.invalid/",
454
+ () => Promise.reject(new Error("must not be called")),
455
+ { lookup },
456
+ ),
457
+ UrlError,
458
+ );
459
+ deepStrictEqual(hostnames, ["rebinding.invalid", "rebinding.invalid"]);
460
+ deepStrictEqual(recorder.calls.length, 4);
461
+ });
462
+
463
+ test("PublicFetchSession.fetch() rejects other origins", async () => {
464
+ const { lookup } = createRecordingLookup(publicAddresses);
465
+ const recorder = createRecordingPinner();
466
+ const { openPublicFetch } = createPublicFetch({
467
+ pinner: () => Promise.resolve(recorder.pinner),
468
+ lookup: failingLookup,
469
+ });
470
+ const session = await openPublicFetch("https://pinned-host.invalid/a", {
471
+ lookup,
472
+ });
473
+ try {
474
+ for (
475
+ const input of [
476
+ "http://pinned-host.invalid/a",
477
+ "https://pinned-host.invalid:8443/a",
478
+ "https://other-host.invalid/a",
479
+ "https://8.8.8.8/a",
480
+ new URL("https://other-host.invalid/a"),
481
+ new Request("https://other-host.invalid/a"),
482
+ ]
483
+ ) {
484
+ await rejects(() => session.fetch(input), TypeError, String(input));
485
+ }
486
+ deepStrictEqual(recorder.calls, []);
487
+ // Other paths and queries of the same origin are allowed:
488
+ const response = await session.fetch("https://pinned-host.invalid/b?c");
489
+ await response.body?.cancel();
490
+ deepStrictEqual(
491
+ recorder.calls.map(({ url }) => url),
492
+ ["https://pinned-host.invalid/b?c"],
493
+ );
494
+ } finally {
495
+ await session.close();
496
+ }
497
+ });
498
+
499
+ test("PublicFetchSession.fetch() never follows redirects", async (t) => {
500
+ await t.step("pinned", async () => {
501
+ const { lookup } = createRecordingLookup(publicAddresses);
502
+ const recorder = createRecordingPinner(() =>
503
+ new Response(null, {
504
+ status: 302,
505
+ headers: { Location: "https://pinned-host.invalid/target" },
506
+ })
507
+ );
508
+ const { openPublicFetch } = createPublicFetch({
509
+ pinner: () => Promise.resolve(recorder.pinner),
510
+ lookup: failingLookup,
511
+ });
512
+ const session = await openPublicFetch("https://pinned-host.invalid/", {
513
+ lookup,
514
+ });
515
+ try {
516
+ const url = "https://pinned-host.invalid/";
517
+ for (
518
+ const [input, init] of [
519
+ [url, undefined],
520
+ [url, { redirect: "follow" }],
521
+ [url, { redirect: "error" }],
522
+ [new Request(url), undefined],
523
+ [new Request(url, { redirect: "follow" }), undefined],
524
+ [new Request(url), { redirect: "follow" }],
525
+ ] as const
526
+ ) {
527
+ const response = await session.fetch(input, init);
528
+ deepStrictEqual(response.status, 302);
529
+ }
530
+ } finally {
531
+ await session.close();
532
+ }
533
+ deepStrictEqual(
534
+ recorder.calls.map(({ redirect }) => redirect),
535
+ Array(6).fill("manual"),
536
+ );
537
+ });
538
+
539
+ await t.step("unpinned", async () => {
540
+ const { lookup } = createRecordingLookup(publicAddresses);
541
+ const { openPublicFetch } = createPublicFetch({
542
+ pinner: () => Promise.resolve(null),
543
+ lookup: failingLookup,
544
+ });
545
+ await withMockedGlobalFetch(async () => {
546
+ fetchMock.route("https://unpinned-host.invalid/", 200);
547
+ const session = await openPublicFetch("https://unpinned-host.invalid/", {
548
+ lookup,
549
+ });
550
+ try {
551
+ await session.fetch("https://unpinned-host.invalid/", {
552
+ redirect: "follow",
553
+ });
554
+ await session.fetch(
555
+ new Request("https://unpinned-host.invalid/", { redirect: "follow" }),
556
+ );
557
+ } finally {
558
+ await session.close();
559
+ }
560
+ const calls = fetchMock.callHistory.calls();
561
+ deepStrictEqual(calls.length, 2);
562
+ for (const call of calls) {
563
+ deepStrictEqual((call.args[1] as RequestInit).redirect, "manual");
564
+ }
565
+ });
566
+ });
567
+
568
+ await t.step("real server", async () => {
569
+ let hits = 0;
570
+ const server = createServer((_, res) => {
571
+ hits++;
572
+ res.writeHead(302, { Location: "/target" });
573
+ res.end("redirect");
574
+ });
575
+ const port = await listen(server);
576
+ try {
577
+ const url = `http://127.0.0.1:${port}/`;
578
+ const session = await openDirectFetch(url);
579
+ try {
580
+ const response = await session.fetch(url, { redirect: "follow" });
581
+ deepStrictEqual(response.status, 302);
582
+ deepStrictEqual(response.headers.get("Location"), "/target");
583
+ await response.body?.cancel();
584
+ } finally {
585
+ await session.close();
586
+ }
587
+ deepStrictEqual(hits, 1);
588
+ } finally {
589
+ await closeServer(server);
590
+ }
591
+ });
592
+ });
593
+
594
+ test("PublicFetchSession.close()", async () => {
595
+ const bodies = [
596
+ createTrackedBody(), // consumed
597
+ createTrackedBody(), // left unconsumed
598
+ createTrackedBody(), // locked by a reader
599
+ ];
600
+ const { lookup } = createRecordingLookup(publicAddresses);
601
+ const recorder = createRecordingPinner((_, index) =>
602
+ index < bodies.length
603
+ ? new Response(bodies[index].stream)
604
+ : new Response(null, { status: 204 })
605
+ );
606
+ const { openPublicFetch } = createPublicFetch({
607
+ pinner: () => Promise.resolve(recorder.pinner),
608
+ lookup: failingLookup,
609
+ });
610
+ const url = "https://pinned-host.invalid/";
611
+ const session: PublicFetchSession = await openPublicFetch(url, { lookup });
612
+ const consumed = await session.fetch(url);
613
+ deepStrictEqual(await consumed.text(), "body");
614
+ const unconsumed = await session.fetch(url);
615
+ const locked = await session.fetch(url);
616
+ const reader = locked.body!.getReader();
617
+ const empty = await session.fetch(url);
618
+ deepStrictEqual(empty.body, null);
619
+
620
+ await session.close();
621
+ deepStrictEqual(bodies.map(({ state }) => state.cancelled), [0, 1, 0]);
622
+ ok(unconsumed.bodyUsed);
623
+ deepStrictEqual(recorder.released, [recorder.grants[0]]);
624
+ throws(() => redeemPinGrant(recorder.grants[0], new URL(url)), TypeError);
625
+ await rejects(() => session.fetch(url), TypeError);
626
+ deepStrictEqual(recorder.calls.length, 4);
627
+
628
+ // Idempotent:
629
+ await session.close();
630
+ deepStrictEqual(recorder.released.length, 1);
631
+ deepStrictEqual(bodies.map(({ state }) => state.cancelled), [0, 1, 0]);
632
+ await reader.cancel();
633
+ });
634
+
635
+ test("PublicFetchSession.close() logs cancel failures without throwing", async (t) => {
636
+ const url = "https://pinned-host.invalid/";
637
+
638
+ function setUp(cancelErrors: readonly (Error | undefined)[]) {
639
+ const bodies = cancelErrors.map((cancelError) =>
640
+ createTrackedBody({ cancelError })
641
+ );
642
+ const { lookup } = createRecordingLookup(publicAddresses);
643
+ const recorder = createRecordingPinner((_, index) =>
644
+ new Response(bodies[index].stream)
645
+ );
646
+ const { openPublicFetch } = createPublicFetch({
647
+ pinner: () => Promise.resolve(recorder.pinner),
648
+ lookup: failingLookup,
649
+ });
650
+ return { bodies, recorder, lookup, openPublicFetch };
651
+ }
652
+
653
+ async function assertClosed(
654
+ session: PublicFetchSession,
655
+ recorder: ReturnType<typeof createRecordingPinner>,
656
+ ): Promise<void> {
657
+ deepStrictEqual(recorder.released.length, 1);
658
+ for (const grant of recorder.grants) {
659
+ strictEqual(grant, recorder.released[0]);
660
+ }
661
+ // The grant is revoked even if releasing it fails:
662
+ throws(() => redeemPinGrant(recorder.released[0], new URL(url)), TypeError);
663
+ await rejects(() => session.fetch(url), TypeError);
664
+ // The second close() neither throws nor cleans up again:
665
+ await session.close();
666
+ deepStrictEqual(recorder.released.length, 1);
667
+ }
668
+
669
+ await t.step("two failures", async () => {
670
+ const errors = [new Error("cancel 1"), new Error("cancel 2")];
671
+ const { bodies, recorder, lookup, openPublicFetch } = setUp(errors);
672
+ const { records } = await captureLogs(async () => {
673
+ const session = await openPublicFetch(url, { lookup });
674
+ await session.fetch(url);
675
+ await session.fetch(url);
676
+ await session.close();
677
+ deepStrictEqual(bodies.map(({ state }) => state.cancelled), [1, 1]);
678
+ await assertClosed(session, recorder);
679
+ await settleCancellations();
680
+ });
681
+ deepStrictEqual(cancelFailureLogs(records), [
682
+ ["debug", url, errors[0]],
683
+ ["debug", url, errors[1]],
684
+ ]);
685
+ });
686
+
687
+ await t.step("one failure", async () => {
688
+ const error = new Error("cancel");
689
+ const { bodies, recorder, lookup, openPublicFetch } = setUp([
690
+ undefined,
691
+ error,
692
+ ]);
693
+ const { records } = await captureLogs(async () => {
694
+ const session = await openPublicFetch(url, { lookup });
695
+ await session.fetch(url);
696
+ await session.fetch(url);
697
+ await session.close();
698
+ deepStrictEqual(bodies.map(({ state }) => state.cancelled), [1, 1]);
699
+ await assertClosed(session, recorder);
700
+ await settleCancellations();
701
+ });
702
+ deepStrictEqual(cancelFailureLogs(records), [["debug", url, error]]);
703
+ });
704
+
705
+ await t.step("failure already seen by the caller", async () => {
706
+ const error = new Error("cancel");
707
+ const { bodies, recorder, lookup, openPublicFetch } = setUp([error]);
708
+ const { records } = await captureLogs(async () => {
709
+ const session = await openPublicFetch(url, { lookup });
710
+ const response = await session.fetch(url);
711
+ await rejects(() => response.body!.cancel(), (e) => e === error);
712
+ // A body whose cancellation was attempted is disturbed, so close() does
713
+ // not cancel it again:
714
+ ok(response.bodyUsed);
715
+ await session.close();
716
+ deepStrictEqual(bodies[0].state.cancelled, 1);
717
+ await assertClosed(session, recorder);
718
+ await settleCancellations();
719
+ });
720
+ deepStrictEqual(cancelFailureLogs(records), []);
721
+ });
722
+
723
+ await t.step("release failure", async () => {
724
+ const releaseError = new Error("release");
725
+ const { recorder, lookup, openPublicFetch } = setUp([]);
726
+ recorder.failRelease(releaseError);
727
+ const session = await openPublicFetch(url, { lookup });
728
+ await rejects(() => session.close(), (e) => e === releaseError);
729
+ await assertClosed(session, recorder);
730
+ });
731
+
732
+ await t.step("cancel and release failures", async () => {
733
+ const cancelError = new Error("cancel");
734
+ const releaseError = new Error("release");
735
+ const { bodies, recorder, lookup, openPublicFetch } = setUp([cancelError]);
736
+ recorder.failRelease(releaseError);
737
+ const { records } = await captureLogs(async () => {
738
+ const session = await openPublicFetch(url, { lookup });
739
+ await session.fetch(url);
740
+ // Only the release error is thrown; the cancel error is only logged:
741
+ await rejects(() => session.close(), (e) => e === releaseError);
742
+ deepStrictEqual(bodies[0].state.cancelled, 1);
743
+ await assertClosed(session, recorder);
744
+ await settleCancellations();
745
+ });
746
+ deepStrictEqual(cancelFailureLogs(records), [["debug", url, cancelError]]);
747
+ });
748
+ });
749
+
750
+ test("PublicFetchSession.close() does not wait for body cancellation", async (t) => {
751
+ const url = "https://pinned-host.invalid/";
752
+
753
+ async function assertCloseSettles(
754
+ respond: () => Response,
755
+ ): Promise<number> {
756
+ const { lookup } = createRecordingLookup(publicAddresses);
757
+ const recorder = createRecordingPinner(respond);
758
+ const { openPublicFetch } = createPublicFetch({
759
+ pinner: () => Promise.resolve(recorder.pinner),
760
+ lookup: failingLookup,
761
+ });
762
+ const session = await openPublicFetch(url, { lookup });
763
+ const response = await session.fetch(url);
764
+ ok(!response.bodyUsed);
765
+ await settlesWithin(session.close());
766
+ ok(response.bodyUsed);
767
+ deepStrictEqual(recorder.released.length, 1);
768
+ throws(() => redeemPinGrant(recorder.grants[0], new URL(url)), TypeError);
769
+ return recorder.calls.length;
770
+ }
771
+
772
+ await t.step("never-settling cancel", async () => {
773
+ const cancelled: unknown[] = [];
774
+ const calls = await assertCloseSettles(() =>
775
+ new Response(
776
+ new ReadableStream({
777
+ cancel(reason) {
778
+ cancelled.push(reason);
779
+ return new Promise<void>(() => {});
780
+ },
781
+ }),
782
+ )
783
+ );
784
+ deepStrictEqual(calls, 1);
785
+ deepStrictEqual(cancelled.length, 1);
786
+ });
787
+
788
+ await t.step("tee branch", async () => {
789
+ // The cancellation of a tee branch settles only after the other branch
790
+ // is cancelled too, which never happens here:
791
+ const originals: Response[] = [];
792
+ const calls = await assertCloseSettles(() => {
793
+ const original = new Response(createTrackedBody().stream);
794
+ originals.push(original);
795
+ return original.clone();
796
+ });
797
+ deepStrictEqual(calls, 1);
798
+ ok(!originals[0].bodyUsed);
799
+ await originals[0].body!.cancel();
800
+ });
801
+
802
+ await t.step("fetch-mock responses", async () => {
803
+ // fetch-mock returns a clone (a tee branch) of a Response instance that
804
+ // a route returns. The cancellation of a string body's branch never
805
+ // settles on Node.js, and that of a stream body's branch never settles on
806
+ // any runtime:
807
+ const { lookup } = createRecordingLookup(publicAddresses);
808
+ const { openPublicFetch } = createPublicFetch({
809
+ pinner: () => Promise.resolve(null),
810
+ lookup: failingLookup,
811
+ });
812
+ await withMockedGlobalFetch(async () => {
813
+ fetchMock.route(
814
+ "https://unpinned-host.invalid/string",
815
+ new Response("string"),
816
+ );
817
+ fetchMock.route(
818
+ "https://unpinned-host.invalid/stream",
819
+ () => new Response(createTrackedBody().stream),
820
+ );
821
+ fetchMock.route(
822
+ "https://unpinned-host.invalid/redirect",
823
+ () =>
824
+ new Response(createTrackedBody().stream, {
825
+ status: 302,
826
+ headers: { Location: "https://unpinned-host.invalid/string" },
827
+ }),
828
+ );
829
+ for (const path of ["string", "stream", "redirect"]) {
830
+ const target = `https://unpinned-host.invalid/${path}`;
831
+ const session = await openPublicFetch(target, { lookup });
832
+ const response = await session.fetch(target);
833
+ ok(!response.bodyUsed);
834
+ await settlesWithin(session.close());
835
+ ok(response.bodyUsed);
836
+ }
837
+ deepStrictEqual(fetchMock.callHistory.calls().length, 3);
838
+ });
839
+ });
840
+ });
841
+
842
+ test("PublicFetchSession.fetch() cancels responses arriving after close()", async (t) => {
843
+ const url = "https://pinned-host.invalid/";
844
+
845
+ async function assertCancelledAfterClose(
846
+ response: Response,
847
+ ): Promise<void> {
848
+ let respond!: (response: Response) => void;
849
+ const { lookup } = createRecordingLookup(publicAddresses);
850
+ const recorder = createRecordingPinner(() =>
851
+ new Promise<Response>((resolve) => respond = resolve)
852
+ );
853
+ const { openPublicFetch } = createPublicFetch({
854
+ pinner: () => Promise.resolve(recorder.pinner),
855
+ lookup: failingLookup,
856
+ });
857
+ const session = await openPublicFetch(url, { lookup });
858
+ const pending = session.fetch(url);
859
+ // Let the pinner receive the request:
860
+ while (recorder.calls.length < 1) await Promise.resolve();
861
+ await session.close();
862
+ respond(response);
863
+ await settlesWithin(rejects(() => pending, TypeError));
864
+ ok(response.bodyUsed);
865
+ deepStrictEqual(recorder.released.length, 1);
866
+ }
867
+
868
+ await t.step("cancellable body", async () => {
869
+ const body = createTrackedBody();
870
+ await assertCancelledAfterClose(new Response(body.stream));
871
+ deepStrictEqual(body.state.cancelled, 1);
872
+ });
873
+
874
+ await t.step("never-settling cancel", async () => {
875
+ let cancelled = 0;
876
+ await assertCancelledAfterClose(
877
+ new Response(
878
+ new ReadableStream({
879
+ cancel() {
880
+ cancelled++;
881
+ return new Promise<void>(() => {});
882
+ },
883
+ }),
884
+ ),
885
+ );
886
+ deepStrictEqual(cancelled, 1);
887
+ });
888
+
889
+ await t.step("failing cancel", async () => {
890
+ const error = new Error("cancel");
891
+ const body = createTrackedBody({ cancelError: error });
892
+ const { records } = await captureLogs(async () => {
893
+ await assertCancelledAfterClose(new Response(body.stream));
894
+ await settleCancellations();
895
+ });
896
+ deepStrictEqual(body.state.cancelled, 1);
897
+ deepStrictEqual(cancelFailureLogs(records), [["debug", url, error]]);
898
+ });
899
+ });
900
+
901
+ test("withPublicFetch()", async (t) => {
902
+ const url = "https://pinned-host.invalid/";
903
+
904
+ function setUp() {
905
+ const bodies: ReturnType<typeof createTrackedBody>[] = [];
906
+ const { lookup } = createRecordingLookup(publicAddresses);
907
+ const recorder = createRecordingPinner(() => {
908
+ const body = createTrackedBody();
909
+ bodies.push(body);
910
+ return new Response(body.stream);
911
+ });
912
+ const { withPublicFetch } = createPublicFetch({
913
+ pinner: () => Promise.resolve(recorder.pinner),
914
+ lookup: failingLookup,
915
+ });
916
+ return { bodies, recorder, lookup, withPublicFetch };
917
+ }
918
+
919
+ await t.step("returns the result", async () => {
920
+ const { bodies, recorder, lookup, withPublicFetch } = setUp();
921
+ const result = await withPublicFetch(
922
+ url,
923
+ async (fetch) => await (await fetch(url)).text(),
924
+ { lookup },
925
+ );
926
+ deepStrictEqual(result, "body");
927
+ deepStrictEqual(bodies.map(({ state }) => state.cancelled), [0]);
928
+ deepStrictEqual(recorder.released.length, 1);
929
+ });
930
+
931
+ await t.step("closes the session when the callback throws", async () => {
932
+ const { bodies, recorder, lookup, withPublicFetch } = setUp();
933
+ const error = new Error("callback");
934
+ let fetchInScope: PublicFetchSession["fetch"] | undefined;
935
+ await rejects(
936
+ () =>
937
+ withPublicFetch(url, async (fetch) => {
938
+ fetchInScope = fetch;
939
+ await fetch(url);
940
+ throw error;
941
+ }, { lookup }),
942
+ (e) => e === error,
943
+ );
944
+ deepStrictEqual(bodies.map(({ state }) => state.cancelled), [1]);
945
+ deepStrictEqual(recorder.released.length, 1);
946
+ await rejects(() => fetchInScope!(url), TypeError);
947
+ });
948
+
949
+ await t.step("rejects returning a Response", async () => {
950
+ const { bodies, recorder, lookup, withPublicFetch } = setUp();
951
+ await rejects(
952
+ () => withPublicFetch(url, (fetch) => fetch(url), { lookup }),
953
+ TypeError,
954
+ );
955
+ deepStrictEqual(bodies.map(({ state }) => state.cancelled), [1]);
956
+ deepStrictEqual(recorder.released.length, 1);
957
+ });
958
+
959
+ await t.step("does not call the callback for disallowed URLs", async () => {
960
+ const { recorder, withPublicFetch } = setUp();
961
+ let called = false;
962
+ await rejects(
963
+ () =>
964
+ withPublicFetch(url, () => {
965
+ called = true;
966
+ return Promise.resolve();
967
+ }, {
968
+ lookup: () => Promise.resolve([{ address: "10.0.0.1", family: 4 }]),
969
+ }),
970
+ UrlError,
971
+ );
972
+ ok(!called);
973
+ deepStrictEqual(recorder.calls, []);
974
+ deepStrictEqual(recorder.released, []);
975
+ });
976
+ });
977
+
978
+ test("openPublicFetch() honors the abort signal", async () => {
979
+ const recorder = createRecordingPinner();
980
+ const { openPublicFetch } = createPublicFetch({
981
+ pinner: () => Promise.resolve(recorder.pinner),
982
+ lookup: failingLookup,
983
+ });
984
+ const url = "https://pinned-host.invalid/";
985
+
986
+ const aborted = new AbortController();
987
+ aborted.abort(new Error("aborted"));
988
+ const before = createRecordingLookup(publicAddresses);
989
+ await rejects(
990
+ () => openPublicFetch(url, { signal: aborted.signal, ...before }),
991
+ (e) => e === aborted.signal.reason,
992
+ );
993
+ deepStrictEqual(before.hostnames, []);
994
+
995
+ const during = new AbortController();
996
+ await rejects(
997
+ () =>
998
+ openPublicFetch(url, {
999
+ signal: during.signal,
1000
+ lookup() {
1001
+ during.abort(new Error("aborted during lookup"));
1002
+ return Promise.resolve(publicAddresses);
1003
+ },
1004
+ }),
1005
+ (e) => e === during.signal.reason,
1006
+ );
1007
+ deepStrictEqual(recorder.calls, []);
1008
+ deepStrictEqual(recorder.released, []);
1009
+ });
1010
+
1011
+ function withNavigatorUserAgent<T>(
1012
+ userAgent: string,
1013
+ fn: () => Promise<T>,
1014
+ ): Promise<T> {
1015
+ const descriptor = Object.getOwnPropertyDescriptor(navigator, "userAgent");
1016
+ Object.defineProperty(navigator, "userAgent", {
1017
+ value: userAgent,
1018
+ configurable: true,
1019
+ });
1020
+ const restore = () => {
1021
+ if (descriptor == null) {
1022
+ delete (navigator as { userAgent?: string }).userAgent;
1023
+ } else {
1024
+ Object.defineProperty(navigator, "userAgent", descriptor);
1025
+ }
1026
+ };
1027
+ return fn().finally(restore);
1028
+ }
1029
+
1030
+ function isUnpinnedLog(record: LogRecord): boolean {
1031
+ return typeof record.rawMessage === "string" &&
1032
+ record.rawMessage.startsWith("Outbound {protocol} connections are not");
1033
+ }
1034
+
1035
+ test("openPublicFetch() uses the global fetch() without a pinner", async () => {
1036
+ const { lookup, hostnames } = createRecordingLookup(publicAddresses);
1037
+ const unsupported = createRecordingPinner();
1038
+ unsupported.pinner.supports = () => false;
1039
+ const pinners: (ConnectionPinner | null)[] = [null, unsupported.pinner];
1040
+ const { openPublicFetch } = createPublicFetch({
1041
+ pinner: () => Promise.resolve(pinners[hostnames.length % 2]),
1042
+ lookup: failingLookup,
1043
+ });
1044
+ // Simulates Cloudflare Workers so that the runtime and protocol pairs of
1045
+ // the debug log are not used by any other test in the process:
1046
+ const { records } = await captureLogs(() =>
1047
+ withNavigatorUserAgent(
1048
+ "Cloudflare-Workers",
1049
+ () =>
1050
+ withMockedGlobalFetch(async () => {
1051
+ fetchMock.route("begin:https://unpinned-host.invalid/", "https");
1052
+ fetchMock.route("begin:http://unpinned-host.invalid/", "http");
1053
+ for (const protocol of ["https", "http", "https", "http", "https"]) {
1054
+ const url =
1055
+ `${protocol}://unpinned-host.invalid/${hostnames.length}`;
1056
+ const session = await openPublicFetch(url, { lookup });
1057
+ try {
1058
+ const response = await session.fetch(url);
1059
+ deepStrictEqual(await response.text(), protocol);
1060
+ } finally {
1061
+ await session.close();
1062
+ }
1063
+ }
1064
+ // The hostname is not replaced with the address, since there is no
1065
+ // pinner to keep the Host header and the TLS server name:
1066
+ deepStrictEqual(
1067
+ fetchMock.callHistory.calls().map(({ url }) => url),
1068
+ [
1069
+ "https://unpinned-host.invalid/0",
1070
+ "http://unpinned-host.invalid/1",
1071
+ "https://unpinned-host.invalid/2",
1072
+ "http://unpinned-host.invalid/3",
1073
+ "https://unpinned-host.invalid/4",
1074
+ ],
1075
+ );
1076
+ // Addresses are still validated:
1077
+ await rejects(
1078
+ () =>
1079
+ openPublicFetch("https://unpinned-host.invalid/", {
1080
+ lookup: () =>
1081
+ Promise.resolve([{ address: "127.0.0.1", family: 4 }]),
1082
+ }),
1083
+ UrlError,
1084
+ );
1085
+ deepStrictEqual(fetchMock.callHistory.calls().length, 5);
1086
+ }),
1087
+ )
1088
+ );
1089
+ deepStrictEqual(unsupported.calls, []);
1090
+ deepStrictEqual(unsupported.released, []);
1091
+ // Logged once per runtime and protocol:
1092
+ deepStrictEqual(
1093
+ records.filter(isUnpinnedLog).map((r) => [
1094
+ r.level,
1095
+ r.properties.runtime,
1096
+ r.properties.protocol,
1097
+ ]),
1098
+ [["debug", "cfworkers", "https:"], ["debug", "cfworkers", "http:"]],
1099
+ );
1100
+ });
1101
+
1102
+ test("openPublicFetch() uses the global fetch() on Deno", {
1103
+ ignore: !("Deno" in globalThis),
1104
+ }, async () => {
1105
+ const { lookup, hostnames } = createRecordingLookup(publicAddresses);
1106
+ await withMockedGlobalFetch(async () => {
1107
+ fetchMock.route("https://deno-host.invalid/object", { body: "ok" });
1108
+ for (let i = 0; i < 2; i++) {
1109
+ const session = await openPublicFetch(
1110
+ "https://deno-host.invalid/object",
1111
+ { lookup },
1112
+ );
1113
+ try {
1114
+ const response = await session.fetch(
1115
+ "https://deno-host.invalid/object",
1116
+ { redirect: "follow" },
1117
+ );
1118
+ deepStrictEqual(await response.text(), "ok");
1119
+ } finally {
1120
+ await session.close();
1121
+ }
1122
+ }
1123
+ const calls = fetchMock.callHistory.calls();
1124
+ deepStrictEqual(
1125
+ calls.map(({ url, args }) => [url, (args[1] as RequestInit).redirect]),
1126
+ [
1127
+ ["https://deno-host.invalid/object", "manual"],
1128
+ ["https://deno-host.invalid/object", "manual"],
1129
+ ],
1130
+ );
1131
+ });
1132
+ deepStrictEqual(hostnames, ["deno-host.invalid", "deno-host.invalid"]);
1133
+ // The debug log is emitted only once per runtime and protocol in a module
1134
+ // instance, and other tests may already have emitted it in this one, so it
1135
+ // is checked in a worker, which has its own module instances. Workers need
1136
+ // the read permission for their modules, so this is skipped without it
1137
+ // (e.g., `deno task test` of this package):
1138
+ const workerUrl = new URL(
1139
+ "./testdata/public-fetch-deno-worker.ts",
1140
+ import.meta.url,
1141
+ );
1142
+ const permission = await Deno.permissions.query({
1143
+ name: "read",
1144
+ path: workerUrl,
1145
+ });
1146
+ if (permission.state !== "granted") return;
1147
+ const worker = new Worker(workerUrl, { type: "module" });
1148
+ try {
1149
+ const result = await settlesWithin(
1150
+ new Promise((resolve, reject) => {
1151
+ worker.onmessage = (event) => resolve(event.data);
1152
+ worker.onerror = (event) => {
1153
+ event.preventDefault();
1154
+ reject(new Error(event.message));
1155
+ };
1156
+ }),
1157
+ 10_000,
1158
+ );
1159
+ deepStrictEqual(result, {
1160
+ calls: [
1161
+ ["https://deno-host.invalid/1", "manual"],
1162
+ ["https://deno-host.invalid/2", "manual"],
1163
+ ["http://deno-host.invalid/3", "manual"],
1164
+ ],
1165
+ hostnames: [
1166
+ "deno-host.invalid",
1167
+ "deno-host.invalid",
1168
+ "deno-host.invalid",
1169
+ ],
1170
+ bodies: ["ok", "ok", "ok"],
1171
+ logs: [["debug", "deno", "https:"], ["debug", "deno", "http:"]],
1172
+ });
1173
+ } finally {
1174
+ worker.terminate();
1175
+ }
1176
+ });
1177
+
1178
+ test("openPublicFetch() pins connections on Node.js", {
1179
+ ignore: detectRuntime() !== "node",
1180
+ }, async () => {
1181
+ const { lookup, hostnames } = createRecordingLookup(publicAddresses);
1182
+ await withMockedGlobalFetch(async () => {
1183
+ fetchMock.route("https://node-host.invalid/object", { body: "ok" });
1184
+ const session = await openPublicFetch(
1185
+ "https://node-host.invalid/object",
1186
+ { lookup },
1187
+ );
1188
+ try {
1189
+ const response = await session.fetch(
1190
+ "https://node-host.invalid/object",
1191
+ { redirect: "follow" },
1192
+ );
1193
+ deepStrictEqual(await response.text(), "ok");
1194
+ } finally {
1195
+ await session.close();
1196
+ }
1197
+ // The request is sent with the pinner's undici agent as the dispatcher;
1198
+ // fetch-mock replaces the global fetch(), so the agent is not used here:
1199
+ const calls = fetchMock.callHistory.calls();
1200
+ deepStrictEqual(calls.length, 1);
1201
+ const init = calls[0].args[1] as RequestInit & { dispatcher?: unknown };
1202
+ deepStrictEqual(calls[0].url, "https://node-host.invalid/object");
1203
+ deepStrictEqual(init.redirect, "manual");
1204
+ ok(
1205
+ typeof (init.dispatcher as { dispatch?: unknown } | undefined)
1206
+ ?.dispatch === "function",
1207
+ String(init.dispatcher),
1208
+ );
1209
+ });
1210
+ deepStrictEqual(hostnames, ["node-host.invalid"]);
1211
+ });
1212
+
1213
+ test("openPublicFetch() rejects fedify-test.internal", async (t) => {
1214
+ const url = "https://fedify-test.internal/";
1215
+ const cases = [
1216
+ {
1217
+ name: "private address",
1218
+ lookup: () => Promise.resolve([{ address: "10.0.0.1", family: 4 }]),
1219
+ reason: "disallowed",
1220
+ },
1221
+ {
1222
+ name: "lookup failure",
1223
+ lookup: () =>
1224
+ Promise.reject(
1225
+ Object.assign(new Error("getaddrinfo ENOTFOUND"), {
1226
+ code: "ENOTFOUND",
1227
+ }),
1228
+ ),
1229
+ reason: "dns",
1230
+ },
1231
+ ] as const;
1232
+ for (const { name, lookup, reason } of cases) {
1233
+ await t.step(name, async () => {
1234
+ const recorder = createRecordingPinner();
1235
+ const recorded = createPublicFetch({
1236
+ pinner: () => Promise.resolve(recorder.pinner),
1237
+ lookup: failingLookup,
1238
+ });
1239
+ await withMockedGlobalFetch(async () => {
1240
+ fetchMock.catch(200);
1241
+ // Both the runtime's openPublicFetch() and one with a recording
1242
+ // pinner have no exception for the hostname:
1243
+ for (const open of [openPublicFetch, recorded.openPublicFetch]) {
1244
+ await rejects(() => open(url, { lookup }), (error) => {
1245
+ ok(error instanceof UrlError, String(error));
1246
+ deepStrictEqual(error.reason, reason);
1247
+ return true;
1248
+ });
1249
+ }
1250
+ deepStrictEqual(fetchMock.callHistory.calls(), []);
1251
+ });
1252
+ deepStrictEqual(recorder.calls, []);
1253
+ deepStrictEqual(recorder.released, []);
1254
+ });
1255
+ }
1256
+ });
1257
+
1258
+ test("openDirectFetch() follows the session rules", async () => {
1259
+ let hits = 0;
1260
+ const server = createServer((_, res) => {
1261
+ hits++;
1262
+ // Leaves the body open so that only close() can finish it:
1263
+ res.writeHead(200, { "Content-Type": "text/plain" });
1264
+ res.write("partial");
1265
+ });
1266
+ const port = await listen(server);
1267
+ try {
1268
+ const url = `http://127.0.0.1:${port}/`;
1269
+ const session = await openDirectFetch(url);
1270
+ deepStrictEqual(session.url.href, url);
1271
+ await rejects(
1272
+ () => session.fetch(`http://127.0.0.1:${port + 1}/`),
1273
+ TypeError,
1274
+ );
1275
+ const response = await session.fetch(`${url}path`);
1276
+ deepStrictEqual(response.status, 200);
1277
+ ok(!response.bodyUsed);
1278
+ await session.close();
1279
+ ok(response.bodyUsed);
1280
+ await rejects(() => session.fetch(url), TypeError);
1281
+ await session.close();
1282
+ deepStrictEqual(hits, 1);
1283
+ } finally {
1284
+ await closeServer(server);
1285
+ }
1286
+ });