@fedify/vocab-runtime 2.4.0-dev.2169 → 2.4.0-dev.2193
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 +1 -1
- package/dist/{docloader-ClQraSWr.d.cts → docloader-C63enuyr.d.cts} +53 -1
- package/dist/{docloader-ClQraSWr.d.ts → docloader-C63enuyr.d.ts} +53 -1
- 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/mod.cjs +206 -17
- package/dist/mod.d.cts +7 -3
- package/dist/mod.d.ts +7 -3
- package/dist/mod.js +205 -18
- package/dist/{portable-ilkFPcXa.d.cts → portable-Cux3IA20.d.cts} +1 -1
- package/dist/{portable-DRtvOxTb.d.ts → portable-DBA1FS_L.d.ts} +1 -1
- package/dist/tests/{body-CNwDKYQs.mjs → body-D1z0X88T.mjs} +55 -2
- package/dist/tests/{body-DHAC8qhR.cjs → body-ORlui-Nx.cjs} +60 -1
- package/dist/tests/body.test.cjs +1 -1
- package/dist/tests/body.test.mjs +1 -1
- package/dist/tests/decimal.test.cjs +2 -2
- package/dist/tests/decimal.test.mjs +2 -2
- package/dist/tests/{docloader-DWkl3u7X.mjs → docloader-CUdtpDuf.mjs} +153 -19
- package/dist/tests/{docloader-DLkb9qAS.cjs → docloader-Vc3BC9ad.cjs} +164 -18
- package/dist/tests/docloader.test.cjs +266 -3
- package/dist/tests/docloader.test.mjs +267 -4
- package/dist/tests/internal/portable-dereference.test.cjs +2 -2
- package/dist/tests/internal/portable-dereference.test.mjs +2 -2
- package/dist/tests/{jsonld-cache-BO-QXxIc.cjs → jsonld-cache-BQMn_ILF.cjs} +1 -1
- package/dist/tests/{jsonld-cache-B7PKo3Lu.mjs → jsonld-cache-DUmuWRwp.mjs} +1 -1
- package/dist/tests/jsonld-cache.test.cjs +2 -2
- package/dist/tests/jsonld-cache.test.mjs +2 -2
- package/dist/tests/{request-Dm3ye46t.cjs → request-C5_3S-un.cjs} +1 -1
- package/dist/tests/{request-Cg8MQByW.mjs → request-IIntZ5kj.mjs} +1 -1
- package/dist/tests/request.test.cjs +1 -1
- package/dist/tests/request.test.mjs +1 -1
- package/dist/tests/{url-gLKqAO1H.cjs → url-B8xyTwP-.cjs} +5 -1
- package/dist/tests/{url-D7qRF-dF.mjs → url-DfS-cvwr.mjs} +5 -1
- package/dist/tests/url.test.cjs +2 -2
- package/dist/tests/url.test.mjs +2 -2
- package/dist/{url-BHZYe8Vu.js → url-Be3moMor.js} +5 -1
- package/dist/{url-P6l9s9dN.cjs → url-DDVweCXr.cjs} +5 -1
- package/package.json +1 -1
- package/src/body.ts +57 -0
- package/src/docloader.test.ts +341 -3
- package/src/docloader.ts +205 -5
- package/src/mod.ts +2 -0
- package/src/url.test.ts +7 -1
- package/src/url.ts +6 -2
|
@@ -224,10 +224,14 @@ function isGatewayUrl(url) {
|
|
|
224
224
|
}
|
|
225
225
|
/**
|
|
226
226
|
* Parses and validates an FEP-ef61 gateway base URI.
|
|
227
|
+
* @throws {TypeError} If the URI is malformed, or is not an HTTP(S) base URI
|
|
228
|
+
* with no credentials, path, query, or fragment. In the
|
|
229
|
+
* latter case, the message starts with
|
|
230
|
+
* `Invalid FEP-ef61 gateway:`.
|
|
227
231
|
*/
|
|
228
232
|
function parseGatewayUrl(url) {
|
|
229
233
|
const parsed = parseIri(url);
|
|
230
|
-
if (!isGatewayUrl(parsed)) throw new TypeError(
|
|
234
|
+
if (!isGatewayUrl(parsed)) throw new TypeError(`Invalid FEP-ef61 gateway: ${url}. FEP-ef61 gateways must be HTTP(S) base URIs with no credentials, path, query, or fragment.`);
|
|
231
235
|
return parsed;
|
|
232
236
|
}
|
|
233
237
|
const COMPATIBLE_ID_PATH_PREFIX = "/.well-known/apgateway/";
|
package/dist/tests/url.test.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const require_url = require("./url-
|
|
1
|
+
const require_url = require("./url-B8xyTwP-.cjs");
|
|
2
2
|
let node_test = require("node:test");
|
|
3
3
|
let node_assert = require("node:assert");
|
|
4
4
|
//#region src/url.test.ts
|
|
@@ -240,7 +240,7 @@ let node_assert = require("node:assert");
|
|
|
240
240
|
"https://server.example/?x=1",
|
|
241
241
|
"https://server.example/#fragment"
|
|
242
242
|
]) {
|
|
243
|
-
(0, node_assert.throws)(() => require_url.parseGatewayUrl(url), TypeError);
|
|
243
|
+
(0, node_assert.throws)(() => require_url.parseGatewayUrl(url), (error) => error instanceof TypeError && error.message.startsWith("Invalid FEP-ef61 gateway: "));
|
|
244
244
|
(0, node_assert.ok)(!require_url.isGatewayUrl(new URL(url)));
|
|
245
245
|
}
|
|
246
246
|
});
|
package/dist/tests/url.test.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as validateLookupAddresses, a as formatIri, c as haveSameFe34Origin, d as isValidPublicIPv4Address, f as isValidPublicIPv6Address, g as toCompatibleEf61Id, h as parseJsonLdId, i as expandIPv6Address, l as haveSameIriOrigin, m as parseIri, n as arePortableUrisEqual, o as fromCompatibleEf61Id, p as parseGatewayUrl, r as canonicalizePortableUri, s as getFe34Origin, t as UrlError, u as isGatewayUrl, v as validatePublicUrl } from "./url-
|
|
1
|
+
import { _ as validateLookupAddresses, a as formatIri, c as haveSameFe34Origin, d as isValidPublicIPv4Address, f as isValidPublicIPv6Address, g as toCompatibleEf61Id, h as parseJsonLdId, i as expandIPv6Address, l as haveSameIriOrigin, m as parseIri, n as arePortableUrisEqual, o as fromCompatibleEf61Id, p as parseGatewayUrl, r as canonicalizePortableUri, s as getFe34Origin, t as UrlError, u as isGatewayUrl, v as validatePublicUrl } from "./url-DfS-cvwr.mjs";
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import { deepStrictEqual, ok, rejects, strictEqual, throws } from "node:assert";
|
|
4
4
|
//#region src/url.test.ts
|
|
@@ -240,7 +240,7 @@ test("parseGatewayUrl() accepts only HTTP(S) base URIs", () => {
|
|
|
240
240
|
"https://server.example/?x=1",
|
|
241
241
|
"https://server.example/#fragment"
|
|
242
242
|
]) {
|
|
243
|
-
throws(() => parseGatewayUrl(url), TypeError);
|
|
243
|
+
throws(() => parseGatewayUrl(url), (error) => error instanceof TypeError && error.message.startsWith("Invalid FEP-ef61 gateway: "));
|
|
244
244
|
ok(!isGatewayUrl(new URL(url)));
|
|
245
245
|
}
|
|
246
246
|
});
|
|
@@ -225,10 +225,14 @@ function isGatewayUrl(url) {
|
|
|
225
225
|
}
|
|
226
226
|
/**
|
|
227
227
|
* Parses and validates an FEP-ef61 gateway base URI.
|
|
228
|
+
* @throws {TypeError} If the URI is malformed, or is not an HTTP(S) base URI
|
|
229
|
+
* with no credentials, path, query, or fragment. In the
|
|
230
|
+
* latter case, the message starts with
|
|
231
|
+
* `Invalid FEP-ef61 gateway:`.
|
|
228
232
|
*/
|
|
229
233
|
function parseGatewayUrl(url) {
|
|
230
234
|
const parsed = parseIri(url);
|
|
231
|
-
if (!isGatewayUrl(parsed)) throw new TypeError(
|
|
235
|
+
if (!isGatewayUrl(parsed)) throw new TypeError(`Invalid FEP-ef61 gateway: ${url}. FEP-ef61 gateways must be HTTP(S) base URIs with no credentials, path, query, or fragment.`);
|
|
232
236
|
return parsed;
|
|
233
237
|
}
|
|
234
238
|
const COMPATIBLE_ID_PATH_PREFIX = "/.well-known/apgateway/";
|
|
@@ -227,10 +227,14 @@ function isGatewayUrl(url) {
|
|
|
227
227
|
}
|
|
228
228
|
/**
|
|
229
229
|
* Parses and validates an FEP-ef61 gateway base URI.
|
|
230
|
+
* @throws {TypeError} If the URI is malformed, or is not an HTTP(S) base URI
|
|
231
|
+
* with no credentials, path, query, or fragment. In the
|
|
232
|
+
* latter case, the message starts with
|
|
233
|
+
* `Invalid FEP-ef61 gateway:`.
|
|
230
234
|
*/
|
|
231
235
|
function parseGatewayUrl(url) {
|
|
232
236
|
const parsed = parseIri(url);
|
|
233
|
-
if (!isGatewayUrl(parsed)) throw new TypeError(
|
|
237
|
+
if (!isGatewayUrl(parsed)) throw new TypeError(`Invalid FEP-ef61 gateway: ${url}. FEP-ef61 gateways must be HTTP(S) base URIs with no credentials, path, query, or fragment.`);
|
|
234
238
|
return parsed;
|
|
235
239
|
}
|
|
236
240
|
const COMPATIBLE_ID_PATH_PREFIX = "/.well-known/apgateway/";
|
package/package.json
CHANGED
package/src/body.ts
CHANGED
|
@@ -93,3 +93,60 @@ export async function readBoundedText(
|
|
|
93
93
|
reader?.releaseLock();
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reads raw bytes while limiting bytes received from the body stream.
|
|
99
|
+
* Unlike {@link readBoundedText}, the bytes are returned as they were
|
|
100
|
+
* received, without decoding.
|
|
101
|
+
* @param message The response or request to read.
|
|
102
|
+
* @param maxBytes The maximum number of decoded bytes to read.
|
|
103
|
+
* @param url The URL to include in errors and logs.
|
|
104
|
+
* @returns The body bytes.
|
|
105
|
+
* @throws {BodyTooLargeError} If the body exceeds the limit.
|
|
106
|
+
* @internal
|
|
107
|
+
*/
|
|
108
|
+
export async function readBoundedBytes(
|
|
109
|
+
message: Pick<Request, "body" | "headers">,
|
|
110
|
+
maxBytes: number,
|
|
111
|
+
url: string | URL,
|
|
112
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
113
|
+
validateBodySizeLimit(maxBytes);
|
|
114
|
+
const reader = message.body?.getReader();
|
|
115
|
+
const tooLarge = (): never => {
|
|
116
|
+
getLogger(["fedify", "runtime", "body"]).warn(
|
|
117
|
+
"Body from {url} exceeds the limit of {maxBytes} bytes.",
|
|
118
|
+
{ url: url.toString(), maxBytes },
|
|
119
|
+
);
|
|
120
|
+
throw new BodyTooLargeError(url, maxBytes);
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
const length = message.headers.get("Content-Length");
|
|
124
|
+
const encoding = message.headers.get("Content-Encoding");
|
|
125
|
+
if (
|
|
126
|
+
(encoding == null || encoding.toLowerCase() === "identity") &&
|
|
127
|
+
length != null && /^\d+$/.test(length) && Number(length) > maxBytes
|
|
128
|
+
) tooLarge();
|
|
129
|
+
if (reader == null) return new Uint8Array(0);
|
|
130
|
+
const chunks: Uint8Array[] = [];
|
|
131
|
+
let size = 0;
|
|
132
|
+
while (true) {
|
|
133
|
+
const { done, value } = await reader.read();
|
|
134
|
+
if (done) break;
|
|
135
|
+
size += value.byteLength;
|
|
136
|
+
if (size > maxBytes) tooLarge();
|
|
137
|
+
chunks.push(value);
|
|
138
|
+
}
|
|
139
|
+
const bytes = new Uint8Array(size);
|
|
140
|
+
let offset = 0;
|
|
141
|
+
for (const chunk of chunks) {
|
|
142
|
+
bytes.set(chunk, offset);
|
|
143
|
+
offset += chunk.byteLength;
|
|
144
|
+
}
|
|
145
|
+
return bytes;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (reader != null) void reader.cancel(error).catch(() => {});
|
|
148
|
+
throw error;
|
|
149
|
+
} finally {
|
|
150
|
+
reader?.releaseLock();
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/docloader.test.ts
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { configure, type LogRecord, reset } from "@logtape/logtape";
|
|
2
2
|
import fetchMock from "fetch-mock";
|
|
3
|
-
import { deepStrictEqual, ok, rejects } from "node:assert";
|
|
3
|
+
import { deepStrictEqual, ok, rejects, throws } from "node:assert";
|
|
4
4
|
import dns from "node:dns/promises";
|
|
5
5
|
import { test } from "node:test";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
createServer,
|
|
8
|
+
type IncomingMessage,
|
|
9
|
+
type ServerResponse,
|
|
10
|
+
} from "node:http";
|
|
7
11
|
import { gzipSync } from "node:zlib";
|
|
8
12
|
import preloadedContexts from "./contexts.ts";
|
|
9
13
|
import cidV1Context from "./contexts/cid-v1.json" with { type: "json" };
|
|
10
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
getDocumentLoader,
|
|
16
|
+
getRemoteDocument,
|
|
17
|
+
resolveDocumentLoaderTimeout,
|
|
18
|
+
withDocumentLoaderTimeout,
|
|
19
|
+
} from "./docloader.ts";
|
|
11
20
|
import { FetchError } from "./request.ts";
|
|
12
21
|
import { UrlError } from "./url.ts";
|
|
13
22
|
|
|
@@ -1118,3 +1127,332 @@ test("getDocumentLoader() rejects cancellation before fetching", async () => {
|
|
|
1118
1127
|
fetchMock.hardReset();
|
|
1119
1128
|
}
|
|
1120
1129
|
});
|
|
1130
|
+
|
|
1131
|
+
async function withServer<T>(
|
|
1132
|
+
handler: (request: IncomingMessage, response: ServerResponse) => void,
|
|
1133
|
+
callback: (baseUrl: string) => Promise<T>,
|
|
1134
|
+
): Promise<T> {
|
|
1135
|
+
const server = createServer(handler);
|
|
1136
|
+
await new Promise<void>((resolve, reject) => {
|
|
1137
|
+
server.once("error", reject);
|
|
1138
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
1139
|
+
});
|
|
1140
|
+
try {
|
|
1141
|
+
const address = server.address();
|
|
1142
|
+
ok(address != null && typeof address !== "string");
|
|
1143
|
+
return await callback(`http://127.0.0.1:${address.port}`);
|
|
1144
|
+
} finally {
|
|
1145
|
+
await new Promise<void>((resolve, reject) => {
|
|
1146
|
+
server.close((error) => error == null ? resolve() : reject(error));
|
|
1147
|
+
server.closeAllConnections();
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function isTimeoutFetchError(error: unknown, url: string): boolean {
|
|
1153
|
+
ok(error instanceof FetchError, String(error));
|
|
1154
|
+
deepStrictEqual(error.url.href, url);
|
|
1155
|
+
deepStrictEqual(error.response, undefined);
|
|
1156
|
+
ok(error.cause instanceof DOMException);
|
|
1157
|
+
deepStrictEqual(error.cause.name, "TimeoutError");
|
|
1158
|
+
return true;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
test("resolveDocumentLoaderTimeout() validates timeouts", () => {
|
|
1162
|
+
deepStrictEqual(resolveDocumentLoaderTimeout(undefined), 10_000);
|
|
1163
|
+
deepStrictEqual(resolveDocumentLoaderTimeout(null), null);
|
|
1164
|
+
deepStrictEqual(resolveDocumentLoaderTimeout(1500), 1500);
|
|
1165
|
+
deepStrictEqual(resolveDocumentLoaderTimeout(0.5), 1);
|
|
1166
|
+
deepStrictEqual(resolveDocumentLoaderTimeout(2_147_483_647), 2_147_483_647);
|
|
1167
|
+
for (const invalid of [0, -1, NaN, Infinity, 2_147_483_648]) {
|
|
1168
|
+
throws(() => resolveDocumentLoaderTimeout(invalid), RangeError);
|
|
1169
|
+
}
|
|
1170
|
+
throws(() => getDocumentLoader({ timeout: 0 }), RangeError);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
test("getDocumentLoader() times out a request without a response", async () => {
|
|
1174
|
+
await withServer(() => {
|
|
1175
|
+
// Never respond.
|
|
1176
|
+
}, async (baseUrl) => {
|
|
1177
|
+
const url = `${baseUrl}/hang`;
|
|
1178
|
+
const loader = getDocumentLoader({
|
|
1179
|
+
allowPrivateAddress: true,
|
|
1180
|
+
timeout: 100,
|
|
1181
|
+
});
|
|
1182
|
+
await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
|
|
1183
|
+
});
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
test("getDocumentLoader() times out a stalled response body", async () => {
|
|
1187
|
+
for (const status of [200, 404]) {
|
|
1188
|
+
await withServer((_, response) => {
|
|
1189
|
+
response.writeHead(status, {
|
|
1190
|
+
"Content-Type": "application/activity+json",
|
|
1191
|
+
});
|
|
1192
|
+
response.write('{"id":');
|
|
1193
|
+
// Never finish the body.
|
|
1194
|
+
}, async (baseUrl) => {
|
|
1195
|
+
const url = `${baseUrl}/stalled`;
|
|
1196
|
+
const loader = getDocumentLoader({
|
|
1197
|
+
allowPrivateAddress: true,
|
|
1198
|
+
timeout: 200,
|
|
1199
|
+
});
|
|
1200
|
+
await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
test("getDocumentLoader() shares the timeout across redirects", async () => {
|
|
1206
|
+
let requests = 0;
|
|
1207
|
+
await withServer((request, response) => {
|
|
1208
|
+
requests++;
|
|
1209
|
+
const index = Number(request.url?.split("/").at(-1));
|
|
1210
|
+
setTimeout(() => {
|
|
1211
|
+
if (index >= 15) {
|
|
1212
|
+
response.writeHead(200, {
|
|
1213
|
+
"Content-Type": "application/activity+json",
|
|
1214
|
+
});
|
|
1215
|
+
response.end('{"done":true}');
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
response.writeHead(302, { Location: `/redirect/${index + 1}` });
|
|
1219
|
+
response.end();
|
|
1220
|
+
}, 50);
|
|
1221
|
+
}, async (baseUrl) => {
|
|
1222
|
+
const url = `${baseUrl}/redirect/0`;
|
|
1223
|
+
// Each hop is well within the timeout, but the whole chain is not:
|
|
1224
|
+
const loader = getDocumentLoader({
|
|
1225
|
+
allowPrivateAddress: true,
|
|
1226
|
+
timeout: 300,
|
|
1227
|
+
});
|
|
1228
|
+
await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
|
|
1229
|
+
ok(requests < 16, `requests: ${requests}`);
|
|
1230
|
+
// Without a timeout, the chain completes:
|
|
1231
|
+
const unbounded = getDocumentLoader({
|
|
1232
|
+
allowPrivateAddress: true,
|
|
1233
|
+
timeout: null,
|
|
1234
|
+
});
|
|
1235
|
+
deepStrictEqual((await unbounded(url)).document, { done: true });
|
|
1236
|
+
});
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
test("getDocumentLoader() shares the timeout across alternates", async (t) => {
|
|
1240
|
+
for (const html of [false, true]) {
|
|
1241
|
+
await t.test(html ? "HTML" : "Link header", async () => {
|
|
1242
|
+
await withServer((request, response) => {
|
|
1243
|
+
const index = Number(request.url?.split("/").at(-1));
|
|
1244
|
+
setTimeout(() => {
|
|
1245
|
+
if (index >= 15) {
|
|
1246
|
+
response.writeHead(200, {
|
|
1247
|
+
"Content-Type": "application/activity+json",
|
|
1248
|
+
});
|
|
1249
|
+
response.end('{"done":true}');
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
const next = `/alternate/${index + 1}`;
|
|
1253
|
+
response.writeHead(
|
|
1254
|
+
200,
|
|
1255
|
+
html ? { "Content-Type": "text/html" } : {
|
|
1256
|
+
"Content-Type": "text/plain",
|
|
1257
|
+
Link:
|
|
1258
|
+
`<${next}>; rel="alternate"; type="application/activity+json"`,
|
|
1259
|
+
},
|
|
1260
|
+
);
|
|
1261
|
+
response.end(
|
|
1262
|
+
html
|
|
1263
|
+
? `<link rel="alternate" type="application/activity+json" href="${next}">`
|
|
1264
|
+
: "not JSON",
|
|
1265
|
+
);
|
|
1266
|
+
}, 50);
|
|
1267
|
+
}, async (baseUrl) => {
|
|
1268
|
+
const url = `${baseUrl}/alternate/0`;
|
|
1269
|
+
const loader = getDocumentLoader({
|
|
1270
|
+
allowPrivateAddress: true,
|
|
1271
|
+
timeout: 300,
|
|
1272
|
+
});
|
|
1273
|
+
await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
|
|
1274
|
+
});
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
test("getDocumentLoader() lets the caller's signal abort a request", async () => {
|
|
1280
|
+
await withServer(() => {
|
|
1281
|
+
// Never respond.
|
|
1282
|
+
}, async (baseUrl) => {
|
|
1283
|
+
const url = `${baseUrl}/hang`;
|
|
1284
|
+
for (const timeout of [undefined, 60_000, null]) {
|
|
1285
|
+
const loader = getDocumentLoader({ allowPrivateAddress: true, timeout });
|
|
1286
|
+
const controller = new AbortController();
|
|
1287
|
+
const reason = new Error("Canceled by the caller");
|
|
1288
|
+
setTimeout(() => controller.abort(reason), 50);
|
|
1289
|
+
await rejects(loader(url, { signal: controller.signal }), (e) => {
|
|
1290
|
+
deepStrictEqual(e, reason);
|
|
1291
|
+
return true;
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
// The caller's signal wins even if the timeout fires too:
|
|
1295
|
+
const loader = getDocumentLoader({
|
|
1296
|
+
allowPrivateAddress: true,
|
|
1297
|
+
timeout: 50,
|
|
1298
|
+
});
|
|
1299
|
+
const controller = new AbortController();
|
|
1300
|
+
const reason = new Error("Canceled by the caller");
|
|
1301
|
+
controller.abort(reason);
|
|
1302
|
+
await rejects(loader(url, { signal: controller.signal }), (e) => {
|
|
1303
|
+
deepStrictEqual(e, reason);
|
|
1304
|
+
return true;
|
|
1305
|
+
});
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
test("getDocumentLoader() bounds DNS lookups that ignore the timeout", async (t) => {
|
|
1310
|
+
// The validator skips DNS when Deno has no network permission.
|
|
1311
|
+
if (
|
|
1312
|
+
"Deno" in globalThis &&
|
|
1313
|
+
(await Deno.permissions.query({ name: "net" })).state !== "granted"
|
|
1314
|
+
) {
|
|
1315
|
+
t.skip("requires the net permission");
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
fetchMock.mockGlobal();
|
|
1319
|
+
let requests = 0;
|
|
1320
|
+
const url = "https://slow-dns.example/object";
|
|
1321
|
+
fetchMock.get(url, () => {
|
|
1322
|
+
requests++;
|
|
1323
|
+
return Response.json({ id: url });
|
|
1324
|
+
});
|
|
1325
|
+
// Stubbing works only because url.ts uses the default node:dns/promises
|
|
1326
|
+
// import; see the FIXME there.
|
|
1327
|
+
const originalLookup = dns.lookup;
|
|
1328
|
+
let resolveLookup: () => void = () => {};
|
|
1329
|
+
const lookedUp = new Promise<void>((resolve) => resolveLookup = resolve);
|
|
1330
|
+
dns.lookup = (() =>
|
|
1331
|
+
new Promise((resolve) =>
|
|
1332
|
+
setTimeout(() => {
|
|
1333
|
+
resolve([{ address: "93.184.215.14", family: 4 }]);
|
|
1334
|
+
resolveLookup();
|
|
1335
|
+
}, 200)
|
|
1336
|
+
)) as typeof dns.lookup;
|
|
1337
|
+
try {
|
|
1338
|
+
const loader = getDocumentLoader({ timeout: 50 });
|
|
1339
|
+
await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
|
|
1340
|
+
// A late DNS answer must not start the request anymore:
|
|
1341
|
+
await lookedUp;
|
|
1342
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1343
|
+
deepStrictEqual(requests, 0);
|
|
1344
|
+
} finally {
|
|
1345
|
+
dns.lookup = originalLookup;
|
|
1346
|
+
fetchMock.hardReset();
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
test("withDocumentLoaderTimeout() cleans up after each call", async () => {
|
|
1351
|
+
const signals: AbortSignal[] = [];
|
|
1352
|
+
const loader = withDocumentLoaderTimeout((url, options) => {
|
|
1353
|
+
signals.push(options!.signal!);
|
|
1354
|
+
return Promise.resolve({
|
|
1355
|
+
contextUrl: null,
|
|
1356
|
+
document: {},
|
|
1357
|
+
documentUrl: url,
|
|
1358
|
+
});
|
|
1359
|
+
}, 50);
|
|
1360
|
+
const results = await Promise.all([
|
|
1361
|
+
loader("https://example.com/a"),
|
|
1362
|
+
loader("https://example.com/b"),
|
|
1363
|
+
]);
|
|
1364
|
+
deepStrictEqual(results.map((r) => r.documentUrl), [
|
|
1365
|
+
"https://example.com/a",
|
|
1366
|
+
"https://example.com/b",
|
|
1367
|
+
]);
|
|
1368
|
+
ok(signals[0] !== signals[1]);
|
|
1369
|
+
// The timers are cleared, so the signals are never aborted:
|
|
1370
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1371
|
+
ok(signals.every((signal) => !signal.aborted));
|
|
1372
|
+
// A loader that is given no timeout is returned as is:
|
|
1373
|
+
const inner = () => Promise.reject(new Error("unused"));
|
|
1374
|
+
ok(withDocumentLoaderTimeout(inner, null) === inner);
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
test("getRemoteDocument() keeps error bodies byte for byte", async () => {
|
|
1378
|
+
const bytes = new Uint8Array([0xef, 0xbb, 0xbf, 0x4e, 0x6f, 0xff, 0xfe]);
|
|
1379
|
+
const url = "https://example.com/error";
|
|
1380
|
+
let error: unknown;
|
|
1381
|
+
await rejects(
|
|
1382
|
+
getRemoteDocument(
|
|
1383
|
+
url,
|
|
1384
|
+
new Response(bytes, { status: 404 }),
|
|
1385
|
+
() => Promise.reject(new Error("unused")),
|
|
1386
|
+
),
|
|
1387
|
+
(e) => {
|
|
1388
|
+
error = e;
|
|
1389
|
+
return true;
|
|
1390
|
+
},
|
|
1391
|
+
);
|
|
1392
|
+
ok(error instanceof FetchError);
|
|
1393
|
+
ok(error.response != null);
|
|
1394
|
+
deepStrictEqual(error.response.status, 404);
|
|
1395
|
+
deepStrictEqual(new Uint8Array(await error.response.arrayBuffer()), bytes);
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
test("getRemoteDocument() keeps metadata of bodiless or large errors", async () => {
|
|
1399
|
+
const url = "https://example.com/error";
|
|
1400
|
+
for (
|
|
1401
|
+
const response of [
|
|
1402
|
+
new Response(null, { status: 304, headers: { ETag: '"a"' } }),
|
|
1403
|
+
new Response(new Uint8Array(1024 * 1024 + 1), {
|
|
1404
|
+
status: 500,
|
|
1405
|
+
headers: { ETag: '"a"' },
|
|
1406
|
+
}),
|
|
1407
|
+
]
|
|
1408
|
+
) {
|
|
1409
|
+
await rejects(
|
|
1410
|
+
getRemoteDocument(
|
|
1411
|
+
url,
|
|
1412
|
+
response,
|
|
1413
|
+
() => Promise.reject(new Error("unused")),
|
|
1414
|
+
),
|
|
1415
|
+
(e) => {
|
|
1416
|
+
ok(e instanceof FetchError);
|
|
1417
|
+
ok(e.response != null);
|
|
1418
|
+
deepStrictEqual(e.response.status, response.status);
|
|
1419
|
+
deepStrictEqual(e.response.headers.get("ETag"), '"a"');
|
|
1420
|
+
deepStrictEqual(e.response.body, null);
|
|
1421
|
+
return true;
|
|
1422
|
+
},
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
test("getDocumentLoader() reports statuses that Response cannot hold", async () => {
|
|
1428
|
+
await withServer((_, response) => {
|
|
1429
|
+
response.writeHead(999);
|
|
1430
|
+
response.end("Request denied");
|
|
1431
|
+
}, async (baseUrl) => {
|
|
1432
|
+
const url = `${baseUrl}/denied`;
|
|
1433
|
+
const loader = getDocumentLoader({ allowPrivateAddress: true });
|
|
1434
|
+
let error: unknown;
|
|
1435
|
+
await rejects(loader(url), (e) => {
|
|
1436
|
+
error = e;
|
|
1437
|
+
return true;
|
|
1438
|
+
});
|
|
1439
|
+
ok(error instanceof FetchError, String(error));
|
|
1440
|
+
deepStrictEqual(error.response?.status, 999);
|
|
1441
|
+
deepStrictEqual(await error.response.text(), "Request denied");
|
|
1442
|
+
});
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
test("getDocumentLoader() times out a stalled body of such a status", async () => {
|
|
1446
|
+
await withServer((_, response) => {
|
|
1447
|
+
response.writeHead(999);
|
|
1448
|
+
response.write("Request");
|
|
1449
|
+
// Never finish the body.
|
|
1450
|
+
}, async (baseUrl) => {
|
|
1451
|
+
const url = `${baseUrl}/stalled-denied`;
|
|
1452
|
+
const loader = getDocumentLoader({
|
|
1453
|
+
allowPrivateAddress: true,
|
|
1454
|
+
timeout: 200,
|
|
1455
|
+
});
|
|
1456
|
+
await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
|
|
1457
|
+
});
|
|
1458
|
+
});
|