@procedures/http 0.0.0-stage → 0.3.0
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/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/address.d.ts +31 -0
- package/dist/address.d.ts.map +1 -0
- package/dist/address.js +214 -0
- package/dist/address.js.map +1 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +18 -0
- package/dist/errors.js.map +1 -0
- package/dist/exchange.d.ts +102 -0
- package/dist/exchange.d.ts.map +1 -0
- package/dist/exchange.js +322 -0
- package/dist/exchange.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/module.d.ts +41 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +205 -0
- package/dist/module.js.map +1 -0
- package/dist/parsers.d.ts +37 -0
- package/dist/parsers.d.ts.map +1 -0
- package/dist/parsers.js +175 -0
- package/dist/parsers.js.map +1 -0
- package/dist/policy.d.ts +94 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +140 -0
- package/dist/policy.js.map +1 -0
- package/dist/procedures/http/index.d.ts +5 -0
- package/dist/procedures/http/index.d.ts.map +1 -0
- package/dist/procedures/http/index.js +5 -0
- package/dist/procedures/http/index.js.map +1 -0
- package/dist/procedures/http/json.d.ts +14 -0
- package/dist/procedures/http/json.d.ts.map +1 -0
- package/dist/procedures/http/json.js +56 -0
- package/dist/procedures/http/json.js.map +1 -0
- package/dist/procedures/http/ndjson.d.ts +18 -0
- package/dist/procedures/http/ndjson.d.ts.map +1 -0
- package/dist/procedures/http/ndjson.js +65 -0
- package/dist/procedures/http/ndjson.js.map +1 -0
- package/dist/procedures/http/request.d.ts +14 -0
- package/dist/procedures/http/request.d.ts.map +1 -0
- package/dist/procedures/http/request.js +79 -0
- package/dist/procedures/http/request.js.map +1 -0
- package/dist/procedures/http/sse.d.ts +19 -0
- package/dist/procedures/http/sse.d.ts.map +1 -0
- package/dist/procedures/http/sse.js +66 -0
- package/dist/procedures/http/sse.js.map +1 -0
- package/dist/register.d.ts +6 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +8 -0
- package/dist/register.js.map +1 -0
- package/dist/scope.d.ts +53 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +107 -0
- package/dist/scope.js.map +1 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +79 -3
- package/src/address.ts +206 -0
- package/src/errors.ts +44 -0
- package/src/exchange.ts +381 -0
- package/src/index.ts +11 -0
- package/src/module.ts +257 -0
- package/src/parsers.ts +184 -0
- package/src/policy.ts +192 -0
- package/src/procedures/http/index.ts +4 -0
- package/src/procedures/http/json.ts +52 -0
- package/src/procedures/http/ndjson.ts +64 -0
- package/src/procedures/http/request.ts +72 -0
- package/src/procedures/http/sse.ts +65 -0
- package/src/register.ts +10 -0
- package/src/scope.ts +129 -0
- package/src/types.ts +127 -0
package/src/exchange.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One HTTP exchange with the host check, the redirects and the limits (roadmap R38).
|
|
3
|
+
*
|
|
4
|
+
* The exchange uses `node:http` and `node:https`, not `fetch`: the connection must use the
|
|
5
|
+
* addresses that the host check allowed. A `lookup` function gives those addresses to the socket,
|
|
6
|
+
* so the DNS cannot give another address after the check (DNS rebinding). The exchange follows
|
|
7
|
+
* each redirect itself and examines each new host. Each request gets its own connection.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { request as plainRequest, type IncomingHttpHeaders, type IncomingMessage, type RequestOptions } from "node:http";
|
|
11
|
+
import { request as tlsRequest } from "node:https";
|
|
12
|
+
import type { LookupFunction } from "node:net";
|
|
13
|
+
import { PassThrough, pipeline, type Readable } from "node:stream";
|
|
14
|
+
import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
|
|
15
|
+
import type { ProcedurePath } from "@procedures/client";
|
|
16
|
+
import { HttpError } from "./errors.js";
|
|
17
|
+
import { checkHost, DEFAULT_HOST_POLICY, type AllowedTarget, type HostPolicy } from "./policy.js";
|
|
18
|
+
import type { CallScope } from "./scope.js";
|
|
19
|
+
|
|
20
|
+
/** One mebibyte. */
|
|
21
|
+
export const MIB: number = 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
/** The default size limit of a response body and of a request body: 10 MiB. */
|
|
24
|
+
export const DEFAULT_MAX_BYTES: number = 10 * MIB;
|
|
25
|
+
|
|
26
|
+
/** The default size limit of one NDJSON line and of one server-sent event: 1 MiB. */
|
|
27
|
+
export const DEFAULT_ITEM_BYTES: number = MIB;
|
|
28
|
+
|
|
29
|
+
/** The default time of `http.request` and `http.json`: 30 seconds. */
|
|
30
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
/** The default number of redirects that a call follows. */
|
|
33
|
+
export const DEFAULT_MAX_REDIRECTS = 5;
|
|
34
|
+
|
|
35
|
+
/** The settings of a module: the host policy and the size limits of the host. */
|
|
36
|
+
export interface HttpSettings {
|
|
37
|
+
readonly policy: HostPolicy;
|
|
38
|
+
/** The largest body of a request, in bytes. */
|
|
39
|
+
readonly maxRequestBytes: number;
|
|
40
|
+
/** The largest body of a response, in bytes. An input can set a smaller limit. */
|
|
41
|
+
readonly maxResponseBytes: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The settings of the default module: each public host, and 10 MiB for each body. */
|
|
45
|
+
export const DEFAULT_SETTINGS: HttpSettings = {
|
|
46
|
+
policy: DEFAULT_HOST_POLICY,
|
|
47
|
+
maxRequestBytes: DEFAULT_MAX_BYTES,
|
|
48
|
+
maxResponseBytes: DEFAULT_MAX_BYTES,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The headers that each request gets, unless the input sets them. */
|
|
52
|
+
const DEFAULT_HEADERS: Readonly<Record<string, string>> = {
|
|
53
|
+
"accept-encoding": "gzip, deflate, br",
|
|
54
|
+
"user-agent": "@procedures/http",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** What to do with a redirect. */
|
|
58
|
+
export type RedirectMode = "follow" | "manual" | "error";
|
|
59
|
+
|
|
60
|
+
/** A request before the first exchange. */
|
|
61
|
+
export interface PreparedRequest {
|
|
62
|
+
readonly url: URL;
|
|
63
|
+
readonly method: string;
|
|
64
|
+
/** The headers, with names in lowercase. */
|
|
65
|
+
readonly headers: Record<string, string>;
|
|
66
|
+
readonly body?: Buffer | undefined;
|
|
67
|
+
readonly redirect: RedirectMode;
|
|
68
|
+
readonly maxRedirects: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The parts of a request that the procedures share. */
|
|
72
|
+
export interface RequestParts {
|
|
73
|
+
readonly url: string;
|
|
74
|
+
readonly method?: string | undefined;
|
|
75
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
76
|
+
readonly query?: Readonly<Record<string, QueryValue | readonly QueryValue[]>> | undefined;
|
|
77
|
+
readonly redirect?: RedirectMode | undefined;
|
|
78
|
+
readonly maxRedirects?: number | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A value of the query of a URL. */
|
|
82
|
+
export type QueryValue = string | number | boolean;
|
|
83
|
+
|
|
84
|
+
/** The response of an exchange. */
|
|
85
|
+
export interface Exchange {
|
|
86
|
+
readonly response: IncomingMessage;
|
|
87
|
+
/** The body without its content encoding (gzip, deflate or br). */
|
|
88
|
+
readonly body: Readable;
|
|
89
|
+
/** The URL of the response: the last URL after the redirects. */
|
|
90
|
+
readonly url: string;
|
|
91
|
+
/** The URLs of the redirects that the exchange followed. */
|
|
92
|
+
readonly redirects: readonly string[];
|
|
93
|
+
/** This function closes the connection. */
|
|
94
|
+
close(): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The headers that a redirect to another origin removes, as `fetch` does. */
|
|
98
|
+
const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"];
|
|
99
|
+
|
|
100
|
+
/** This function parses the URL of an input, and adds the query. Only `http:` and `https:` pass. */
|
|
101
|
+
export function urlOf(parts: RequestParts, path: ProcedurePath): URL {
|
|
102
|
+
let url: URL;
|
|
103
|
+
try {
|
|
104
|
+
url = new URL(parts.url);
|
|
105
|
+
} catch {
|
|
106
|
+
throw new HttpError("BAD_URL", `${path.join(".")}: ${JSON.stringify(parts.url)} is not a URL`);
|
|
107
|
+
}
|
|
108
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
109
|
+
throw new HttpError("BAD_URL", `${path.join(".")}: the URL ${url.href} is not an http: or https: URL`);
|
|
110
|
+
}
|
|
111
|
+
for (const [key, value] of Object.entries(parts.query ?? {})) {
|
|
112
|
+
for (const item of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(item));
|
|
113
|
+
}
|
|
114
|
+
url.hash = "";
|
|
115
|
+
return url;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The headers of an input with names in lowercase. A later name of the same header wins. */
|
|
119
|
+
export function headersOf(headers: Readonly<Record<string, string>> | undefined, defaults: Readonly<Record<string, string>> = {}): Record<string, string> {
|
|
120
|
+
const out: Record<string, string> = {};
|
|
121
|
+
for (const [name, value] of Object.entries(defaults)) out[name.toLowerCase()] = value;
|
|
122
|
+
for (const [name, value] of Object.entries(headers ?? {})) out[name.toLowerCase()] = value;
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** This function makes a request from the shared parts of an input. */
|
|
127
|
+
export function prepare(parts: RequestParts, path: ProcedurePath, settings: HttpSettings, extra: { headers?: Record<string, string>; body?: Buffer | undefined } = {}): PreparedRequest {
|
|
128
|
+
const body = extra.body;
|
|
129
|
+
if (body !== undefined && body.length > settings.maxRequestBytes) {
|
|
130
|
+
throw new HttpError("REQUEST_TOO_LARGE", `${path.join(".")}: the body of the request has ${body.length} bytes, and the limit is ${settings.maxRequestBytes} bytes`);
|
|
131
|
+
}
|
|
132
|
+
const headers = headersOf(parts.headers, { ...DEFAULT_HEADERS, ...extra.headers });
|
|
133
|
+
const url = urlOf(parts, path);
|
|
134
|
+
if ((url.username !== "" || url.password !== "") && headers["authorization"] === undefined) {
|
|
135
|
+
headers["authorization"] = `Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString("base64")}`;
|
|
136
|
+
}
|
|
137
|
+
url.username = "";
|
|
138
|
+
url.password = "";
|
|
139
|
+
return {
|
|
140
|
+
url,
|
|
141
|
+
method: (parts.method ?? "GET").toUpperCase(),
|
|
142
|
+
headers,
|
|
143
|
+
body,
|
|
144
|
+
redirect: parts.redirect ?? "follow",
|
|
145
|
+
maxRedirects: parts.maxRedirects ?? DEFAULT_MAX_REDIRECTS,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A `lookup` for the socket that gives only the addresses that the check allowed. */
|
|
150
|
+
function pinnedLookup(target: AllowedTarget): LookupFunction {
|
|
151
|
+
return (_hostname, options, callback) => {
|
|
152
|
+
const family = options.family === "IPv4" ? 4 : options.family === "IPv6" ? 6 : options.family;
|
|
153
|
+
const addresses = family === 4 || family === 6 ? target.addresses.filter((entry) => entry.family === family) : target.addresses;
|
|
154
|
+
const first = addresses[0];
|
|
155
|
+
if (first === undefined) {
|
|
156
|
+
const error = new Error(`No allowed address of ${target.hostname} has the family IPv${String(family)}`) as NodeJS.ErrnoException;
|
|
157
|
+
error.code = "ENOTFOUND";
|
|
158
|
+
callback(error, "", 4);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (options.all) callback(null, addresses.map((entry) => ({ address: entry.address, family: entry.family })));
|
|
162
|
+
else callback(null, first.address, first.family);
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** This function sends one request to the checked target, and gives the response. */
|
|
167
|
+
function send(request: { url: URL; method: string; headers: Record<string, string>; body?: Buffer | undefined }, target: AllowedTarget, scope: CallScope): Promise<IncomingMessage> {
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const options: RequestOptions = {
|
|
170
|
+
method: request.method,
|
|
171
|
+
host: target.hostname,
|
|
172
|
+
port: target.port,
|
|
173
|
+
path: `${request.url.pathname}${request.url.search}`,
|
|
174
|
+
headers: request.body === undefined ? request.headers : { ...request.headers, "content-length": String(request.body.length) },
|
|
175
|
+
lookup: pinnedLookup(target),
|
|
176
|
+
signal: scope.signal,
|
|
177
|
+
// A connection of its own: a pooled socket could belong to another check
|
|
178
|
+
agent: false,
|
|
179
|
+
};
|
|
180
|
+
const outgoing = (request.url.protocol === "https:" ? tlsRequest : plainRequest)(options, resolve);
|
|
181
|
+
outgoing.on("error", reject);
|
|
182
|
+
outgoing.end(request.body);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isRedirect(status: number): boolean {
|
|
187
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The body of a response without its content encoding. Only gzip, deflate and br are decoded. */
|
|
191
|
+
function decodedBody(response: IncomingMessage, method: string): Readable {
|
|
192
|
+
const status = response.statusCode ?? 0;
|
|
193
|
+
const encoding = String(response.headers["content-encoding"] ?? "").trim().toLowerCase();
|
|
194
|
+
const empty = method === "HEAD" || status === 204 || status === 304 || response.headers["content-length"] === "0";
|
|
195
|
+
const decoder =
|
|
196
|
+
empty ? undefined
|
|
197
|
+
: encoding === "gzip" || encoding === "x-gzip" ? createGunzip()
|
|
198
|
+
: encoding === "deflate" ? createInflate()
|
|
199
|
+
: encoding === "br" ? createBrotliDecompress()
|
|
200
|
+
: undefined;
|
|
201
|
+
if (decoder === undefined) return response;
|
|
202
|
+
// An error of the response (an abort, a reset) reaches the reader of the decoded body
|
|
203
|
+
const out = new PassThrough();
|
|
204
|
+
out.on("error", () => undefined);
|
|
205
|
+
pipeline(response, decoder, out, () => undefined);
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The result of a promise, or the error of the scope when the scope stops first. */
|
|
210
|
+
function untilStopped<T>(promise: Promise<T>, scope: CallScope): Promise<T> {
|
|
211
|
+
if (scope.reason !== undefined) return Promise.reject(scope.error());
|
|
212
|
+
return new Promise<T>((resolve, reject) => {
|
|
213
|
+
const onStop = (): void => reject(scope.error());
|
|
214
|
+
scope.signal.addEventListener("abort", onStop, { once: true });
|
|
215
|
+
promise.then(
|
|
216
|
+
(value) => {
|
|
217
|
+
scope.signal.removeEventListener("abort", onStop);
|
|
218
|
+
resolve(value);
|
|
219
|
+
},
|
|
220
|
+
(error: unknown) => {
|
|
221
|
+
scope.signal.removeEventListener("abort", onStop);
|
|
222
|
+
reject(error);
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The text of a header value: several values are joined with a comma. */
|
|
229
|
+
export function headerText(value: string | string[] | undefined): string | undefined {
|
|
230
|
+
return Array.isArray(value) ? value.join(", ") : value;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* This function sends a request, follows the redirects and gives the response. Each URL goes
|
|
235
|
+
* through the host check before its connection. A refused host throws `FORBIDDEN` with the
|
|
236
|
+
* reason `host-not-allowed`.
|
|
237
|
+
*/
|
|
238
|
+
export async function exchange(request: PreparedRequest, policy: HostPolicy, scope: CallScope): Promise<Exchange> {
|
|
239
|
+
const path = scope.path;
|
|
240
|
+
let url = request.url;
|
|
241
|
+
let method = request.method;
|
|
242
|
+
let body = request.body;
|
|
243
|
+
let headers = { ...request.headers };
|
|
244
|
+
const redirects: string[] = [];
|
|
245
|
+
for (;;) {
|
|
246
|
+
// The look-up of the DNS has no signal: the stop of the scope ends the wait
|
|
247
|
+
const target = await untilStopped(checkHost(policy, url, path, redirects.length === 0 ? "the URL" : `the redirect to ${url.href}`), scope);
|
|
248
|
+
if (scope.reason !== undefined) throw scope.error();
|
|
249
|
+
let response: IncomingMessage;
|
|
250
|
+
try {
|
|
251
|
+
response = await send({ url, method, headers, body }, target, scope);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (scope.reason !== undefined) throw scope.error();
|
|
254
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
255
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
256
|
+
throw new HttpError("CONNECTION", `${path.join(".")}: the request to ${url.origin} failed${code ? ` (${code})` : ""}: ${reason}`, undefined, { cause: error });
|
|
257
|
+
}
|
|
258
|
+
// A stop before the first read destroys the response: the reader gets the error, not the process
|
|
259
|
+
response.on("error", () => undefined);
|
|
260
|
+
scope.touch();
|
|
261
|
+
const status = response.statusCode ?? 0;
|
|
262
|
+
const location = headerText(response.headers.location);
|
|
263
|
+
if (!isRedirect(status) || location === undefined || request.redirect === "manual") {
|
|
264
|
+
return { response, body: decodedBody(response, method), url: url.href, redirects, close: () => response.destroy() };
|
|
265
|
+
}
|
|
266
|
+
response.destroy();
|
|
267
|
+
if (request.redirect === "error") {
|
|
268
|
+
throw new HttpError("REDIRECT", `${path.join(".")}: the response is a redirect (${status}) to ${location}, and the input has redirect: "error"`, status);
|
|
269
|
+
}
|
|
270
|
+
if (redirects.length >= request.maxRedirects) {
|
|
271
|
+
throw new HttpError("TOO_MANY_REDIRECTS", `${path.join(".")}: more than ${request.maxRedirects} redirects (maxRedirects)`, status);
|
|
272
|
+
}
|
|
273
|
+
let next: URL;
|
|
274
|
+
try {
|
|
275
|
+
next = new URL(location, url);
|
|
276
|
+
} catch {
|
|
277
|
+
throw new HttpError("BAD_URL", `${path.join(".")}: the redirect location ${JSON.stringify(location)} is not a URL`, status);
|
|
278
|
+
}
|
|
279
|
+
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
|
280
|
+
throw new HttpError("BAD_URL", `${path.join(".")}: the redirect to ${next.href} is not an http: or https: URL`, status);
|
|
281
|
+
}
|
|
282
|
+
next.hash = "";
|
|
283
|
+
// 303, and 301 or 302 after a POST, continue with a GET without the body, as fetch does
|
|
284
|
+
if ((status === 303 && method !== "HEAD") || ((status === 301 || status === 302) && method === "POST")) {
|
|
285
|
+
method = "GET";
|
|
286
|
+
body = undefined;
|
|
287
|
+
headers = Object.fromEntries(Object.entries(headers).filter(([name]) => !name.startsWith("content-")));
|
|
288
|
+
}
|
|
289
|
+
if (next.origin !== url.origin) {
|
|
290
|
+
headers = Object.fromEntries(Object.entries(headers).filter(([name]) => !CREDENTIAL_HEADERS.includes(name)));
|
|
291
|
+
}
|
|
292
|
+
redirects.push(next.href);
|
|
293
|
+
url = next;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** This function reads a whole body. More than `maxBytes` bytes throws `RESPONSE_TOO_LARGE`. */
|
|
298
|
+
export async function readBody(body: Readable, maxBytes: number, scope: CallScope, response: IncomingMessage): Promise<Buffer> {
|
|
299
|
+
const declared = Number(response.headers["content-length"]);
|
|
300
|
+
const encoded = response.headers["content-encoding"] !== undefined;
|
|
301
|
+
const tooLarge = (): HttpError =>
|
|
302
|
+
new HttpError("RESPONSE_TOO_LARGE", `${scope.path.join(".")}: the body of the response has more than ${maxBytes} bytes (maxResponseBytes)`, response.statusCode);
|
|
303
|
+
if (!encoded && Number.isFinite(declared) && declared > maxBytes) {
|
|
304
|
+
body.destroy();
|
|
305
|
+
throw tooLarge();
|
|
306
|
+
}
|
|
307
|
+
const chunks: Buffer[] = [];
|
|
308
|
+
let size = 0;
|
|
309
|
+
try {
|
|
310
|
+
for await (const chunk of body) {
|
|
311
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer);
|
|
312
|
+
size += buffer.length;
|
|
313
|
+
if (size > maxBytes) throw tooLarge();
|
|
314
|
+
chunks.push(buffer);
|
|
315
|
+
scope.touch();
|
|
316
|
+
}
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (scope.reason !== undefined) throw scope.error();
|
|
319
|
+
if (error instanceof HttpError) throw error;
|
|
320
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
321
|
+
throw new HttpError("CONNECTION", `${scope.path.join(".")}: the body of the response failed: ${reason}`, response.statusCode, { cause: error });
|
|
322
|
+
} finally {
|
|
323
|
+
body.destroy();
|
|
324
|
+
response.destroy();
|
|
325
|
+
}
|
|
326
|
+
return Buffer.concat(chunks);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The headers of a response as JSON: a string, or a list for `set-cookie`. */
|
|
330
|
+
export function responseHeaders(headers: IncomingHttpHeaders): Record<string, string | string[]> {
|
|
331
|
+
const out: Record<string, string | string[]> = {};
|
|
332
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
333
|
+
if (value !== undefined) out[name] = value;
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The first characters of a body, for a message. */
|
|
339
|
+
export function excerpt(body: Buffer | string, length = 200): string {
|
|
340
|
+
const text = typeof body === "string" ? body : body.subarray(0, length * 4).toString("utf8");
|
|
341
|
+
return text.length > length ? `${text.slice(0, length)}...` : text;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** True for a status in 200 to 299. */
|
|
345
|
+
export function isOk(status: number): boolean {
|
|
346
|
+
return status >= 200 && status <= 299;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The `HTTP_STATUS` error of a response whose status is not in 200 to 299. The message has the
|
|
351
|
+
* start of the body (at most 4 KiB is read).
|
|
352
|
+
*/
|
|
353
|
+
export async function statusError(opened: Exchange, scope: CallScope): Promise<HttpError> {
|
|
354
|
+
const status = opened.response.statusCode ?? 0;
|
|
355
|
+
let start = "";
|
|
356
|
+
try {
|
|
357
|
+
const chunks: Buffer[] = [];
|
|
358
|
+
let size = 0;
|
|
359
|
+
for await (const chunk of opened.body) {
|
|
360
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer);
|
|
361
|
+
chunks.push(buffer);
|
|
362
|
+
size += buffer.length;
|
|
363
|
+
if (size >= 4096) break;
|
|
364
|
+
}
|
|
365
|
+
start = excerpt(Buffer.concat(chunks));
|
|
366
|
+
} catch {
|
|
367
|
+
if (scope.reason !== undefined) throw scope.error();
|
|
368
|
+
} finally {
|
|
369
|
+
opened.close();
|
|
370
|
+
}
|
|
371
|
+
const text = opened.response.statusMessage ? ` ${opened.response.statusMessage}` : "";
|
|
372
|
+
return new HttpError("HTTP_STATUS", `${scope.path.join(".")}: the server answered ${status}${text}${start ? `: ${start}` : ""}`, status);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The chunks of a body. Each chunk starts the idle time of the scope again. */
|
|
376
|
+
export async function* touching(body: Readable, scope: CallScope): AsyncGenerator<Uint8Array | string, void, undefined> {
|
|
377
|
+
for await (const chunk of body) {
|
|
378
|
+
scope.touch();
|
|
379
|
+
yield chunk as Uint8Array | string;
|
|
380
|
+
}
|
|
381
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The root registers nothing: import "<package>/register", or give the module to createRegistry()
|
|
2
|
+
export * from "./module.js";
|
|
3
|
+
export * from "./types.js";
|
|
4
|
+
export { httpRequest, httpJson, httpNdjson, httpSse } from "./procedures/http/index.js";
|
|
5
|
+
// The host policy (roadmap R38)
|
|
6
|
+
export { HOST_NOT_ALLOWED, DEFAULT_HOST_POLICY, createHostPolicy, checkHost, isHostNotAllowed, systemResolver } from "./policy.js";
|
|
7
|
+
export type { AllowedTarget, HostPolicy, HostPolicyOptions, HostResolver, ResolvedAddress } from "./policy.js";
|
|
8
|
+
export { isPublicAddress, parseAddress, specialClass } from "./address.js";
|
|
9
|
+
export { HttpError, type HttpErrorCode } from "./errors.js";
|
|
10
|
+
export { DEFAULT_MAX_BYTES, DEFAULT_ITEM_BYTES, DEFAULT_TIMEOUT_MS, DEFAULT_SETTINGS, type HttpSettings } from "./exchange.js";
|
|
11
|
+
export { ndjsonLines, sseEvents, type NdjsonLine, type ServerSentEvent } from "./parsers.js";
|
package/src/module.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The procedures of @procedures/http as data. Importing this file registers nothing.
|
|
3
|
+
*
|
|
4
|
+
* Scaffold a procedure with: mark procedure new <name> --path packages/http
|
|
5
|
+
*
|
|
6
|
+
* `httpModule` reaches each public host and refuses the other addresses. `createHttpModule()`
|
|
7
|
+
* gives the same procedures with another host policy or other size limits (roadmap R38).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import {
|
|
12
|
+
defineModule,
|
|
13
|
+
registerModules,
|
|
14
|
+
PROCEDURE_REGISTRY,
|
|
15
|
+
createProcedure,
|
|
16
|
+
zodAdapter,
|
|
17
|
+
type AnyProcedure,
|
|
18
|
+
type Effects,
|
|
19
|
+
type ProcedureHandler,
|
|
20
|
+
type ProcedureModule,
|
|
21
|
+
type ProcedureRegistry,
|
|
22
|
+
} from "@procedures/client";
|
|
23
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_SETTINGS, type HttpSettings } from "./exchange.js";
|
|
24
|
+
import { createHostPolicy, type HostPolicyOptions } from "./policy.js";
|
|
25
|
+
import type { ScopeContext } from "./scope.js";
|
|
26
|
+
import { httpRequest } from "./procedures/http/request.js";
|
|
27
|
+
import { httpJson } from "./procedures/http/json.js";
|
|
28
|
+
import { httpNdjson } from "./procedures/http/ndjson.js";
|
|
29
|
+
import { httpSse } from "./procedures/http/sse.js";
|
|
30
|
+
import type {
|
|
31
|
+
HttpJsonInput,
|
|
32
|
+
HttpJsonOutput,
|
|
33
|
+
HttpNdjsonInput,
|
|
34
|
+
HttpNdjsonOutput,
|
|
35
|
+
HttpRequestInput,
|
|
36
|
+
HttpRequestOutput,
|
|
37
|
+
HttpSseInput,
|
|
38
|
+
HttpSseOutput,
|
|
39
|
+
} from "./types.js";
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// Schemas
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
const url = z.url({ protocol: /^https?$/ }).describe("The URL: http: or https:");
|
|
46
|
+
const headers = z.record(z.string(), z.string()).optional().describe("The headers of the request");
|
|
47
|
+
const queryValue = z.union([z.string(), z.number(), z.boolean()]);
|
|
48
|
+
const query = z
|
|
49
|
+
.record(z.string(), z.union([queryValue, z.array(queryValue)]))
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("More parameters for the query of the URL. A list gives the parameter more than one time");
|
|
52
|
+
/** A time in milliseconds: at most 24 hours. */
|
|
53
|
+
const milliseconds = (description: string) => z.number().int().positive().max(86_400_000).optional().describe(description);
|
|
54
|
+
const byteLimit = (description: string) => z.number().int().positive().optional().describe(description);
|
|
55
|
+
const maxResponseBytes = byteLimit("The largest body of the response, in bytes. The default and the maximum is the limit of the module (10 MiB)");
|
|
56
|
+
const headerRecord = z.record(z.string(), z.union([z.string(), z.array(z.string())])).describe("The headers of the response, with names in lowercase");
|
|
57
|
+
|
|
58
|
+
const HttpRequestInputSchema = z.object({
|
|
59
|
+
url,
|
|
60
|
+
method: z.enum(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]).optional().describe("The method (default: GET)"),
|
|
61
|
+
headers,
|
|
62
|
+
query,
|
|
63
|
+
body: z.string().optional().describe("A body of text"),
|
|
64
|
+
json: z.unknown().optional().describe("A body of JSON: the procedure sets content-type: application/json"),
|
|
65
|
+
bodyBase64: z.base64().optional().describe("A body of bytes in base64"),
|
|
66
|
+
responseType: z.enum(["text", "json", "bytes"]).optional().describe("The form of the body of the response: text (default), json, or bytes in base64"),
|
|
67
|
+
timeoutMs: milliseconds("The time of the whole call, in milliseconds (default: 30000)"),
|
|
68
|
+
maxResponseBytes,
|
|
69
|
+
redirect: z.enum(["follow", "manual", "error"]).optional().describe("What to do with a redirect: follow it (default), give it (manual), or fail (error)"),
|
|
70
|
+
maxRedirects: z.number().int().min(0).max(20).optional().describe("The most redirects that the call follows (default: 5)"),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const HttpRequestOutputSchema = z.object({
|
|
74
|
+
status: z.number().int().describe("The status of the response"),
|
|
75
|
+
statusText: z.string().describe("The reason phrase of the status"),
|
|
76
|
+
ok: z.boolean().describe("True for a status in 200 to 299"),
|
|
77
|
+
url: z.string().describe("The URL of the response: the last URL after the redirects"),
|
|
78
|
+
redirected: z.boolean().describe("True when the call followed a redirect"),
|
|
79
|
+
headers: headerRecord,
|
|
80
|
+
body: z.unknown().describe("The body: text, parsed JSON (null when empty), or bytes in base64"),
|
|
81
|
+
bytes: z.number().int().describe("The size of the body in bytes, after the content encoding"),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const HttpJsonInputSchema = z.object({
|
|
85
|
+
url,
|
|
86
|
+
method: z.enum(["GET", "POST"]).optional().describe("GET or POST (default: POST with json, GET without it)"),
|
|
87
|
+
headers,
|
|
88
|
+
query,
|
|
89
|
+
json: z.unknown().optional().describe("The JSON body of a POST"),
|
|
90
|
+
timeoutMs: milliseconds("The time of the whole call, in milliseconds (default: 30000)"),
|
|
91
|
+
maxResponseBytes,
|
|
92
|
+
failOnStatus: z.boolean().optional().describe("True (default): a status outside 200 to 299 is an error. False: the output has the status"),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const HttpJsonOutputSchema = z.object({
|
|
96
|
+
status: z.number().int().describe("The status of the response"),
|
|
97
|
+
ok: z.boolean().describe("True for a status in 200 to 299"),
|
|
98
|
+
url: z.string().describe("The URL of the response: the last URL after the redirects"),
|
|
99
|
+
headers: headerRecord,
|
|
100
|
+
data: z.unknown().describe("The parsed body. An empty body gives null. An error status with a body that is not JSON gives its text"),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const streamFields = {
|
|
104
|
+
url,
|
|
105
|
+
headers,
|
|
106
|
+
query,
|
|
107
|
+
timeoutMs: milliseconds("The time of the whole stream, in milliseconds (default: no limit)"),
|
|
108
|
+
idleTimeoutMs: milliseconds("The longest time without data, in milliseconds (default: no limit)"),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const HttpNdjsonInputSchema = z.object({
|
|
112
|
+
...streamFields,
|
|
113
|
+
maxLineBytes: byteLimit("The largest line, in bytes (default: 1 MiB)"),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const HttpNdjsonOutputSchema = z.unknown().describe("The JSON value of one line");
|
|
117
|
+
|
|
118
|
+
const HttpSseInputSchema = z.object({
|
|
119
|
+
...streamFields,
|
|
120
|
+
lastEventId: z.string().optional().describe("The Last-Event-ID header: the server continues after this event"),
|
|
121
|
+
maxEventBytes: byteLimit("The largest event, in bytes (default: 1 MiB)"),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const HttpSseOutputSchema = z.object({
|
|
125
|
+
event: z.string().describe("The type of the event (default: message)"),
|
|
126
|
+
data: z.string().describe("The data of the event: its data lines joined with LF"),
|
|
127
|
+
id: z.string().optional().describe("The last event ID of the stream"),
|
|
128
|
+
retry: z.number().int().optional().describe("The reconnection time that the event gave, in milliseconds"),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// =============================================================================
|
|
132
|
+
// Procedures
|
|
133
|
+
// =============================================================================
|
|
134
|
+
|
|
135
|
+
/** The effects of a request with any method: it reads from the network and can change remote data. */
|
|
136
|
+
const NETWORK_WRITE: Effects = { reads: ["network"], writes: ["network"] };
|
|
137
|
+
|
|
138
|
+
/** The effects of a GET: it reads from the network and changes nothing. */
|
|
139
|
+
const NETWORK_READ: Effects = { reads: ["network"], writes: [] };
|
|
140
|
+
|
|
141
|
+
/** The four procedures, with handlers that use the settings. */
|
|
142
|
+
function httpProcedures(settings: HttpSettings): AnyProcedure[] {
|
|
143
|
+
const httpRequestProcedure = createProcedure()
|
|
144
|
+
.path(["http", "request"])
|
|
145
|
+
.input(zodAdapter<HttpRequestInput>(HttpRequestInputSchema))
|
|
146
|
+
.output(zodAdapter<HttpRequestOutput>(HttpRequestOutputSchema))
|
|
147
|
+
.meta({
|
|
148
|
+
description: "Send an HTTP request and give the status, the headers and the body",
|
|
149
|
+
args: ["url"],
|
|
150
|
+
shorts: { method: "X", responseType: "r", timeoutMs: "t" },
|
|
151
|
+
output: "json",
|
|
152
|
+
// Each method: a POST, a PUT or a DELETE can change or delete remote data
|
|
153
|
+
effects: NETWORK_WRITE,
|
|
154
|
+
})
|
|
155
|
+
.handler(async (input: HttpRequestInput, ctx): Promise<HttpRequestOutput> => httpRequest(input, ctx, settings))
|
|
156
|
+
.build();
|
|
157
|
+
|
|
158
|
+
const httpJsonProcedure = createProcedure()
|
|
159
|
+
.path(["http", "json"])
|
|
160
|
+
.input(zodAdapter<HttpJsonInput>(HttpJsonInputSchema))
|
|
161
|
+
.output(zodAdapter<HttpJsonOutput>(HttpJsonOutputSchema))
|
|
162
|
+
.meta({
|
|
163
|
+
description: "Send a GET or a POST of JSON and give the parsed JSON response",
|
|
164
|
+
args: ["url"],
|
|
165
|
+
shorts: { method: "X", timeoutMs: "t" },
|
|
166
|
+
output: "json",
|
|
167
|
+
// A POST can change remote data
|
|
168
|
+
effects: NETWORK_WRITE,
|
|
169
|
+
})
|
|
170
|
+
.handler(async (input: HttpJsonInput, ctx): Promise<HttpJsonOutput> => httpJson(input, ctx, settings))
|
|
171
|
+
.build();
|
|
172
|
+
|
|
173
|
+
const httpNdjsonProcedure = createProcedure()
|
|
174
|
+
.path(["http", "ndjson"])
|
|
175
|
+
.input(zodAdapter<HttpNdjsonInput>(HttpNdjsonInputSchema))
|
|
176
|
+
.output(zodAdapter<HttpNdjsonOutput>(HttpNdjsonOutputSchema))
|
|
177
|
+
.meta({
|
|
178
|
+
description: "Read an NDJSON response as a stream of parsed lines",
|
|
179
|
+
args: ["url"],
|
|
180
|
+
shorts: { timeoutMs: "t" },
|
|
181
|
+
output: "json",
|
|
182
|
+
streaming: true,
|
|
183
|
+
// A server can send lines without an end (a watch, a feed): roadmap R15
|
|
184
|
+
stream: { unbounded: true },
|
|
185
|
+
// Only a GET
|
|
186
|
+
effects: NETWORK_READ,
|
|
187
|
+
})
|
|
188
|
+
// The promise gives the stream after the status: invokeProcedure reads a returned async
|
|
189
|
+
// iterable as a stream, and the handler types of the core do not describe that case
|
|
190
|
+
.handler(((input: HttpNdjsonInput, ctx: ScopeContext) => httpNdjson(input, ctx, settings)) as unknown as ProcedureHandler<HttpNdjsonInput, HttpNdjsonOutput>)
|
|
191
|
+
.build();
|
|
192
|
+
|
|
193
|
+
const httpSseProcedure = createProcedure()
|
|
194
|
+
.path(["http", "sse"])
|
|
195
|
+
.input(zodAdapter<HttpSseInput>(HttpSseInputSchema))
|
|
196
|
+
.output(zodAdapter<HttpSseOutput>(HttpSseOutputSchema))
|
|
197
|
+
.meta({
|
|
198
|
+
description: "Read a stream of server-sent events",
|
|
199
|
+
args: ["url"],
|
|
200
|
+
shorts: { timeoutMs: "t" },
|
|
201
|
+
output: "json",
|
|
202
|
+
streaming: true,
|
|
203
|
+
// An event stream can run without an end: roadmap R15
|
|
204
|
+
stream: { unbounded: true },
|
|
205
|
+
// Only a GET
|
|
206
|
+
effects: NETWORK_READ,
|
|
207
|
+
})
|
|
208
|
+
.handler(((input: HttpSseInput, ctx: ScopeContext) => httpSse(input, ctx, settings)) as unknown as ProcedureHandler<HttpSseInput, HttpSseOutput>)
|
|
209
|
+
.build();
|
|
210
|
+
|
|
211
|
+
return [httpRequestProcedure, httpJsonProcedure, httpNdjsonProcedure, httpSseProcedure];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The module of this package, for `createRegistry()`: its procedures as data. It reaches each
|
|
216
|
+
* public host and refuses loopback, private, link-local and the other special addresses.
|
|
217
|
+
* Importing this file registers nothing.
|
|
218
|
+
*/
|
|
219
|
+
export const httpModule: ProcedureModule = defineModule("@procedures/http", httpProcedures(DEFAULT_SETTINGS));
|
|
220
|
+
|
|
221
|
+
export default httpModule;
|
|
222
|
+
|
|
223
|
+
/** The options of `createHttpModule()`: the host policy and the size limits of the host. */
|
|
224
|
+
export interface HttpModuleOptions extends HostPolicyOptions {
|
|
225
|
+
/** The largest body of a request, in bytes. The default is 10 MiB. */
|
|
226
|
+
readonly maxRequestBytes?: number | undefined;
|
|
227
|
+
/** The largest body of a response, in bytes: the maximum of each input. The default is 10 MiB. */
|
|
228
|
+
readonly maxResponseBytes?: number | undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* This function gives the module of this package with a host policy and size limits. Without
|
|
233
|
+
* options, it gives `httpModule`. A host gives it to `createRegistry()` before its bundles, so it
|
|
234
|
+
* replaces the `httpModule` that a bundle requires (the first module of a name counts). A bad
|
|
235
|
+
* entry of `allow` throws here.
|
|
236
|
+
*
|
|
237
|
+
* ```ts
|
|
238
|
+
* createRegistry(createHttpModule({ allow: ["localhost:11434", "10.0.0.0/8"] }));
|
|
239
|
+
* ```
|
|
240
|
+
*/
|
|
241
|
+
export function createHttpModule(options: HttpModuleOptions = {}): ProcedureModule {
|
|
242
|
+
if (Object.values(options).every((value) => value === undefined)) return httpModule;
|
|
243
|
+
const settings: HttpSettings = {
|
|
244
|
+
policy: createHostPolicy(options),
|
|
245
|
+
maxRequestBytes: options.maxRequestBytes ?? DEFAULT_MAX_BYTES,
|
|
246
|
+
maxResponseBytes: options.maxResponseBytes ?? DEFAULT_MAX_BYTES,
|
|
247
|
+
};
|
|
248
|
+
return defineModule("@procedures/http", httpProcedures(settings));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* This function registers the procedures of @procedures/http in a registry. The default is the
|
|
253
|
+
* global registry. The options are the options of `createHttpModule()`.
|
|
254
|
+
*/
|
|
255
|
+
export function registerHttpProcedures(registry: ProcedureRegistry = PROCEDURE_REGISTRY, options: HttpModuleOptions = {}): void {
|
|
256
|
+
registerModules(registry, [createHttpModule(options)]);
|
|
257
|
+
}
|