@fedify/vocab-runtime 2.4.5-dev.2462 → 2.4.6-dev.2628
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-BMHljyYb.d.cts → docloader-DOWjnNCz.d.cts} +4 -8
- package/dist/{docloader-BMHljyYb.d.ts → docloader-DOWjnNCz.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 +1 -1
- package/dist/internal/portable-dereference.d.cts +2 -2
- package/dist/internal/portable-dereference.d.ts +2 -2
- package/dist/internal/portable-dereference.js +1 -1
- package/dist/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-6Aj-7dZJ.d.cts → portable-CTnZWX7J.d.cts} +1 -1
- package/dist/{portable-nkTiqH0w.d.ts → portable-Cq-rhQgY.d.ts} +1 -1
- package/dist/public-fetch-Dvfvawei.cjs +527 -0
- package/dist/public-fetch-ziZ39L5I.js +510 -0
- package/dist/tests/{body-Dlnsb-QX.cjs → body-1uyjqhY5.cjs} +1 -1
- package/dist/tests/{body-BXrFIcFa.mjs → body-BIuoyur4.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 +3 -3
- package/dist/tests/decimal.test.mjs +3 -3
- package/dist/tests/{docloader-DsuF8YDf.mjs → docloader-CmylAvrm.mjs} +106 -40
- package/dist/tests/{docloader-c9BQWaF1.cjs → docloader-DGDSOOhc.cjs} +111 -39
- 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-BvS3AhZ2.cjs +89 -0
- package/dist/tests/node-Cg4pRuYy.mjs +84 -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-CJi9ZkYs.cjs +220 -0
- package/dist/tests/pinners-zSofbiy2.mjs +203 -0
- package/dist/tests/{portable-dereference-DmTYDy2p.mjs → portable-dereference-BHRE5Pkm.mjs} +2 -2
- package/dist/tests/{portable-dereference-qTLMqe2_.cjs → portable-dereference-mRHrC7PI.cjs} +2 -2
- package/dist/tests/{portable-media-M7idvaUq.mjs → portable-media-B83gylai.mjs} +3 -3
- package/dist/tests/{portable-media-CS4czG6X.cjs → portable-media-DyuL0sR_.cjs} +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-DNkPoks1.mjs +170 -0
- package/dist/tests/public-fetch-U822jbKB.cjs +186 -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-CgTgsBUA.cjs → request-BDe7VfMX.cjs} +1 -1
- package/dist/tests/{request-C336eT6A.mjs → request-Bj4ZeRfh.mjs} +1 -1
- package/dist/tests/request.test.cjs +1 -1
- package/dist/tests/request.test.mjs +1 -1
- package/dist/tests/support-BmzI3dRJ.cjs +363 -0
- package/dist/tests/support-CiHDVJgh.mjs +272 -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.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
|
@@ -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
|
+
}
|