@procedures/http 0.0.0-stage → 0.2.3
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/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/package.json
CHANGED
|
@@ -1,6 +1,82 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package",
|
|
2
3
|
"name": "@procedures/http",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
4
|
+
"version": "0.2.3",
|
|
5
|
+
"description": "HTTP as procedures: request, JSON, NDJSON and server-sent events, with a host allowlist that refuses private addresses, size limits and the deadline of the call.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"procedures",
|
|
8
|
+
"http",
|
|
9
|
+
"fetch",
|
|
10
|
+
"ndjson",
|
|
11
|
+
"server-sent-events",
|
|
12
|
+
"sse",
|
|
13
|
+
"ssrf",
|
|
14
|
+
"allowlist"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://mark1russell7.github.io/client/",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/mark1russell7/client/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/mark1russell7/client.git",
|
|
23
|
+
"directory": "packages/http"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"author": "Mark Russell (https://github.com/mark1russell7)",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./module": {
|
|
36
|
+
"types": "./dist/module.d.ts",
|
|
37
|
+
"import": "./dist/module.js"
|
|
38
|
+
},
|
|
39
|
+
"./register": {
|
|
40
|
+
"types": "./dist/register.d.ts",
|
|
41
|
+
"import": "./dist/register.js"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"src",
|
|
47
|
+
"!src/**/*.test.ts",
|
|
48
|
+
"!src/**/*.test.tsx",
|
|
49
|
+
"!src/**/__tests__",
|
|
50
|
+
"CHANGELOG.md"
|
|
51
|
+
],
|
|
52
|
+
"sideEffects": [
|
|
53
|
+
"./dist/register.js"
|
|
54
|
+
],
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@mark1russell7/cue": "^1.0.0",
|
|
57
|
+
"@types/node": "^22.0.0",
|
|
58
|
+
"typescript": "^7.0.2",
|
|
59
|
+
"vitest": "^5.0.3"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=22"
|
|
63
|
+
},
|
|
64
|
+
"publishConfig": {
|
|
65
|
+
"access": "public",
|
|
66
|
+
"provenance": true
|
|
67
|
+
},
|
|
68
|
+
"client": {
|
|
69
|
+
"procedures": "./dist/register.js"
|
|
70
|
+
},
|
|
71
|
+
"dependencies": {
|
|
72
|
+
"@procedures/client": "0.2.3",
|
|
73
|
+
"zod": "^4.6.5"
|
|
74
|
+
},
|
|
75
|
+
"scripts": {
|
|
76
|
+
"build": "tsc -b",
|
|
77
|
+
"typecheck": "tsc --noEmit",
|
|
78
|
+
"clean": "rm -rf dist .tsbuildinfo",
|
|
79
|
+
"test": "vitest run",
|
|
80
|
+
"test:watch": "vitest"
|
|
81
|
+
}
|
|
6
82
|
}
|
package/src/address.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP addresses: the parse, the CIDR ranges and the special-purpose ranges (roadmap R38).
|
|
3
|
+
*
|
|
4
|
+
* A public address is an address that the IANA registries of special-purpose addresses do not
|
|
5
|
+
* name. The HTTP procedures refuse each other address by default: loopback, private, link-local,
|
|
6
|
+
* shared (CGNAT), multicast, documentation, benchmarking and reserved addresses. An IPv6 address
|
|
7
|
+
* that holds an IPv4 address (IPv4-mapped, NAT64, 6to4) has the class of the IPv4 address.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isIP } from "node:net";
|
|
11
|
+
|
|
12
|
+
/** An IP address as bytes: 4 bytes for IPv4, 16 bytes for IPv6. */
|
|
13
|
+
export type AddressBytes = Uint8Array;
|
|
14
|
+
|
|
15
|
+
/** The bytes of an IPv4 address in dotted decimal, or undefined. */
|
|
16
|
+
function ipv4Bytes(text: string): AddressBytes | undefined {
|
|
17
|
+
const parts = text.split(".");
|
|
18
|
+
if (parts.length !== 4) return undefined;
|
|
19
|
+
const bytes = new Uint8Array(4);
|
|
20
|
+
for (let i = 0; i < 4; i++) {
|
|
21
|
+
const part = parts[i]!;
|
|
22
|
+
if (!/^(0|[1-9][0-9]{0,2})$/.test(part)) return undefined;
|
|
23
|
+
const value = Number(part);
|
|
24
|
+
if (value > 255) return undefined;
|
|
25
|
+
bytes[i] = value;
|
|
26
|
+
}
|
|
27
|
+
return bytes;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The bytes of an IPv6 address, or undefined. A zone (`%eth0`) is removed. */
|
|
31
|
+
function ipv6Bytes(text: string): AddressBytes | undefined {
|
|
32
|
+
let address = text.startsWith("[") && text.endsWith("]") ? text.slice(1, -1) : text;
|
|
33
|
+
const zone = address.indexOf("%");
|
|
34
|
+
if (zone >= 0) address = address.slice(0, zone);
|
|
35
|
+
if (isIP(address) !== 6) return undefined;
|
|
36
|
+
// An IPv4 tail: "::ffff:1.2.3.4"
|
|
37
|
+
const groups: number[] = [];
|
|
38
|
+
const parseGroups = (part: string): number[] | undefined => {
|
|
39
|
+
if (part === "") return [];
|
|
40
|
+
const out: number[] = [];
|
|
41
|
+
for (const group of part.split(":")) {
|
|
42
|
+
if (group.includes(".")) {
|
|
43
|
+
const v4 = ipv4Bytes(group);
|
|
44
|
+
if (!v4) return undefined;
|
|
45
|
+
out.push((v4[0]! << 8) | v4[1]!, (v4[2]! << 8) | v4[3]!);
|
|
46
|
+
} else {
|
|
47
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return undefined;
|
|
48
|
+
out.push(parseInt(group, 16));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
};
|
|
53
|
+
const double = address.indexOf("::");
|
|
54
|
+
if (double >= 0) {
|
|
55
|
+
const head = parseGroups(address.slice(0, double));
|
|
56
|
+
const tail = parseGroups(address.slice(double + 2));
|
|
57
|
+
if (!head || !tail || head.length + tail.length > 7) return undefined;
|
|
58
|
+
groups.push(...head, ...new Array<number>(8 - head.length - tail.length).fill(0), ...tail);
|
|
59
|
+
} else {
|
|
60
|
+
const all = parseGroups(address);
|
|
61
|
+
if (!all || all.length !== 8) return undefined;
|
|
62
|
+
groups.push(...all);
|
|
63
|
+
}
|
|
64
|
+
const bytes = new Uint8Array(16);
|
|
65
|
+
groups.forEach((group, index) => {
|
|
66
|
+
bytes[index * 2] = group >> 8;
|
|
67
|
+
bytes[index * 2 + 1] = group & 0xff;
|
|
68
|
+
});
|
|
69
|
+
return bytes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The bytes of an IPv4 or IPv6 address (brackets and a zone are allowed), or undefined. */
|
|
73
|
+
export function parseAddress(text: string): AddressBytes | undefined {
|
|
74
|
+
return ipv4Bytes(text) ?? ipv6Bytes(text);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The text of an address: dotted decimal for IPv4, the compressed form for IPv6. */
|
|
78
|
+
export function formatAddress(bytes: AddressBytes): string {
|
|
79
|
+
if (bytes.length === 4) return [...bytes].join(".");
|
|
80
|
+
const groups: number[] = [];
|
|
81
|
+
for (let i = 0; i < 16; i += 2) groups.push((bytes[i]! << 8) | bytes[i + 1]!);
|
|
82
|
+
// The longest run of two or more zero groups becomes "::"
|
|
83
|
+
let bestStart = -1;
|
|
84
|
+
let bestLength = 0;
|
|
85
|
+
for (let i = 0; i < 8; ) {
|
|
86
|
+
if (groups[i] !== 0) {
|
|
87
|
+
i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
let j = i;
|
|
91
|
+
while (j < 8 && groups[j] === 0) j++;
|
|
92
|
+
if (j - i > bestLength && j - i >= 2) {
|
|
93
|
+
bestStart = i;
|
|
94
|
+
bestLength = j - i;
|
|
95
|
+
}
|
|
96
|
+
i = j;
|
|
97
|
+
}
|
|
98
|
+
const hex = groups.map((group) => group.toString(16));
|
|
99
|
+
if (bestStart < 0) return hex.join(":");
|
|
100
|
+
return `${hex.slice(0, bestStart).join(":")}::${hex.slice(bestStart + bestLength).join(":")}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A range of addresses: the bytes of its first address and the length of its prefix in bits. */
|
|
104
|
+
export interface AddressRange {
|
|
105
|
+
readonly bytes: AddressBytes;
|
|
106
|
+
readonly prefix: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A range from CIDR text (`10.0.0.0/8`, `fd00::/8`) or one address. Undefined for bad text. */
|
|
110
|
+
export function parseRange(text: string): AddressRange | undefined {
|
|
111
|
+
const slash = text.lastIndexOf("/");
|
|
112
|
+
const bytes = parseAddress(slash >= 0 ? text.slice(0, slash) : text);
|
|
113
|
+
if (!bytes) return undefined;
|
|
114
|
+
const bits = bytes.length * 8;
|
|
115
|
+
if (slash < 0) return { bytes, prefix: bits };
|
|
116
|
+
const prefixText = text.slice(slash + 1);
|
|
117
|
+
if (!/^[0-9]{1,3}$/.test(prefixText)) return undefined;
|
|
118
|
+
const prefix = Number(prefixText);
|
|
119
|
+
return prefix <= bits ? { bytes, prefix } : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** True when the range has the address. An IPv4 range does not have an IPv6 address. */
|
|
123
|
+
export function rangeHas(range: AddressRange, address: AddressBytes): boolean {
|
|
124
|
+
if (range.bytes.length !== address.length) return false;
|
|
125
|
+
let bits = range.prefix;
|
|
126
|
+
for (let i = 0; bits > 0; i++, bits -= 8) {
|
|
127
|
+
const mask = bits >= 8 ? 0xff : (0xff << (8 - bits)) & 0xff;
|
|
128
|
+
if ((range.bytes[i]! & mask) !== (address[i]! & mask)) return false;
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function range(text: string): AddressRange {
|
|
134
|
+
const parsed = parseRange(text);
|
|
135
|
+
if (!parsed) throw new Error(`Bad range ${text}`);
|
|
136
|
+
return parsed;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The IPv4 ranges that are not public, with their names (IANA IPv4 Special-Purpose Address Registry). */
|
|
140
|
+
const IPV4_SPECIAL: ReadonlyArray<readonly [AddressRange, string]> = [
|
|
141
|
+
[range("0.0.0.0/8"), "this-network"],
|
|
142
|
+
[range("10.0.0.0/8"), "private"],
|
|
143
|
+
[range("100.64.0.0/10"), "shared (CGNAT)"],
|
|
144
|
+
[range("127.0.0.0/8"), "loopback"],
|
|
145
|
+
[range("169.254.0.0/16"), "link-local"],
|
|
146
|
+
[range("172.16.0.0/12"), "private"],
|
|
147
|
+
[range("192.0.0.0/24"), "IETF protocol assignment"],
|
|
148
|
+
[range("192.0.2.0/24"), "documentation"],
|
|
149
|
+
[range("192.88.99.0/24"), "6to4 relay"],
|
|
150
|
+
[range("192.168.0.0/16"), "private"],
|
|
151
|
+
[range("198.18.0.0/15"), "benchmarking"],
|
|
152
|
+
[range("198.51.100.0/24"), "documentation"],
|
|
153
|
+
[range("203.0.113.0/24"), "documentation"],
|
|
154
|
+
[range("224.0.0.0/4"), "multicast"],
|
|
155
|
+
[range("240.0.0.0/4"), "reserved"],
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
/** The IPv6 ranges in the global unicast range that are not public (IANA IPv6 Special-Purpose Address Registry). */
|
|
159
|
+
const IPV6_SPECIAL: ReadonlyArray<readonly [AddressRange, string]> = [
|
|
160
|
+
[range("2001::/23"), "IETF protocol assignment"],
|
|
161
|
+
[range("2001:db8::/32"), "documentation"],
|
|
162
|
+
[range("3fff::/20"), "documentation"],
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
const GLOBAL_UNICAST = range("2000::/3");
|
|
166
|
+
const IPV4_MAPPED = range("::ffff:0:0/96");
|
|
167
|
+
const NAT64 = range("64:ff9b::/96");
|
|
168
|
+
const SIX_TO_FOUR = range("2002::/16");
|
|
169
|
+
const IPV6_NAMED: ReadonlyArray<readonly [AddressRange, string]> = [
|
|
170
|
+
[range("::/128"), "unspecified"],
|
|
171
|
+
[range("::1/128"), "loopback"],
|
|
172
|
+
[range("fc00::/7"), "unique local (private)"],
|
|
173
|
+
[range("fe80::/10"), "link-local"],
|
|
174
|
+
[range("fec0::/10"), "site-local"],
|
|
175
|
+
[range("ff00::/8"), "multicast"],
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The class of an address that is not public, or undefined for a public address. The class is a
|
|
180
|
+
* name for a message: `loopback`, `private`, `link-local` and the others.
|
|
181
|
+
*/
|
|
182
|
+
export function specialClass(address: AddressBytes): string | undefined {
|
|
183
|
+
if (address.length === 4) {
|
|
184
|
+
for (const [special, name] of IPV4_SPECIAL) if (rangeHas(special, address)) return name;
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
// An IPv6 address that holds an IPv4 address has the class of that address
|
|
188
|
+
if (rangeHas(IPV4_MAPPED, address) || rangeHas(NAT64, address)) {
|
|
189
|
+
const inner = specialClass(address.slice(12, 16));
|
|
190
|
+
return inner === undefined ? undefined : `${inner} (in IPv6)`;
|
|
191
|
+
}
|
|
192
|
+
if (rangeHas(SIX_TO_FOUR, address)) {
|
|
193
|
+
const inner = specialClass(address.slice(2, 6));
|
|
194
|
+
return inner === undefined ? undefined : `${inner} (in 6to4)`;
|
|
195
|
+
}
|
|
196
|
+
for (const [special, name] of IPV6_NAMED) if (rangeHas(special, address)) return name;
|
|
197
|
+
if (!rangeHas(GLOBAL_UNICAST, address)) return "reserved";
|
|
198
|
+
for (const [special, name] of IPV6_SPECIAL) if (rangeHas(special, address)) return name;
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** True for a public address: no special-purpose range has it. */
|
|
203
|
+
export function isPublicAddress(address: string | AddressBytes): boolean {
|
|
204
|
+
const bytes = typeof address === "string" ? parseAddress(address) : address;
|
|
205
|
+
return bytes !== undefined && specialClass(bytes) === undefined;
|
|
206
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The errors of the HTTP procedures that are not a refusal or a stop. A server sends them with
|
|
3
|
+
* the code `HANDLER_ERROR` and the message. `code` tells the kind to a caller in the process.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The kinds of `HttpError`. */
|
|
7
|
+
export type HttpErrorCode =
|
|
8
|
+
/** The URL is not an `http:` or `https:` URL. */
|
|
9
|
+
| "BAD_URL"
|
|
10
|
+
/** The body of the request is larger than the limit of the module. */
|
|
11
|
+
| "REQUEST_TOO_LARGE"
|
|
12
|
+
/** The body of the response is larger than `maxResponseBytes`. */
|
|
13
|
+
| "RESPONSE_TOO_LARGE"
|
|
14
|
+
/** One NDJSON line is larger than `maxLineBytes`. */
|
|
15
|
+
| "LINE_TOO_LARGE"
|
|
16
|
+
/** One server-sent event is larger than `maxEventBytes`. */
|
|
17
|
+
| "EVENT_TOO_LARGE"
|
|
18
|
+
/** The redirects are more than `maxRedirects`. */
|
|
19
|
+
| "TOO_MANY_REDIRECTS"
|
|
20
|
+
/** The response is a redirect, and the input has `redirect: "error"`. */
|
|
21
|
+
| "REDIRECT"
|
|
22
|
+
/** The status of the response is not in 200 to 299 (`http.json`, `http.ndjson`, `http.sse`). */
|
|
23
|
+
| "HTTP_STATUS"
|
|
24
|
+
/** The body is not JSON, or a line of NDJSON is not JSON. */
|
|
25
|
+
| "BAD_JSON"
|
|
26
|
+
/** The content type of an event stream is not `text/event-stream`. */
|
|
27
|
+
| "BAD_CONTENT_TYPE"
|
|
28
|
+
/** The connection failed: no answer, a reset, a TLS error. */
|
|
29
|
+
| "CONNECTION";
|
|
30
|
+
|
|
31
|
+
/** An error of an HTTP procedure. */
|
|
32
|
+
export class HttpError extends Error {
|
|
33
|
+
override readonly name = "HttpError";
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
readonly code: HttpErrorCode,
|
|
37
|
+
message: string,
|
|
38
|
+
/** The status of the response, when the server answered. */
|
|
39
|
+
readonly status?: number,
|
|
40
|
+
options?: ErrorOptions,
|
|
41
|
+
) {
|
|
42
|
+
super(message, options);
|
|
43
|
+
}
|
|
44
|
+
}
|