@fedify/vocab-runtime 2.4.5-dev.2462 → 2.4.5

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-BMHljyYb.d.cts → docloader-DOWjnNCz.d.cts} +4 -8
  3. package/dist/{docloader-BMHljyYb.d.ts → docloader-DOWjnNCz.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-6Aj-7dZJ.d.cts → portable-CTnZWX7J.d.cts} +1 -1
  21. package/dist/{portable-nkTiqH0w.d.ts → portable-Cq-rhQgY.d.ts} +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-Dlnsb-QX.cjs → body-BO79kuv1.cjs} +1 -1
  25. package/dist/tests/{body-BXrFIcFa.mjs → body-CUzCsDZ4.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 +3 -3
  29. package/dist/tests/decimal.test.mjs +3 -3
  30. package/dist/tests/{docloader-c9BQWaF1.cjs → docloader-B79_YgeY.cjs} +111 -39
  31. package/dist/tests/{docloader-DsuF8YDf.mjs → docloader-C5ZHGEfO.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-BvS3AhZ2.cjs +89 -0
  43. package/dist/tests/node-Cg4pRuYy.mjs +84 -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-CJi9ZkYs.cjs +220 -0
  67. package/dist/tests/pinners-zSofbiy2.mjs +203 -0
  68. package/dist/tests/{portable-dereference-DmTYDy2p.mjs → portable-dereference-BHRE5Pkm.mjs} +2 -2
  69. package/dist/tests/{portable-dereference-qTLMqe2_.cjs → portable-dereference-mRHrC7PI.cjs} +2 -2
  70. package/dist/tests/{portable-media-M7idvaUq.mjs → portable-media-BpVpe1gj.mjs} +3 -3
  71. package/dist/tests/{portable-media-CS4czG6X.cjs → portable-media-C3yY0W5E.cjs} +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-DNkPoks1.mjs +170 -0
  77. package/dist/tests/public-fetch-U822jbKB.cjs +186 -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-C336eT6A.mjs → request-3AP-HBJ6.mjs} +1 -1
  83. package/dist/tests/{request-CgTgsBUA.cjs → request-BhopJTZd.cjs} +1 -1
  84. package/dist/tests/request.test.cjs +1 -1
  85. package/dist/tests/request.test.mjs +1 -1
  86. package/dist/tests/support-BmzI3dRJ.cjs +363 -0
  87. package/dist/tests/support-CiHDVJgh.mjs +272 -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,278 @@
1
+ import { test } from "@fedify/fixture";
2
+ import { deepStrictEqual, ok, rejects } from "node:assert";
3
+ import { readFileSync } from "node:fs";
4
+ import { createServer as createHttpsServer } from "node:https";
5
+ import { issuePinGrant } from "../pin-grant.ts";
6
+ import { createBunPinner } from "./bun.ts";
7
+ import {
8
+ allowAll,
9
+ closeServer,
10
+ getLoopbackSupport,
11
+ ipv4,
12
+ ipv6,
13
+ isBun,
14
+ listen,
15
+ resolveTestdata,
16
+ runChild,
17
+ startCountingServer,
18
+ startRecordingProxy,
19
+ startRecordingServer,
20
+ } from "./testdata/support.ts";
21
+
22
+ // The behaviors that the Bun pinner shares with the other pinners are tested
23
+ // in pinners.test.ts.
24
+
25
+ test("createBunPinner() falls back to the next address for GET and HEAD", {
26
+ ignore: !isBun,
27
+ }, async () => {
28
+ // Skipped where IPv6 is unavailable:
29
+ if (!(await getLoopbackSupport()).ipv6) return;
30
+ // The server listens only on 127.0.0.1, so the first address, ::1, refuses
31
+ // the connection:
32
+ const { server, port, requests } = await startRecordingServer();
33
+ try {
34
+ const pinner = createBunPinner(allowAll);
35
+ const url = new URL(`http://pinned-host.invalid:${port}/`);
36
+ const grant = issuePinGrant(url, [ipv6("::1"), ipv4("127.0.0.1")]);
37
+ const response = await pinner.fetch(grant, new Request(url));
38
+ deepStrictEqual(await response.text(), "pinned");
39
+ const head = await pinner.fetch(
40
+ grant,
41
+ new Request(url, { method: "HEAD" }),
42
+ );
43
+ deepStrictEqual(head.status, 200);
44
+ deepStrictEqual(
45
+ requests.map(({ method, localAddress }) => [method, localAddress]),
46
+ [["GET", "127.0.0.1"], ["HEAD", "127.0.0.1"]],
47
+ );
48
+ } finally {
49
+ await closeServer(server);
50
+ }
51
+ });
52
+
53
+ test("createBunPinner() does not fall back for POST", {
54
+ ignore: !isBun,
55
+ }, async () => {
56
+ // Skipped where IPv6 is unavailable:
57
+ if (!(await getLoopbackSupport()).ipv6) return;
58
+ // The first address, ::1, refuses the connection, and the second one
59
+ // would reach the server:
60
+ const { server, port, requests } = await startRecordingServer();
61
+ try {
62
+ const pinner = createBunPinner(allowAll);
63
+ const url = new URL(`http://pinned-host.invalid:${port}/`);
64
+ const grant = issuePinGrant(url, [ipv6("::1"), ipv4("127.0.0.1")]);
65
+ await rejects(() =>
66
+ pinner.fetch(
67
+ grant,
68
+ new Request(url, { method: "POST", body: "payload" }),
69
+ )
70
+ );
71
+ deepStrictEqual(requests, []);
72
+ } finally {
73
+ await closeServer(server);
74
+ }
75
+ });
76
+
77
+ interface ProxyChildOutput {
78
+ readonly version: string;
79
+ readonly http: { status?: number; body?: string; error?: string };
80
+ readonly https: { status?: number; body?: string; error?: string };
81
+ readonly control: { status?: number; body?: string; error?: string };
82
+ readonly warnings: readonly unknown[];
83
+ }
84
+
85
+ function runProxyChild(
86
+ proxyEnv: Record<string, string>,
87
+ httpPort: number,
88
+ httpsPort: number,
89
+ ): Promise<ProxyChildOutput> {
90
+ return runChild(
91
+ resolveTestdata("proxy-child.ts", import.meta.url),
92
+ [String(httpPort), String(httpsPort)],
93
+ proxyEnv,
94
+ );
95
+ }
96
+
97
+ interface BunGlobal {
98
+ readonly version: string;
99
+ readonly semver: { satisfies(version: string, range: string): boolean };
100
+ }
101
+
102
+ function getBun(): BunGlobal {
103
+ return (globalThis as unknown as { Bun: BunGlobal }).Bun;
104
+ }
105
+
106
+ /**
107
+ * Whether the current Bun refuses pinned requests to HTTPS servers on
108
+ * non-default ports, since it cannot verify their certificates against
109
+ * the original hostname.
110
+ */
111
+ function refusesNonDefaultHttpsPorts(): boolean {
112
+ const bun = getBun();
113
+ return bun.semver.satisfies(bun.version, "<1.3.11");
114
+ }
115
+
116
+ test("createBunPinner() bypasses environment proxies", {
117
+ ignore: !isBun,
118
+ }, async (t) => {
119
+ const bun = getBun();
120
+ const honorsProxyFalse = bun.semver.satisfies(bun.version, ">=1.4.3");
121
+ const refusesHttps = refusesNonDefaultHttpsPorts();
122
+ for (
123
+ const names of [
124
+ ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"],
125
+ ["ALL_PROXY"],
126
+ ] as const
127
+ ) {
128
+ await t.step(names.join(", "), async () => {
129
+ const proxy = await startRecordingProxy();
130
+ // The HTTP origin server:
131
+ const origin = await startRecordingServer();
132
+ // The HTTPS target, which only counts connections since it has no
133
+ // certificate:
134
+ const tls = await startCountingServer();
135
+ try {
136
+ const proxyUrl = `http://127.0.0.1:${proxy.port}`;
137
+ const output = await runProxyChild(
138
+ Object.fromEntries(names.map((name) => [name, proxyUrl])),
139
+ origin.port,
140
+ tls.port,
141
+ );
142
+ const targets = JSON.stringify(proxy.targets);
143
+ deepStrictEqual(output.version, bun.version);
144
+ // The pinned hostname never reaches the proxy as a target:
145
+ ok(
146
+ proxy.targets.every((target) => !target.includes("pinned-host")),
147
+ targets,
148
+ );
149
+ // The control request without proxy: false shows whether the proxy
150
+ // is in effect. Older Bun versions (e.g., 1.2.22) do not read
151
+ // ALL_PROXY, so it is not in effect with ALL_PROXY alone there:
152
+ const proxyInEffect = output.control.body === "via-proxy";
153
+ if (names.length > 1 || honorsProxyFalse || proxyInEffect) {
154
+ deepStrictEqual(output.control, { status: 200, body: "via-proxy" });
155
+ ok(
156
+ proxy.targets.includes(
157
+ `GET http://127.0.0.1:${origin.port}/control`,
158
+ ),
159
+ targets,
160
+ );
161
+ } else {
162
+ deepStrictEqual(output.control, { status: 200, body: "pinned" });
163
+ }
164
+ const pinnedTargets = proxy.targets.filter((target) =>
165
+ !target.endsWith("/control")
166
+ );
167
+ if (honorsProxyFalse || !proxyInEffect) {
168
+ // Pinned requests connect directly to the pinned address:
169
+ deepStrictEqual(pinnedTargets, [], targets);
170
+ deepStrictEqual(output.http, { status: 200, body: "pinned" });
171
+ deepStrictEqual(
172
+ origin.requests
173
+ .filter(({ url }) => url === "/pinned")
174
+ .map(({ host }) => host),
175
+ [`pinned-host.invalid:${origin.port}`],
176
+ );
177
+ deepStrictEqual(tls.counter.connections, refusesHttps ? 0 : 1);
178
+ } else {
179
+ // Older Bun versions ignore proxy: false, so pinned requests go
180
+ // through the proxy, but with the pinned address as the target:
181
+ deepStrictEqual(pinnedTargets, [
182
+ `GET http://127.0.0.1:${origin.port}/pinned`,
183
+ ...(refusesHttps ? [] : [`CONNECT 127.0.0.1:${tls.port}`]),
184
+ ]);
185
+ deepStrictEqual(output.http, { status: 200, body: "via-proxy" });
186
+ deepStrictEqual(origin.requests, []);
187
+ deepStrictEqual(tls.counter.connections, 0);
188
+ }
189
+ // The HTTPS target has no certificate, and Bun versions before 1.3.11
190
+ // refuse it before connecting since it is on a non-default port:
191
+ ok(
192
+ refusesHttps
193
+ ? output.https.error?.startsWith("TypeError")
194
+ : output.https.error != null,
195
+ JSON.stringify(output.https),
196
+ );
197
+ // Older Bun versions warn once whenever a proxy variable is set:
198
+ deepStrictEqual(output.warnings.length, honorsProxyFalse ? 0 : 1);
199
+ } finally {
200
+ await closeServer(proxy.server);
201
+ await closeServer(origin.server);
202
+ await closeServer(tls.server);
203
+ }
204
+ });
205
+ }
206
+ });
207
+
208
+ type TlsChildResult =
209
+ | { readonly case: string; readonly status: number; readonly body: string }
210
+ | { readonly case: string; readonly error: string };
211
+
212
+ interface TlsChildOutput {
213
+ readonly version: string;
214
+ readonly results: readonly TlsChildResult[];
215
+ }
216
+
217
+ test("createBunPinner() verifies certificates on non-default ports", {
218
+ ignore: !isBun,
219
+ }, async () => {
220
+ const bun = getBun();
221
+ // Bun versions before 1.3.11 cannot verify certificates against
222
+ // the original hostname on non-default ports, so the pinner refuses such
223
+ // requests there. (A custom checkServerIdentity fails only after these
224
+ // versions have sent the whole request, which the server would receive.)
225
+ const refuses = refusesNonDefaultHttpsPorts();
226
+ const certPath = resolveTestdata("tls-cert.pem", import.meta.url);
227
+ const hosts: string[] = [];
228
+ const server = createHttpsServer({
229
+ cert: readFileSync(certPath),
230
+ key: readFileSync(resolveTestdata("tls-key.pem", import.meta.url)),
231
+ }, (req, res) => {
232
+ hosts.push(req.headers.host ?? "");
233
+ res.end(req.headers.host);
234
+ });
235
+ const port = await listen(server);
236
+ try {
237
+ // tls-cert.pem is self-signed for pinned-host.invalid and
238
+ // alternate-host.invalid, and is trusted only by the child process:
239
+ const output = await runChild<TlsChildOutput>(
240
+ resolveTestdata("tls-child.ts", import.meta.url),
241
+ [String(port)],
242
+ { NODE_EXTRA_CA_CERTS: certPath },
243
+ );
244
+ deepStrictEqual(output.version, bun.version);
245
+ const json = JSON.stringify(output.results);
246
+ if (refuses) {
247
+ ok(
248
+ output.results.every((result) =>
249
+ "error" in result && result.error.startsWith("TypeError")
250
+ ),
251
+ json,
252
+ );
253
+ deepStrictEqual(hosts, []);
254
+ return;
255
+ }
256
+ deepStrictEqual(output.results, [
257
+ { case: "pinned", status: 200, body: `pinned-host.invalid:${port}` },
258
+ // A connection that failed the check must not be reused either:
259
+ { case: "unlisted", error: "ERR_TLS_CERT_ALTNAME_INVALID" },
260
+ { case: "unlisted-again", error: "ERR_TLS_CERT_ALTNAME_INVALID" },
261
+ {
262
+ case: "alternate",
263
+ status: 200,
264
+ body: `alternate-host.invalid:${port}`,
265
+ },
266
+ { case: "signals", status: 200, body: `pinned-host.invalid:${port}` },
267
+ ], json);
268
+ // No request reached the server for the hostname the certificate does
269
+ // not cover:
270
+ deepStrictEqual(hosts, [
271
+ `pinned-host.invalid:${port}`,
272
+ `alternate-host.invalid:${port}`,
273
+ `pinned-host.invalid:${port}`,
274
+ ]);
275
+ } finally {
276
+ await closeServer(server);
277
+ }
278
+ });
@@ -0,0 +1,223 @@
1
+ import { getLogger } from "@logtape/logtape";
2
+ import { isIP } from "node:net";
3
+ import { type PinGrant, redeemPinGrant } from "../pin-grant.ts";
4
+ import type { ResolvedAddress } from "../url.ts";
5
+ import type { ConnectionPinner, ConnectionPinnerOptions } from "./types.ts";
6
+
7
+ const logger = getLogger(["fedify", "runtime", "fetch"]);
8
+
9
+ /**
10
+ * The first Bun version that honors `proxy: false` when proxy environment
11
+ * variables are set at startup. Earlier versions send pinned requests
12
+ * through the proxy, with the validated IP address as the target.
13
+ */
14
+ const PROXY_FALSE_MIN_VERSION = "1.4.3";
15
+
16
+ const PROXY_VARIABLES = [
17
+ "HTTP_PROXY",
18
+ "http_proxy",
19
+ "HTTPS_PROXY",
20
+ "https_proxy",
21
+ "ALL_PROXY",
22
+ "all_proxy",
23
+ ] as const;
24
+
25
+ // Transport errors that Bun's fetch() raises before a response arrives.
26
+ // Only GET and HEAD requests fall back to the next address on these.
27
+ const FALLBACK_ERROR_CODES: ReadonlySet<string> = new Set([
28
+ "ConnectionRefused",
29
+ "ECONNREFUSED",
30
+ "EHOSTUNREACH",
31
+ "ENETUNREACH",
32
+ "ETIMEDOUT",
33
+ ]);
34
+
35
+ /**
36
+ * Bun versions before 1.3.11 verify the server certificate against the `Host`
37
+ * header, including a non-default port, instead of `tls.serverName`, so
38
+ * pinned requests to HTTPS servers on non-default ports always fail the check
39
+ * there. A custom `checkServerIdentity` cannot stand in for it: these
40
+ * versions send the whole request before a failing custom check aborts it
41
+ * (and 1.3.10 even reuses the connection for the next request), so such
42
+ * requests are refused instead.
43
+ */
44
+ const HOST_HEADER_IDENTITY_RANGE = "<1.3.11";
45
+
46
+ interface BunGlobal {
47
+ readonly version: string;
48
+ readonly semver: { satisfies(version: string, range: string): boolean };
49
+ }
50
+
51
+ interface BunFetchInit extends RequestInit {
52
+ tls?: { serverName?: string };
53
+ proxy?: string | false;
54
+ }
55
+
56
+ /**
57
+ * Creates a pinner for Bun. Every request is sent to an IP literal URL with
58
+ * the original `Host` header, `tls.serverName` set to the original hostname
59
+ * (so that the certificate is still verified against it), and `proxy: false`.
60
+ *
61
+ * GET and HEAD requests fall back to the next address on transport errors
62
+ * raised before a response arrives. Other methods use only the first
63
+ * address, since a transport error does not guarantee that the request did
64
+ * not reach the server, and retrying it could deliver an activity twice.
65
+ * Together with the transport retries of `doubleKnock()`, a GET request is
66
+ * attempted at most twice per address. Note that Bun 1.4 also reports
67
+ * a connection reset after the request was sent as a `TypeError`, so GET and
68
+ * HEAD requests may be repeated against the next address in that case, which
69
+ * is acceptable for these idempotent methods.
70
+ *
71
+ * Bun versions before 1.3.11 verify certificates against the `Host` header,
72
+ * which includes a non-default port, and cannot safely check them against
73
+ * the original hostname in any other way, so requests to HTTPS servers on
74
+ * non-default ports fail there with a `TypeError` before connecting.
75
+ *
76
+ * Bun versions before 1.4.3 ignore `proxy: false` when proxy environment
77
+ * variables are set, so requests go through the proxy with the validated IP
78
+ * address as the target. In that case a warning is logged once.
79
+ * @param options The pinner options.
80
+ * @returns The pinner.
81
+ */
82
+ export function createBunPinner(
83
+ { verifyAddress }: ConnectionPinnerOptions,
84
+ ): ConnectionPinner {
85
+ // Older Bun versions read proxy variables only at startup, so they are
86
+ // checked once when the pinner is created:
87
+ let proxyWarningPending = ignoresProxyFalse() && hasProxyVariables();
88
+ return {
89
+ supports(url: URL): boolean {
90
+ return url.protocol === "http:" || url.protocol === "https:";
91
+ },
92
+
93
+ async fetch(
94
+ grant: PinGrant,
95
+ request: Request,
96
+ signal?: AbortSignal,
97
+ ): Promise<Response> {
98
+ const url = new URL(request.url);
99
+ const { hostname, addresses } = redeemPinGrant(grant, url);
100
+ if (proxyWarningPending) {
101
+ proxyWarningPending = false;
102
+ logger.warn(
103
+ "Bun {version} ignores proxy: false while proxy environment " +
104
+ "variables are set, so outbound requests go through the proxy " +
105
+ "instead of connecting directly to the validated addresses. " +
106
+ "Upgrade to Bun {minVersion} or later, or unset the proxy " +
107
+ "environment variables; see {url}.",
108
+ {
109
+ version: getBun()?.version,
110
+ minVersion: PROXY_FALSE_MIN_VERSION,
111
+ url: "https://fedify.dev/manual/deploy#bun-behind-an-http-proxy",
112
+ },
113
+ );
114
+ }
115
+ const fallback = request.method === "GET" || request.method === "HEAD";
116
+ const candidates = fallback ? addresses : addresses.slice(0, 1);
117
+ const body = request.body == null ? null : await request.arrayBuffer();
118
+ let lastError: unknown = new TypeError(
119
+ `No address to connect to for ${url.host}.`,
120
+ );
121
+ for (const address of candidates) {
122
+ throwIfAborted(signal, request.signal);
123
+ // Never fall back past an address that fails the check:
124
+ verifyAddress(address);
125
+ const headers = new Headers(request.headers);
126
+ headers.set("Host", url.host);
127
+ const init: BunFetchInit = {
128
+ method: request.method,
129
+ headers,
130
+ body,
131
+ redirect: "manual",
132
+ signal: signal == null || signal === request.signal
133
+ ? request.signal
134
+ : anySignal(signal, request.signal),
135
+ proxy: false,
136
+ };
137
+ if (url.protocol === "https:") {
138
+ init.tls = getTlsOptions(url, hostname);
139
+ }
140
+ try {
141
+ return await fetch(toAddressUrl(url, address), init);
142
+ } catch (error) {
143
+ throwIfAborted(signal, request.signal);
144
+ if (!fallback || !isFallbackError(error)) throw error;
145
+ lastError = error;
146
+ }
147
+ }
148
+ throw lastError;
149
+ },
150
+
151
+ release(): void {
152
+ },
153
+ };
154
+ }
155
+
156
+ function getTlsOptions(url: URL, hostname: string): BunFetchInit["tls"] {
157
+ const bun = getBun();
158
+ if (
159
+ url.port !== "" && bun != null &&
160
+ bun.semver.satisfies(bun.version, HOST_HEADER_IDENTITY_RANGE)
161
+ ) {
162
+ throw new TypeError(
163
+ `Bun ${bun.version} cannot verify the certificate of ${url.host} ` +
164
+ "when connecting to a validated address; upgrade to Bun 1.3.11 or " +
165
+ "later to fetch from HTTPS servers on non-default ports.",
166
+ );
167
+ }
168
+ return isIP(hostname) === 0 ? { serverName: hostname } : {};
169
+ }
170
+
171
+ /**
172
+ * Combines two signals without `AbortSignal.any()`, which Bun 1.1.0 lacks.
173
+ */
174
+ function anySignal(a: AbortSignal, b: AbortSignal): AbortSignal {
175
+ if (a.aborted) return a;
176
+ if (b.aborted) return b;
177
+ const controller = new AbortController();
178
+ const abort = (event: Event) => {
179
+ controller.abort((event.target as AbortSignal).reason);
180
+ a.removeEventListener("abort", abort);
181
+ b.removeEventListener("abort", abort);
182
+ };
183
+ a.addEventListener("abort", abort);
184
+ b.addEventListener("abort", abort);
185
+ return controller.signal;
186
+ }
187
+
188
+ function toAddressUrl(url: URL, address: ResolvedAddress): URL {
189
+ const result = new URL(url);
190
+ result.hostname = address.family === 6
191
+ ? `[${address.address}]`
192
+ : address.address;
193
+ return result;
194
+ }
195
+
196
+ function isFallbackError(error: unknown): boolean {
197
+ if (error instanceof Error && error.name === "AbortError") return false;
198
+ if (error instanceof TypeError) return true;
199
+ const code = (error as { code?: unknown } | null)?.code;
200
+ return typeof code === "string" && FALLBACK_ERROR_CODES.has(code);
201
+ }
202
+
203
+ function throwIfAborted(...signals: (AbortSignal | undefined)[]): void {
204
+ for (const signal of signals) signal?.throwIfAborted();
205
+ }
206
+
207
+ function getBun(): BunGlobal | undefined {
208
+ return (globalThis as { Bun?: BunGlobal }).Bun;
209
+ }
210
+
211
+ function ignoresProxyFalse(): boolean {
212
+ const bun = getBun();
213
+ if (bun == null) return false;
214
+ return !bun.semver.satisfies(bun.version, `>=${PROXY_FALSE_MIN_VERSION}`);
215
+ }
216
+
217
+ function hasProxyVariables(): boolean {
218
+ const env = (globalThis as {
219
+ process?: { env?: Record<string, string | undefined> };
220
+ }).process?.env;
221
+ if (env == null) return false;
222
+ return PROXY_VARIABLES.some((name) => (env[name] ?? "") !== "");
223
+ }
@@ -0,0 +1,59 @@
1
+ import { assertPublicAddress } from "../url.ts";
2
+ import { createBunPinner } from "./bun.ts";
3
+ import { createNodePinner } from "./node.ts";
4
+ import type { ConnectionPinner } from "./types.ts";
5
+
6
+ export type { ConnectionPinner, ConnectionPinnerOptions } from "./types.ts";
7
+
8
+ /**
9
+ * The JavaScript runtimes that are told apart for connection pinning.
10
+ */
11
+ export type Runtime = "cfworkers" | "deno" | "bun" | "node" | "other";
12
+
13
+ /**
14
+ * Detects the current runtime. Cloudflare Workers is checked first, since
15
+ * `process` can exist there with `nodejs_compat`, and Deno and Bun before
16
+ * Node.js, since both provide `process.versions.node`.
17
+ * @returns The current runtime.
18
+ */
19
+ export function detectRuntime(): Runtime {
20
+ const g = globalThis as {
21
+ navigator?: { userAgent?: string };
22
+ process?: { versions?: { node?: string } };
23
+ };
24
+ if (g.navigator?.userAgent === "Cloudflare-Workers") return "cfworkers";
25
+ if ("Deno" in globalThis) return "deno";
26
+ if ("Bun" in globalThis) return "bun";
27
+ if (g.process?.versions?.node != null) return "node";
28
+ return "other";
29
+ }
30
+
31
+ let runtimePinner: Promise<ConnectionPinner | null> | undefined;
32
+
33
+ /**
34
+ * Gets the production pinner of the current runtime, which rejects
35
+ * non-public addresses right before connecting. Deno and Cloudflare Workers
36
+ * have no pinner.
37
+ * @returns The pinner, or `null` if the runtime has none.
38
+ */
39
+ export function getRuntimePinner(): Promise<ConnectionPinner | null> {
40
+ runtimePinner ??= createRuntimePinner(detectRuntime());
41
+ return runtimePinner;
42
+ }
43
+
44
+ async function createRuntimePinner(
45
+ runtime: Runtime,
46
+ ): Promise<ConnectionPinner | null> {
47
+ switch (runtime) {
48
+ case "bun":
49
+ return createBunPinner({ verifyAddress: assertPublicAddress });
50
+ case "node":
51
+ // The npm undici package is loaded only on Node.js:
52
+ return createNodePinner({
53
+ verifyAddress: assertPublicAddress,
54
+ undici: await import("undici"),
55
+ });
56
+ default:
57
+ return null;
58
+ }
59
+ }