@sdxc/outbound 2026.10.9 → 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.
Files changed (3) hide show
  1. package/README.md +219 -72
  2. package/dist/resolve.js +11 -1
  3. 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 that fetches a feed someone subscribed to, a page someone saved or an image a post embeds has to decide where it is willing to go, re-decide on every redirect, stop waiting at some point, and stop reading at some size. This package answers those four questions with plain functions that call the global `fetch` and answer a `Result`. Checking and following matter only for the public internet; reading within a cap works on any `Request` or `Response`, including one from a Durable Object stub, a service binding or an incoming request.
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
- ```bash
12
+ ```sh
10
13
  npm add @sdxc/outbound
11
14
  ```
12
15
 
13
- Every function answers a `Result` from [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result), installed with this package.
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
- ### Checking A URL
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: OutboundError { code: "refused-address" }
24
- checkUrl("https://printer.local/"); // failure: OutboundError { code: "refused-host" }
25
- checkUrl("https://user:pass@example.com/"); // failure: OutboundError { code: "refused-credentials" }
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
- ### Following A URL And Reading The Body
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(url, {
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 body = await readText(followed.data.response, { maxBytes: 2 * 1024 * 1024 });
42
- if (isFailure(body)) return body; // too-large, timeout or network
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
- The `timeout` covers the whole chain **and** the body read afterwards.
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
- ### Capping A Body That Did Not Come From `fetch`
57
+ ### Handle the failure
48
58
 
49
59
  ```typescript
50
- import { readBytes } from "@sdxc/outbound";
60
+ import { follow } from "@sdxc/outbound";
61
+ import { isFailure } from "@sdxc/result";
51
62
 
52
- let body = await readBytes(request, { maxBytes: 1024 * 1024 });
53
- if (isFailure(body)) return new Response(null, { status: 413 });
54
- verifySignature(body.data.data);
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
- ### Streaming A Capped Body Through
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?): Result<URL, OutboundError>`
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
- ### `follow(input, options?): Promise<Result<Followed, OutboundError>>`
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
- Requests a URL with `redirect: "manual"` and `credentials: "omit"`, and walks the redirect chain itself. Every hop passes `checkUrl` with the same options, so a redirect cannot reach somewhere the first URL could not, and the same headers are sent on every hop. A redirect's body is released before the next hop. The final response is answered whatever its status: mapping a status to an error is the caller's.
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
- Options, beside every `checkUrl` option:
91
+ ### `follow(input: string | URL, options?: FollowOptions): Promise<Result<Followed, OutboundError>>`
89
92
 
90
- - `method` — `"GET"` (default) or `"HEAD"`. A request with a body is a single `fetch` to a URL `checkUrl` passed.
91
- - `headers` — sent on every hop.
92
- - `timeout` — a number of milliseconds or a duration string such as `"8 seconds"`.
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
- `Followed` is `{ response, url, redirects }`, where `url` is where the chain ended. The response's body carries the deadline, so reading it after the deadline fails with `timeout`.
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?): Promise<Result<string[], OutboundError>>`
106
+ ### `resolveHost(url: URL, options?: { signal?: AbortSignal }): Promise<Result<string[], OutboundError>>`
100
107
 
101
- Resolves the `A` and `AAAA` records of a URL's host and answers every address when all are public. An address literal answers itself. `refused-host` for a name that does not exist or has no address, `refused-address` for any address that is not public or cannot be read, and `network` when the resolver fails. `options.signal` aborts both lookups.
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<{ text, bytes }, OutboundError>>`
114
+ ### `readText(message: Request | Response, options: { maxBytes: number }): Promise<Result<ReadText, OutboundError>>`
104
115
 
105
- Reads a `Request` or `Response` body as UTF-8 text. A `Content-Length` over the cap fails before any of the body is read; the count over the stream is what enforces the cap, so a body that lies about its length fails at the same byte and the stream is cancelled.
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<{ data, bytes }, OutboundError>>`
120
+ ### `readBytes(message: Request | Response, options: { maxBytes: number }): Promise<Result<ReadBytes, OutboundError>>`
108
121
 
109
- The same read, keeping the bytes exactly as they arrived in a `Uint8Array`.
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
- The same status and headers, with a body that errors with a `too-large` `OutboundError` at the first chunk past the cap. For a body passed through rather than read.
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
- Carries `code`, the `url` it concerns, and `retryable`.
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
- ## Pattern: Mapping To Your Own Errors
152
+ ### Types
137
153
 
138
- Keep your package's or app's error type and translate at the boundary.
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 FetchFeedError extends Error {
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(url: string) {
154
- let followed = await follow(url, { timeout: "10 seconds" });
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 FetchFeedError(followed.error.message, followed.error.retryable));
182
+ return failure(new FeedError(followed.error.message, followed.error.retryable));
157
183
  }
158
- if (followed.data.response.status === 304) return success(null);
159
- return success(followed.data.response);
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: DNS Rebinding
228
+ ## Pattern: Capping An Incoming Body
164
229
 
165
- `resolve: true` and `resolveHost` catch a public name whose records point inside a network. A Worker cannot pin its connection to the addresses that were checked, so a name that answers with a public address to the check and a private one to the connection still gets through. This narrows DNS rebinding and leaves it open. What holds regardless is architectural: a Worker that reaches its own storage through bindings rather than URLs has nothing private for an escaped request to reach.
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/dist/resolve.js CHANGED
@@ -26,7 +26,7 @@ export async function resolveHost(url, options = {}) {
26
26
  let literal = IP.parse(url.hostname);
27
27
  if (isSuccess(literal))
28
28
  return success([literal.data.toString()]);
29
- let host = url.hostname.replace(/\.+$/u, "");
29
+ let host = withoutTrailingDots(url.hostname);
30
30
  let lookup = options.signal ? { signal: options.signal } : {};
31
31
  let [a, aaaa] = await Promise.all([resolve(host, "A", lookup), resolve(host, "AAAA", lookup)]);
32
32
  if (isFailure(a))
@@ -60,3 +60,13 @@ function lookupFailed(url, host, error) {
60
60
  cause: error,
61
61
  });
62
62
  }
63
+ /**
64
+ * The host name without the root-zone dots that end a fully qualified name, scanned from
65
+ * the end so a name holding thousands of dots still costs linear time.
66
+ */
67
+ function withoutTrailingDots(hostname) {
68
+ let end = hostname.length;
69
+ while (end > 0 && hostname[end - 1] === ".")
70
+ end--;
71
+ return hostname.slice(0, end);
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/outbound",
3
- "version": "2026.10.9",
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.9",
12
- "@sdxc/duration": "2026.10.6",
13
- "@sdxc/ip": "2026.10.6",
14
- "@sdxc/result": "2026.10.6"
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": "64fa732ed0a8ca8693a35aeef6345ae5be2534ef",
16
+ "gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
17
17
  "publishConfig": {
18
18
  "access": "public"
19
19
  },