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