@sdxc/outbound 2026.10.10 → 2026.10.13
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/README.md +219 -72
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -2,123 +2,139 @@
|
|
|
2
2
|
|
|
3
3
|
Check, follow and read URLs a stranger chose: public hosts on every redirect, one deadline, bounded bodies.
|
|
4
4
|
|
|
5
|
-
A Worker
|
|
5
|
+
A Worker fetching a feed someone subscribed to, a page someone saved or an image a post embeds has
|
|
6
|
+
to decide where it will go, re-decide on every redirect, stop waiting at some point and stop reading
|
|
7
|
+
at some size. These functions answer those four questions over the global `fetch`, and every one
|
|
8
|
+
answers a `Result` instead of throwing. The body readers work on any `Request` or `Response`.
|
|
6
9
|
|
|
7
10
|
## Installation
|
|
8
11
|
|
|
9
|
-
```
|
|
12
|
+
```sh
|
|
10
13
|
npm add @sdxc/outbound
|
|
11
14
|
```
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
Results come from [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result), installed with this
|
|
17
|
+
package.
|
|
14
18
|
|
|
15
19
|
## Usage
|
|
16
20
|
|
|
17
|
-
###
|
|
21
|
+
### Check a URL
|
|
18
22
|
|
|
19
23
|
```typescript
|
|
20
24
|
import { checkUrl } from "@sdxc/outbound";
|
|
21
25
|
|
|
22
26
|
checkUrl("https://example.com/feed.xml"); // success: URL
|
|
23
|
-
checkUrl("http://169.254.169.254/latest"); // failure:
|
|
24
|
-
checkUrl("https://printer.local/"); // failure:
|
|
25
|
-
checkUrl("https://user:pass@example.com/"); // failure:
|
|
27
|
+
checkUrl("http://169.254.169.254/latest"); // failure: refused-address
|
|
28
|
+
checkUrl("https://printer.local/"); // failure: refused-host
|
|
29
|
+
checkUrl("https://user:pass@example.com/"); // failure: refused-credentials
|
|
30
|
+
checkUrl("https://example.com:8080/", { ports: "default" }); // failure: refused-port
|
|
26
31
|
```
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
No request or lookup is made: the scheme, credentials, port and host are judged from the URL alone.
|
|
34
|
+
|
|
35
|
+
### Follow it and read the body
|
|
29
36
|
|
|
30
37
|
```typescript
|
|
31
38
|
import { follow, readText } from "@sdxc/outbound";
|
|
32
39
|
import { isFailure } from "@sdxc/result";
|
|
33
40
|
|
|
34
|
-
let followed = await follow(
|
|
41
|
+
let followed = await follow("https://example.com/feed.xml", {
|
|
35
42
|
headers: { accept: "application/atom+xml", "user-agent": "ExampleReader/1.0" },
|
|
36
43
|
timeout: "8 seconds",
|
|
37
|
-
maxRedirects: 5,
|
|
38
44
|
});
|
|
39
45
|
if (isFailure(followed)) return followed;
|
|
40
46
|
|
|
41
|
-
let
|
|
42
|
-
|
|
47
|
+
let { response, url, redirects } = followed.data;
|
|
48
|
+
let body = await readText(response, { maxBytes: 2 * 1024 * 1024 });
|
|
49
|
+
if (isFailure(body)) return body;
|
|
50
|
+
|
|
51
|
+
body.data.text; // the feed, at most 2 MB
|
|
43
52
|
```
|
|
44
53
|
|
|
45
|
-
|
|
54
|
+
Every redirect passes the same check as the first URL, and `timeout` covers the whole chain plus the
|
|
55
|
+
body read afterwards. The final response comes back whatever its status.
|
|
46
56
|
|
|
47
|
-
###
|
|
57
|
+
### Handle the failure
|
|
48
58
|
|
|
49
59
|
```typescript
|
|
50
|
-
import {
|
|
60
|
+
import { follow } from "@sdxc/outbound";
|
|
61
|
+
import { isFailure } from "@sdxc/result";
|
|
51
62
|
|
|
52
|
-
let
|
|
53
|
-
if (isFailure(
|
|
54
|
-
|
|
63
|
+
let followed = await follow(input, { timeout: "8 seconds" });
|
|
64
|
+
if (isFailure(followed)) {
|
|
65
|
+
let { code, url, retryable } = followed.error;
|
|
66
|
+
if (retryable) return scheduleRetry(url); // timeout or network
|
|
67
|
+
return rejectUrl(code); // refused-host, too-many-redirects, …
|
|
68
|
+
}
|
|
55
69
|
```
|
|
56
70
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
```typescript
|
|
60
|
-
import { follow, limitBody } from "@sdxc/outbound";
|
|
61
|
-
|
|
62
|
-
let followed = await follow(imageUrl, { timeout: "10 seconds", ports: "default" });
|
|
63
|
-
if (isFailure(followed)) return new Response(null, { status: 404 });
|
|
64
|
-
|
|
65
|
-
return limitBody(followed.data.response, { maxBytes: 5 * 1024 * 1024 });
|
|
66
|
-
```
|
|
71
|
+
Every failure is one `OutboundError`; its `code` says why and `retryable` says whether a later
|
|
72
|
+
attempt can succeed.
|
|
67
73
|
|
|
68
74
|
## API
|
|
69
75
|
|
|
70
|
-
### `checkUrl(input, options
|
|
71
|
-
|
|
72
|
-
Parses a URL and refuses it unless every rule passes. No request or lookup is made.
|
|
73
|
-
|
|
74
|
-
- Only `http:` and `https:` pass, and a URL carrying a username or password is refused.
|
|
75
|
-
- An address literal passes when it is on the public internet, judged by [`@sdxc/ip`](https://www.npmjs.com/package/@sdxc/ip). IPv4-mapped and NAT64 addresses are judged by the IPv4 inside.
|
|
76
|
-
- A name passes when it has a dot and does not end in a reserved suffix: `localhost`, `local`, `internal`, `intranet`, `lan`, `corp`, `private`, `home.arpa`, `test`, `invalid` or `example`.
|
|
77
|
-
|
|
78
|
-
Options:
|
|
79
|
-
|
|
80
|
-
- `hosts` — `"public"` (default) applies the host rules; `"any"` keeps only the scheme and credential rules, for a caller that reaches its own network on purpose.
|
|
81
|
-
- `literals` — `"public"` (default) allows a public address literal; `"refuse"` refuses every literal.
|
|
82
|
-
- `ports` — `"any"` (default), `"default"` for only the scheme's own port, or a list such as `[80, 443]`, where an omitted port counts as the scheme's own.
|
|
76
|
+
### `checkUrl(input: string | URL, options?: CheckOptions): Result<URL, OutboundError>`
|
|
83
77
|
|
|
84
|
-
|
|
78
|
+
Parses a URL and answers it when every rule passes. Only `http:` and `https:` pass, and a URL with a
|
|
79
|
+
username or password is refused. An address literal passes when it is public, as judged by
|
|
80
|
+
[`@sdxc/ip`](https://www.npmjs.com/package/@sdxc/ip); a name passes when it has a dot and ends in
|
|
81
|
+
none of `localhost`, `local`, `internal`, `intranet`, `lan`, `corp`, `private`, `home.arpa`,
|
|
82
|
+
`test`, `invalid` or `example`. The rules run on the parsed URL, so hex, octal and trailing-dot
|
|
83
|
+
spellings are judged by what they normalize to.
|
|
85
84
|
|
|
86
|
-
|
|
85
|
+
- `hosts`: `"public"` (default) applies the host rules; `"any"` keeps only the scheme and
|
|
86
|
+
credential rules, for a caller reaching its own network on purpose.
|
|
87
|
+
- `literals`: `"public"` (default) allows a public address literal; `"refuse"` refuses every one.
|
|
88
|
+
- `ports`: `"any"` (default), `"default"` for only the scheme's own port, or a list such as
|
|
89
|
+
`[80, 443]`, where an omitted port counts as the scheme's own.
|
|
87
90
|
|
|
88
|
-
|
|
91
|
+
### `follow(input: string | URL, options?: FollowOptions): Promise<Result<Followed, OutboundError>>`
|
|
89
92
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
- `signal` — the caller's own abort, combined with `timeout` through `AbortSignal.any`.
|
|
94
|
-
- `maxRedirects` — default `5`.
|
|
95
|
-
- `resolve` — resolves every hop's name over DNS-over-HTTPS (Cloudflare's resolver, through [`@sdxc/doh`](https://www.npmjs.com/package/@sdxc/doh)) and refuses it when any address is not public. Default `false`.
|
|
93
|
+
Requests a URL anonymously (`credentials: "omit"`, only the headers given) and walks redirects
|
|
94
|
+
itself, checking every hop with the same `checkUrl` options and releasing each redirect's body.
|
|
95
|
+
Answers the final response whatever its status. Beside every `checkUrl` option:
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
- `method`: `"GET"` (default) or `"HEAD"`.
|
|
98
|
+
- `headers`: sent on every hop.
|
|
99
|
+
- `timeout`: milliseconds or a duration string such as `"8 seconds"`, covering the chain and the
|
|
100
|
+
body read after it.
|
|
101
|
+
- `signal`: the caller's own abort, combined with `timeout`.
|
|
102
|
+
- `maxRedirects`: default `5`.
|
|
103
|
+
- `resolve`: when `true`, resolves every hop's name with `resolveHost` and refuses it unless every
|
|
104
|
+
address is public. Default `false`; skipped with `hosts: "any"`.
|
|
98
105
|
|
|
99
|
-
### `resolveHost(url, options
|
|
106
|
+
### `resolveHost(url: URL, options?: { signal?: AbortSignal }): Promise<Result<string[], OutboundError>>`
|
|
100
107
|
|
|
101
|
-
Resolves the `A` and `AAAA` records
|
|
108
|
+
Resolves the host's `A` and `AAAA` records over DNS-over-HTTPS (through
|
|
109
|
+
[`@sdxc/doh`](https://www.npmjs.com/package/@sdxc/doh)) and answers every address when all are
|
|
110
|
+
public; an address literal answers itself. Fails `refused-host` for a name that does not exist or
|
|
111
|
+
has no address, `refused-address` for any non-public or unreadable address, and `network` when the
|
|
112
|
+
resolver fails.
|
|
102
113
|
|
|
103
|
-
### `readText(message, { maxBytes }): Promise<Result<
|
|
114
|
+
### `readText(message: Request | Response, options: { maxBytes: number }): Promise<Result<ReadText, OutboundError>>`
|
|
104
115
|
|
|
105
|
-
Reads a
|
|
116
|
+
Reads a body as UTF-8 text within a cap. A `Content-Length` over the cap fails before reading;
|
|
117
|
+
otherwise the byte count over the stream enforces it and cancels the stream at the first byte past.
|
|
118
|
+
Fails `too-large`, `timeout` (past a `follow` deadline) or `network`.
|
|
106
119
|
|
|
107
|
-
### `readBytes(message, { maxBytes }): Promise<Result<
|
|
120
|
+
### `readBytes(message: Request | Response, options: { maxBytes: number }): Promise<Result<ReadBytes, OutboundError>>`
|
|
108
121
|
|
|
109
|
-
The same read,
|
|
122
|
+
The same read, answering the exact bytes in a `Uint8Array`.
|
|
110
123
|
|
|
111
|
-
### `limitBody(response, { maxBytes }): Response`
|
|
124
|
+
### `limitBody(response: Response, options: { maxBytes: number }): Response`
|
|
112
125
|
|
|
113
|
-
|
|
126
|
+
Answers a response with the same status and headers whose body errors with a `too-large`
|
|
127
|
+
`OutboundError` at the first chunk past the cap, or immediately when `Content-Length` is over it.
|
|
128
|
+
For a body passed through rather than read.
|
|
114
129
|
|
|
115
|
-
### `release(body)`
|
|
130
|
+
### `release(body: { cancel(): Promise<void> } | null): void`
|
|
116
131
|
|
|
117
132
|
Cancels an unread body, or a reader of one, without waiting on the origin. `null` is ignored.
|
|
118
133
|
|
|
119
134
|
### `OutboundError`
|
|
120
135
|
|
|
121
|
-
|
|
136
|
+
The error inside every `Failure`: `code`, the `url` it concerns (the hop that failed, the input text
|
|
137
|
+
for `invalid-url`, or `""` for a body with no URL), and `retryable`.
|
|
122
138
|
|
|
123
139
|
| `code` | `retryable` | When |
|
|
124
140
|
| --------------------- | ----------- | ------------------------------------------------- |
|
|
@@ -133,15 +149,22 @@ Carries `code`, the `url` it concerns, and `retryable`.
|
|
|
133
149
|
| `timeout` | Yes | The deadline passed |
|
|
134
150
|
| `network` | Yes | `fetch` rejected, a body broke off, or DNS failed |
|
|
135
151
|
|
|
136
|
-
|
|
152
|
+
### Types
|
|
137
153
|
|
|
138
|
-
|
|
154
|
+
`CheckOptions`, `FollowOptions`, `ReadOptions` and `ResolveHostOptions` are the options above.
|
|
155
|
+
`Followed` is `{ response, url, redirects }`, where `url` is where the chain ended and the
|
|
156
|
+
response's body carries the deadline. `ReadText` is `{ text, bytes }` and `ReadBytes` is
|
|
157
|
+
`{ data, bytes }`, `bytes` counting what came off the wire. `OutboundErrorCode` is the union of codes.
|
|
158
|
+
|
|
159
|
+
## Pattern: Fetching A Subscribed Feed
|
|
160
|
+
|
|
161
|
+
Map statuses and codes to your own error, and resolve relative links against where the chain ended.
|
|
139
162
|
|
|
140
163
|
```typescript
|
|
141
|
-
import { follow } from "@sdxc/outbound";
|
|
164
|
+
import { follow, readText, release } from "@sdxc/outbound";
|
|
142
165
|
import { failure, isFailure, success } from "@sdxc/result";
|
|
143
166
|
|
|
144
|
-
class
|
|
167
|
+
class FeedError extends Error {
|
|
145
168
|
constructor(
|
|
146
169
|
message: string,
|
|
147
170
|
readonly retryable: boolean,
|
|
@@ -150,19 +173,143 @@ class FetchFeedError extends Error {
|
|
|
150
173
|
}
|
|
151
174
|
}
|
|
152
175
|
|
|
153
|
-
async function fetchFeed(
|
|
154
|
-
let followed = await follow(
|
|
176
|
+
async function fetchFeed(input: string, etag: string | null) {
|
|
177
|
+
let followed = await follow(input, {
|
|
178
|
+
headers: { accept: "application/atom+xml", ...(etag ? { "if-none-match": etag } : {}) },
|
|
179
|
+
timeout: "10 seconds",
|
|
180
|
+
});
|
|
155
181
|
if (isFailure(followed)) {
|
|
156
|
-
return failure(new
|
|
182
|
+
return failure(new FeedError(followed.error.message, followed.error.retryable));
|
|
157
183
|
}
|
|
158
|
-
|
|
159
|
-
|
|
184
|
+
|
|
185
|
+
let { response, url } = followed.data;
|
|
186
|
+
if (response.status === 304) {
|
|
187
|
+
release(response.body);
|
|
188
|
+
return success(null);
|
|
189
|
+
}
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
release(response.body);
|
|
192
|
+
return failure(new FeedError(`Feed answered ${response.status}`, response.status >= 500));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let body = await readText(response, { maxBytes: 2 * 1024 * 1024 });
|
|
196
|
+
if (isFailure(body)) return failure(new FeedError(body.error.message, body.error.retryable));
|
|
197
|
+
|
|
198
|
+
return success({ xml: body.data.text, base: url, etag: response.headers.get("etag") });
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Pattern: Proxying An Embedded Image
|
|
203
|
+
|
|
204
|
+
Stream a stranger's image through without buffering it, on standard ports only, with a size cap.
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
import { follow, limitBody, release } from "@sdxc/outbound";
|
|
208
|
+
import { isFailure } from "@sdxc/result";
|
|
209
|
+
|
|
210
|
+
async function proxyImage(src: string) {
|
|
211
|
+
let followed = await follow(src, {
|
|
212
|
+
ports: "default",
|
|
213
|
+
timeout: "10 seconds",
|
|
214
|
+
headers: { accept: "image/*" },
|
|
215
|
+
});
|
|
216
|
+
if (isFailure(followed)) return new Response(null, { status: 404 });
|
|
217
|
+
|
|
218
|
+
let { response } = followed.data;
|
|
219
|
+
if (!response.ok || !response.headers.get("content-type")?.startsWith("image/")) {
|
|
220
|
+
release(response.body);
|
|
221
|
+
return new Response(null, { status: 404 });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return limitBody(response, { maxBytes: 5 * 1024 * 1024 });
|
|
160
225
|
}
|
|
161
226
|
```
|
|
162
227
|
|
|
163
|
-
## Pattern:
|
|
228
|
+
## Pattern: Capping An Incoming Body
|
|
164
229
|
|
|
165
|
-
|
|
230
|
+
The readers take any `Request` or `Response`, so a webhook handler reads its own request within a
|
|
231
|
+
cap and verifies a signature over the exact bytes.
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
import { readBytes } from "@sdxc/outbound";
|
|
235
|
+
import { isFailure } from "@sdxc/result";
|
|
236
|
+
|
|
237
|
+
async function handleWebhook(request: Request) {
|
|
238
|
+
let body = await readBytes(request, { maxBytes: 1024 * 1024 });
|
|
239
|
+
if (isFailure(body)) {
|
|
240
|
+
return new Response(null, { status: body.error.code === "too-large" ? 413 : 400 });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!(await verifySignature(request.headers, body.data.data))) {
|
|
244
|
+
return new Response(null, { status: 401 });
|
|
245
|
+
}
|
|
246
|
+
return processEvent(new TextDecoder().decode(body.data.data));
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Pattern: Posting To A URL A Stranger Chose
|
|
251
|
+
|
|
252
|
+
`follow` sends `GET` or `HEAD`. For a request with a body, such as a webhook delivery, check the URL
|
|
253
|
+
yourself, send one `fetch` with `redirect: "manual"`, and treat a redirect as a failure.
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
import { checkUrl, readText, resolveHost } from "@sdxc/outbound";
|
|
257
|
+
import { isFailure } from "@sdxc/result";
|
|
258
|
+
|
|
259
|
+
async function deliver(endpoint: string, payload: unknown) {
|
|
260
|
+
let checked = checkUrl(endpoint, { ports: "default" });
|
|
261
|
+
if (isFailure(checked)) return checked;
|
|
262
|
+
|
|
263
|
+
let resolved = await resolveHost(checked.data);
|
|
264
|
+
if (isFailure(resolved)) return resolved;
|
|
265
|
+
|
|
266
|
+
let response = await fetch(checked.data, {
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: { "content-type": "application/json" },
|
|
269
|
+
body: JSON.stringify(payload),
|
|
270
|
+
redirect: "manual",
|
|
271
|
+
credentials: "omit",
|
|
272
|
+
signal: AbortSignal.timeout(5_000),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
let reply = await readText(response, { maxBytes: 64 * 1024 });
|
|
276
|
+
return { status: response.status, reply };
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Pattern: Checking DNS Answers
|
|
281
|
+
|
|
282
|
+
`resolve: true` refuses a public name whose records point inside a network, on every hop.
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
import { follow } from "@sdxc/outbound";
|
|
286
|
+
|
|
287
|
+
let followed = await follow(input, { resolve: true, literals: "refuse", timeout: "8 seconds" });
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
A Worker cannot pin its connection to the addresses that were checked, so a name answering one
|
|
291
|
+
address to the check and another to the connection still gets through: this narrows DNS rebinding
|
|
292
|
+
without closing it. What holds regardless is reaching your own storage through bindings rather than
|
|
293
|
+
URLs, so an escaped request has nothing private to reach.
|
|
294
|
+
|
|
295
|
+
## Pattern: Reaching Your Own Network On Purpose
|
|
296
|
+
|
|
297
|
+
`hosts: "any"` keeps the scheme and credential rules, the redirect limit, the deadline and the caps,
|
|
298
|
+
for an internal URL an operator configured.
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
import { follow, readText } from "@sdxc/outbound";
|
|
302
|
+
import { isFailure } from "@sdxc/result";
|
|
303
|
+
|
|
304
|
+
let followed = await follow("http://metrics.internal/health", {
|
|
305
|
+
hosts: "any",
|
|
306
|
+
maxRedirects: 0,
|
|
307
|
+
timeout: 2_000,
|
|
308
|
+
});
|
|
309
|
+
if (isFailure(followed)) return followed;
|
|
310
|
+
|
|
311
|
+
let body = await readText(followed.data.response, { maxBytes: 16 * 1024 });
|
|
312
|
+
```
|
|
166
313
|
|
|
167
314
|
## Versioning
|
|
168
315
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/outbound",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.13",
|
|
4
4
|
"description": "Check, follow and read URLs a stranger chose: public hosts on every redirect, one deadline, bounded bodies",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
".": "./dist/index.js"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@sdxc/doh": "2026.10.
|
|
12
|
-
"@sdxc/duration": "2026.10.
|
|
13
|
-
"@sdxc/ip": "2026.10.
|
|
14
|
-
"@sdxc/result": "2026.10.
|
|
11
|
+
"@sdxc/doh": "2026.10.13",
|
|
12
|
+
"@sdxc/duration": "2026.10.13",
|
|
13
|
+
"@sdxc/ip": "2026.10.13",
|
|
14
|
+
"@sdxc/result": "2026.10.13"
|
|
15
15
|
},
|
|
16
|
-
"gitHead": "
|
|
16
|
+
"gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
|
|
17
17
|
"publishConfig": {
|
|
18
18
|
"access": "public"
|
|
19
19
|
},
|