@sdxc/outbound 0.0.0-stage → 2026.10.6

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.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergio Xalambrí
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,191 @@
1
- # Temporary Holding Version
1
+ # @sdxc/outbound
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Check, follow and read URLs a stranger chose: public hosts on every redirect, one deadline, bounded bodies.
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.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm add @sdxc/outbound
11
+ ```
12
+
13
+ Every function answers a `Result` from [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result), installed with this package.
14
+
15
+ ## Usage
16
+
17
+ ### Checking A URL
18
+
19
+ ```typescript
20
+ import { checkUrl } from "@sdxc/outbound";
21
+
22
+ 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" }
26
+ ```
27
+
28
+ ### Following A URL And Reading The Body
29
+
30
+ ```typescript
31
+ import { follow, readText } from "@sdxc/outbound";
32
+ import { isFailure } from "@sdxc/result";
33
+
34
+ let followed = await follow(url, {
35
+ headers: { accept: "application/atom+xml", "user-agent": "ExampleReader/1.0" },
36
+ timeout: "8 seconds",
37
+ maxRedirects: 5,
38
+ });
39
+ if (isFailure(followed)) return followed;
40
+
41
+ let body = await readText(followed.data.response, { maxBytes: 2 * 1024 * 1024 });
42
+ if (isFailure(body)) return body; // too-large, timeout or network
43
+ ```
44
+
45
+ The `timeout` covers the whole chain **and** the body read afterwards.
46
+
47
+ ### Capping A Body That Did Not Come From `fetch`
48
+
49
+ ```typescript
50
+ import { readBytes } from "@sdxc/outbound";
51
+
52
+ let body = await readBytes(request, { maxBytes: 1024 * 1024 });
53
+ if (isFailure(body)) return new Response(null, { status: 413 });
54
+ verifySignature(body.data.data);
55
+ ```
56
+
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
+ ```
67
+
68
+ ## API
69
+
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.
83
+
84
+ ### `follow(input, options?): Promise<Result<Followed, OutboundError>>`
85
+
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.
87
+
88
+ Options, beside every `checkUrl` option:
89
+
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`.
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`.
98
+
99
+ ### `resolveHost(url, options?): Promise<Result<string[], OutboundError>>`
100
+
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.
102
+
103
+ ### `readText(message, { maxBytes }): Promise<Result<{ text, bytes }, OutboundError>>`
104
+
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.
106
+
107
+ ### `readBytes(message, { maxBytes }): Promise<Result<{ data, bytes }, OutboundError>>`
108
+
109
+ The same read, keeping the bytes exactly as they arrived in a `Uint8Array`.
110
+
111
+ ### `limitBody(response, { maxBytes }): Response`
112
+
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.
114
+
115
+ ### `release(body)`
116
+
117
+ Cancels an unread body, or a reader of one, without waiting on the origin. `null` is ignored.
118
+
119
+ ### `OutboundError`
120
+
121
+ Carries `code`, the `url` it concerns, and `retryable`.
122
+
123
+ | `code` | `retryable` | When |
124
+ | --------------------- | ----------- | ------------------------------------------------- |
125
+ | `invalid-url` | No | The input does not parse as a URL |
126
+ | `refused-scheme` | No | Not HTTP(S) |
127
+ | `refused-credentials` | No | The URL carries a username or password |
128
+ | `refused-port` | No | A port the `ports` option does not allow |
129
+ | `refused-host` | No | A reserved name, a single label, or no address |
130
+ | `refused-address` | No | A non-public literal, or a non-public DNS answer |
131
+ | `too-many-redirects` | No | The chain ran past `maxRedirects` |
132
+ | `too-large` | No | The body passed `maxBytes` |
133
+ | `timeout` | Yes | The deadline passed |
134
+ | `network` | Yes | `fetch` rejected, a body broke off, or DNS failed |
135
+
136
+ ## Pattern: Mapping To Your Own Errors
137
+
138
+ Keep your package's or app's error type and translate at the boundary.
139
+
140
+ ```typescript
141
+ import { follow } from "@sdxc/outbound";
142
+ import { failure, isFailure, success } from "@sdxc/result";
143
+
144
+ class FetchFeedError extends Error {
145
+ constructor(
146
+ message: string,
147
+ readonly retryable: boolean,
148
+ ) {
149
+ super(message);
150
+ }
151
+ }
152
+
153
+ async function fetchFeed(url: string) {
154
+ let followed = await follow(url, { timeout: "10 seconds" });
155
+ if (isFailure(followed)) {
156
+ return failure(new FetchFeedError(followed.error.message, followed.error.retryable));
157
+ }
158
+ if (followed.data.response.status === 304) return success(null);
159
+ return success(followed.data.response);
160
+ }
161
+ ```
162
+
163
+ ## Pattern: DNS Rebinding
164
+
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.
166
+
167
+ ## Versioning
168
+
169
+ Releases are dated rather than semantic. A version is the UTC date it was published, written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one release goes out per day.
170
+
171
+ Those numbers say when, not what: a later date means a later release and carries no compatibility promise. Any release may change or remove an export.
172
+
173
+ Depend on one exact date, and move it when you are ready to take the change:
174
+
175
+ ```json
176
+ {
177
+ "dependencies": {
178
+ "@sdxc/outbound": "2026.9.4"
179
+ }
180
+ }
181
+ ```
182
+
183
+ A caret or tilde range reads the date as major, minor and patch, so it accepts every later release in the same year. An exact version keeps the upgrade yours to schedule.
184
+
185
+ ## License
186
+
187
+ MIT
188
+
189
+ ## Author
190
+
191
+ [Sergio Xalambrí](https://sergiodxa.com)
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Decides whether a URL is somewhere a Worker should send a request, from the URL
3
+ * alone: the scheme, any credentials, the port, and a host that is a public address
4
+ * or a name outside every reserved suffix. No request and no lookup is made here.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import { OutboundError } from "./error.js";
11
+ /** Which URLs a check lets through; every field defaults to the strictest public policy. */
12
+ export interface CheckOptions {
13
+ /**
14
+ * `"public"` refuses reserved names and non-public addresses; `"any"` keeps only the
15
+ * scheme and credential rules, for a caller that reaches its own network on purpose.
16
+ *
17
+ * @default "public"
18
+ */
19
+ hosts?: "public" | "any";
20
+ /**
21
+ * `"public"` lets through an address literal that is on the public internet;
22
+ * `"refuse"` refuses every literal, so only a name can be asked.
23
+ *
24
+ * @default "public"
25
+ */
26
+ literals?: "public" | "refuse";
27
+ /**
28
+ * `"any"` lets through every port, `"default"` only the scheme's own, and a list
29
+ * only the ports it names, an omitted port counting as the scheme's own.
30
+ *
31
+ * @default "any"
32
+ */
33
+ ports?: "any" | "default" | readonly number[];
34
+ }
35
+ /**
36
+ * Parses a URL and refuses it unless every rule passes. The rules run on the parsed
37
+ * URL, so a host spelled in octal, hex or with a trailing dot is judged by the address
38
+ * or name it normalizes to.
39
+ *
40
+ * @param input - The URL as a stranger wrote it, or one already parsed.
41
+ * @param options - Which hosts, literals and ports are allowed.
42
+ * @returns The parsed URL, or the `OutboundError` naming the rule that refused it.
43
+ * @example checkUrl("https://example.com/feed.xml"); // success
44
+ * @example checkUrl("http://169.254.169.254/"); // failure: refused-address
45
+ */
46
+ export declare function checkUrl(input: string | URL, options?: CheckOptions): Result<URL, OutboundError>;
package/dist/check.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Decides whether a URL is somewhere a Worker should send a request, from the URL
3
+ * alone: the scheme, any credentials, the port, and a host that is a public address
4
+ * or a name outside every reserved suffix. No request and no lookup is made here.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { IP } from "@sdxc/ip";
10
+ import { failure, isSuccess, success } from "@sdxc/result";
11
+ import { OutboundError } from "./error.js";
12
+ /** The port each scheme uses when the URL names none. */
13
+ const DEFAULT_PORTS = { "http:": 80, "https:": 443 };
14
+ /**
15
+ * Name suffixes that never denote a public host: resolver-local names, private-network
16
+ * conventions, and the RFC 2606 and RFC 6761 reserved names. Matched against whole
17
+ * trailing labels, so `example` refuses `www.example` and leaves `example.com` alone.
18
+ */
19
+ const RESERVED_SUFFIXES = [
20
+ "localhost",
21
+ "local",
22
+ "internal",
23
+ "intranet",
24
+ "lan",
25
+ "corp",
26
+ "private",
27
+ "home.arpa",
28
+ "test",
29
+ "invalid",
30
+ "example",
31
+ ];
32
+ /**
33
+ * Parses a URL and refuses it unless every rule passes. The rules run on the parsed
34
+ * URL, so a host spelled in octal, hex or with a trailing dot is judged by the address
35
+ * or name it normalizes to.
36
+ *
37
+ * @param input - The URL as a stranger wrote it, or one already parsed.
38
+ * @param options - Which hosts, literals and ports are allowed.
39
+ * @returns The parsed URL, or the `OutboundError` naming the rule that refused it.
40
+ * @example checkUrl("https://example.com/feed.xml"); // success
41
+ * @example checkUrl("http://169.254.169.254/"); // failure: refused-address
42
+ */
43
+ export function checkUrl(input, options = {}) {
44
+ let text = String(input);
45
+ if (!URL.canParse(text)) {
46
+ return failure(new OutboundError("invalid-url", text, `Refused ${text}: it is not a URL`));
47
+ }
48
+ let url = new URL(text);
49
+ let refuse = (code, why) => failure(new OutboundError(code, url.href, `Refused ${url.href}: ${why}`));
50
+ if (!(url.protocol in DEFAULT_PORTS)) {
51
+ return refuse("refused-scheme", "only HTTP(S) is fetched");
52
+ }
53
+ if (url.username !== "" || url.password !== "") {
54
+ return refuse("refused-credentials", "it carries credentials");
55
+ }
56
+ if (!isAllowedPort(url, options.ports ?? "any")) {
57
+ return refuse("refused-port", `port ${url.port} is not allowed`);
58
+ }
59
+ if (options.hosts === "any")
60
+ return success(url);
61
+ let literal = IP.parse(url.hostname);
62
+ if (isSuccess(literal)) {
63
+ if (options.literals === "refuse")
64
+ return refuse("refused-address", "it names an address");
65
+ if (!literal.data.isPublic) {
66
+ return refuse("refused-address", `${literal.data.toString()} is ${literal.data.classification}`);
67
+ }
68
+ return success(url);
69
+ }
70
+ if (url.hostname.startsWith("["))
71
+ return refuse("refused-address", "it names an address");
72
+ if (!isPublicName(url.hostname))
73
+ return refuse("refused-host", `${url.hostname} is not public`);
74
+ return success(url);
75
+ }
76
+ /** Whether a URL's port, or its scheme's own when it names none, is one the policy allows. */
77
+ function isAllowedPort(url, ports) {
78
+ if (ports === "any")
79
+ return true;
80
+ if (ports === "default")
81
+ return url.port === "";
82
+ let port = url.port === "" ? DEFAULT_PORTS[url.protocol] : Number(url.port);
83
+ return port !== undefined && ports.includes(port);
84
+ }
85
+ /**
86
+ * Whether a name may be public. A single label is refused because a resolver's search
87
+ * domains can complete it into an internal host, and trailing root dots are dropped
88
+ * first so `localhost.` is judged as `localhost`.
89
+ */
90
+ function isPublicName(hostname) {
91
+ let name = hostname.toLowerCase().replace(/\.+$/u, "");
92
+ if (!name.includes("."))
93
+ return false;
94
+ return !RESERVED_SUFFIXES.some((suffix) => name === suffix || name.endsWith(`.${suffix}`));
95
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The one failure every check, walk and read in this package answers with: a code a
3
+ * caller maps to its own error, the URL it concerns, and whether asking again later
4
+ * can change the outcome.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /**
10
+ * Why an outbound request was refused or failed. Each `refused-*` code is decided
11
+ * before a request leaves; `timeout` and `network` are the only outcomes a retry can
12
+ * change.
13
+ */
14
+ export type OutboundErrorCode = "invalid-url" | "refused-scheme" | "refused-credentials" | "refused-port" | "refused-host" | "refused-address" | "too-many-redirects" | "too-large" | "timeout" | "network";
15
+ /** Returned inside a `Failure` by every function in this package, never thrown. */
16
+ export declare class OutboundError extends Error {
17
+ name: string;
18
+ /** Machine-readable cause, for a caller that maps it to its own error type. */
19
+ readonly code: OutboundErrorCode;
20
+ /**
21
+ * The URL the failure concerns: the hop that was refused or failed, the input text
22
+ * for `invalid-url`, or an empty string for a body whose message carries no URL.
23
+ */
24
+ readonly url: string;
25
+ /**
26
+ * @param code - Why the request was refused or failed.
27
+ * @param url - The URL it concerns.
28
+ * @param message - The explanation a log shows.
29
+ * @param options - The underlying error, when one was thrown.
30
+ */
31
+ constructor(code: OutboundErrorCode, url: string, message: string, options?: ErrorOptions);
32
+ /** Whether asking again later can succeed, which holds only for `timeout` and `network`. */
33
+ get retryable(): boolean;
34
+ }
35
+ /**
36
+ * Classifies a thrown value from `fetch` or a body stream: a deadline reports itself
37
+ * as a `TimeoutError`, a capped body errors with its own `OutboundError`, and every
38
+ * other rejection is the transport's.
39
+ *
40
+ * @param url - The URL the failure concerns.
41
+ * @param error - What was thrown.
42
+ */
43
+ export declare function failedWith(url: string, error: unknown): OutboundError;
package/dist/error.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The one failure every check, walk and read in this package answers with: a code a
3
+ * caller maps to its own error, the URL it concerns, and whether asking again later
4
+ * can change the outcome.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** The codes a later attempt can clear: the deadline and the transport. */
10
+ const RETRYABLE_CODES = new Set(["timeout", "network"]);
11
+ /** Returned inside a `Failure` by every function in this package, never thrown. */
12
+ export class OutboundError extends Error {
13
+ name = "OutboundError";
14
+ /** Machine-readable cause, for a caller that maps it to its own error type. */
15
+ code;
16
+ /**
17
+ * The URL the failure concerns: the hop that was refused or failed, the input text
18
+ * for `invalid-url`, or an empty string for a body whose message carries no URL.
19
+ */
20
+ url;
21
+ /**
22
+ * @param code - Why the request was refused or failed.
23
+ * @param url - The URL it concerns.
24
+ * @param message - The explanation a log shows.
25
+ * @param options - The underlying error, when one was thrown.
26
+ */
27
+ constructor(code, url, message, options) {
28
+ super(message, options);
29
+ this.code = code;
30
+ this.url = url;
31
+ }
32
+ /** Whether asking again later can succeed, which holds only for `timeout` and `network`. */
33
+ get retryable() {
34
+ return RETRYABLE_CODES.has(this.code);
35
+ }
36
+ }
37
+ /**
38
+ * Classifies a thrown value from `fetch` or a body stream: a deadline reports itself
39
+ * as a `TimeoutError`, a capped body errors with its own `OutboundError`, and every
40
+ * other rejection is the transport's.
41
+ *
42
+ * @param url - The URL the failure concerns.
43
+ * @param error - What was thrown.
44
+ */
45
+ export function failedWith(url, error) {
46
+ if (error instanceof OutboundError)
47
+ return error;
48
+ let reason = error instanceof Error ? error.message : String(error);
49
+ let label = url === "" ? "the body" : url;
50
+ if (isTimeout(error)) {
51
+ return new OutboundError("timeout", url, `Timed out reading ${label}`, { cause: error });
52
+ }
53
+ return new OutboundError("network", url, `Failed to read ${label}: ${reason}`, {
54
+ cause: error,
55
+ });
56
+ }
57
+ /** Whether a thrown value is a deadline's reason, which a `DOMException` carries by name. */
58
+ function isTimeout(error) {
59
+ return (typeof error === "object" && error !== null && "name" in error && error.name === "TimeoutError");
60
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Requests a URL and walks its redirect chain by hand, so every hop passes the same
3
+ * check as the first, the chain has a length it can exceed, and one deadline covers
4
+ * the whole chain and the body read after it.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { DurationInput } from "@sdxc/duration";
10
+ import type { Result } from "@sdxc/result";
11
+ import type { CheckOptions } from "./check.js";
12
+ import { OutboundError } from "./error.js";
13
+ /** What a walk sends, and what it may spend; every `checkUrl` option applies to every hop. */
14
+ export interface FollowOptions extends CheckOptions {
15
+ /** @default "GET" */
16
+ method?: "GET" | "HEAD";
17
+ /** Sent on every hop, so the origin sees the same request wherever the chain leads. */
18
+ headers?: HeadersInit;
19
+ /** How long the chain and the body read after it may take together. */
20
+ timeout?: DurationInput;
21
+ /** @default 5 */
22
+ maxRedirects?: number;
23
+ /**
24
+ * Resolves every hop's name over DNS-over-HTTPS and refuses it when any address is
25
+ * not public. The connection itself is not pinned to those addresses, so this
26
+ * narrows DNS rebinding and leaves it open to a name that answers differently twice.
27
+ *
28
+ * @default false
29
+ */
30
+ resolve?: boolean;
31
+ /** The caller's own abort, combined with `timeout`. */
32
+ signal?: AbortSignal;
33
+ }
34
+ /** The last response of a chain, where it came from, and how many hops it took. */
35
+ export interface Followed {
36
+ /** Answered whatever its status; its body is bound to the walk's deadline. */
37
+ response: Response;
38
+ /** Where the chain ended, which is what a relative URL in the body resolves against. */
39
+ url: URL;
40
+ redirects: number;
41
+ }
42
+ /**
43
+ * Requests a URL, following redirects while each one passes the check. Requests carry
44
+ * no credentials and only the headers given, so the origin sees an anonymous visitor.
45
+ * A request with a body belongs to a single checked `fetch` with `redirect: "manual"`.
46
+ *
47
+ * @param input - The URL to request, as a stranger wrote it.
48
+ * @param options - The check every hop passes, the headers, and the bounds.
49
+ * @returns The final response whatever its status, or why there is none.
50
+ * @example let followed = await follow(url, { timeout: "8 seconds", headers: { accept: "text/html" } });
51
+ */
52
+ export declare function follow(input: string | URL, options?: FollowOptions): Promise<Result<Followed, OutboundError>>;
package/dist/follow.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Requests a URL and walks its redirect chain by hand, so every hop passes the same
3
+ * check as the first, the chain has a length it can exceed, and one deadline covers
4
+ * the whole chain and the body read after it.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { toMs } from "@sdxc/duration";
10
+ import { failure, isFailure, success } from "@sdxc/result";
11
+ import { checkUrl } from "./check.js";
12
+ import { failedWith, OutboundError } from "./error.js";
13
+ import { release, withBody } from "./read.js";
14
+ import { resolveHost } from "./resolve.js";
15
+ /** A moved page moves once or twice; five hops covers that with room to spare. */
16
+ const MAX_REDIRECTS = 5;
17
+ /** The statuses that answer with another URL to ask instead. */
18
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
19
+ /**
20
+ * Requests a URL, following redirects while each one passes the check. Requests carry
21
+ * no credentials and only the headers given, so the origin sees an anonymous visitor.
22
+ * A request with a body belongs to a single checked `fetch` with `redirect: "manual"`.
23
+ *
24
+ * @param input - The URL to request, as a stranger wrote it.
25
+ * @param options - The check every hop passes, the headers, and the bounds.
26
+ * @returns The final response whatever its status, or why there is none.
27
+ * @example let followed = await follow(url, { timeout: "8 seconds", headers: { accept: "text/html" } });
28
+ */
29
+ export async function follow(input, options = {}) {
30
+ let checked = checkUrl(input, options);
31
+ if (isFailure(checked))
32
+ return checked;
33
+ let limit = options.maxRedirects ?? MAX_REDIRECTS;
34
+ let signal = deadlineOf(options);
35
+ let headers = new Headers(options.headers);
36
+ let url = checked.data;
37
+ for (let redirects = 0;; redirects++) {
38
+ if (options.resolve && options.hosts !== "any") {
39
+ let resolved = await resolveHost(url, signal ? { signal } : {});
40
+ if (isFailure(resolved))
41
+ return resolved;
42
+ }
43
+ let response;
44
+ try {
45
+ response = await fetch(url, {
46
+ method: options.method ?? "GET",
47
+ headers,
48
+ signal,
49
+ redirect: "manual",
50
+ credentials: "omit",
51
+ });
52
+ }
53
+ catch (error) {
54
+ return failure(failedWith(url.href, error));
55
+ }
56
+ let next = redirectTarget(response, url);
57
+ if (next === undefined) {
58
+ return success({ response: bindBody(response, signal), url, redirects });
59
+ }
60
+ release(response.body);
61
+ if (redirects === limit) {
62
+ let first = checked.data.href;
63
+ return failure(new OutboundError("too-many-redirects", first, `Refused ${first}: more than ${limit} redirects`));
64
+ }
65
+ let hop = checkUrl(next, options);
66
+ if (isFailure(hop))
67
+ return hop;
68
+ url = hop.data;
69
+ }
70
+ }
71
+ /** The one signal every hop and the body carry, or none when the caller set no bound. */
72
+ function deadlineOf(options) {
73
+ let signals = [];
74
+ if (options.timeout !== undefined)
75
+ signals.push(AbortSignal.timeout(toMs(options.timeout)));
76
+ if (options.signal)
77
+ signals.push(options.signal);
78
+ return signals.length > 0 ? AbortSignal.any(signals) : undefined;
79
+ }
80
+ /**
81
+ * Ties a body to the walk's deadline, so a body that is still arriving when it passes
82
+ * errors with the deadline's reason in every runtime and the read fails `timeout`.
83
+ */
84
+ function bindBody(response, signal) {
85
+ if (signal === undefined || response.body === null)
86
+ return response;
87
+ return withBody(response, response.body.pipeThrough(new TransformStream(), { signal }));
88
+ }
89
+ /**
90
+ * Names the URL a response sends the client on to, absent when it is an answer rather
91
+ * than a redirect, or when its `Location` is not a URL and so leaves the status itself
92
+ * as the outcome.
93
+ */
94
+ function redirectTarget(response, from) {
95
+ if (!REDIRECT_STATUSES.has(response.status))
96
+ return undefined;
97
+ let location = response.headers.get("location");
98
+ if (location === null || !URL.canParse(location, from))
99
+ return undefined;
100
+ return new URL(location, from).href;
101
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The package entrypoint: checking and following a URL a stranger chose, which only
3
+ * the public internet needs, beside reading any message's body within a cap, which
4
+ * every runtime boundary needs. Every failure is one `OutboundError`.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export type { CheckOptions } from "./check.js";
10
+ export type { OutboundErrorCode } from "./error.js";
11
+ export type { Followed, FollowOptions } from "./follow.js";
12
+ export type { ReadBytes, ReadOptions, ReadText } from "./read.js";
13
+ export type { ResolveHostOptions } from "./resolve.js";
14
+ export { checkUrl } from "./check.js";
15
+ export { OutboundError } from "./error.js";
16
+ export { follow } from "./follow.js";
17
+ export { limitBody, readBytes, readText, release } from "./read.js";
18
+ export { resolveHost } from "./resolve.js";
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The package entrypoint: checking and following a URL a stranger chose, which only
3
+ * the public internet needs, beside reading any message's body within a cap, which
4
+ * every runtime boundary needs. Every failure is one `OutboundError`.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export { checkUrl } from "./check.js";
10
+ export { OutboundError } from "./error.js";
11
+ export { follow } from "./follow.js";
12
+ export { limitBody, readBytes, readText, release } from "./read.js";
13
+ export { resolveHost } from "./resolve.js";
package/dist/read.d.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Reads, caps and lets go of message bodies, whichever runtime handed over the
3
+ * message: the global `fetch`, a binding, a router, or a request a handler received.
4
+ * The count over the stream enforces the cap, so a body never sits whole past it.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import { OutboundError } from "./error.js";
11
+ /** How large a body may be. */
12
+ export interface ReadOptions {
13
+ /** The most bytes the body may carry; one more and the read fails `too-large`. */
14
+ maxBytes: number;
15
+ }
16
+ /** A body read as text, and the bytes that came off the wire to produce it. */
17
+ export interface ReadText {
18
+ text: string;
19
+ bytes: number;
20
+ }
21
+ /** A body read as bytes, exactly as they arrived. */
22
+ export interface ReadBytes {
23
+ data: Uint8Array<ArrayBuffer>;
24
+ bytes: number;
25
+ }
26
+ /**
27
+ * Reads a body as UTF-8 text within a cap.
28
+ *
29
+ * @param message - The request or response whose body to read.
30
+ * @param options - The cap.
31
+ * @returns The text and its size in bytes; `too-large` past the cap, `timeout` when the
32
+ * deadline `follow` set passed mid-body, and `network` when the stream broke off.
33
+ * @example let body = await readText(response, { maxBytes: 2 * 1024 * 1024 });
34
+ */
35
+ export declare function readText(message: Request | Response, options: ReadOptions): Promise<Result<ReadText, OutboundError>>;
36
+ /**
37
+ * Reads a body as bytes within a cap, for a caller that verifies a signature over the
38
+ * exact bytes or decodes them itself.
39
+ *
40
+ * @param message - The request or response whose body to read.
41
+ * @param options - The cap.
42
+ * @returns The bytes and their count, or the same failures as `readText`.
43
+ * @example let body = await readBytes(request, { maxBytes: 1024 * 1024 });
44
+ */
45
+ export declare function readBytes(message: Request | Response, options: ReadOptions): Promise<Result<ReadBytes, OutboundError>>;
46
+ /**
47
+ * Answers the same response with a body that errors with a `too-large` `OutboundError`
48
+ * at the first chunk past the cap, for a body passed through rather than read. A
49
+ * declared length over the cap errors the body before any of it is forwarded.
50
+ *
51
+ * @param response - The response whose body to cap.
52
+ * @param options - The cap.
53
+ * @returns A response with the same status and headers, and the capped body.
54
+ * @example return limitBody(upstream, { maxBytes: 5 * 1024 * 1024 });
55
+ */
56
+ export declare function limitBody(response: Response, options: ReadOptions): Response;
57
+ /**
58
+ * Lets go of a body left unread, telling the origin it may stop sending. The
59
+ * cancellation runs on its own, so a stream that stalls costs the caller nothing.
60
+ *
61
+ * @param source - A body, a reader of one, or `null` for a message with none.
62
+ * @example release(response.body);
63
+ */
64
+ export declare function release(source: {
65
+ cancel(): Promise<void>;
66
+ } | null): void;
67
+ /**
68
+ * The same response around a new body, keeping the URL it came from, so an error
69
+ * reading the new body still names where it was fetched.
70
+ */
71
+ export declare function withBody(response: Response, body: ReadableStream<Uint8Array>): Response;
72
+ /** Reads the length a message claims, for the refusal that costs no bytes at all. */
73
+ export declare function declaredLength(message: Request | Response): number | undefined;
package/dist/read.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Reads, caps and lets go of message bodies, whichever runtime handed over the
3
+ * message: the global `fetch`, a binding, a router, or a request a handler received.
4
+ * The count over the stream enforces the cap, so a body never sits whole past it.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { failure, isFailure, success } from "@sdxc/result";
10
+ import { failedWith, OutboundError } from "./error.js";
11
+ /**
12
+ * Reads a body as UTF-8 text within a cap.
13
+ *
14
+ * @param message - The request or response whose body to read.
15
+ * @param options - The cap.
16
+ * @returns The text and its size in bytes; `too-large` past the cap, `timeout` when the
17
+ * deadline `follow` set passed mid-body, and `network` when the stream broke off.
18
+ * @example let body = await readText(response, { maxBytes: 2 * 1024 * 1024 });
19
+ */
20
+ export async function readText(message, options) {
21
+ let decoder = new TextDecoder();
22
+ let text = "";
23
+ let read = await consume(message, options.maxBytes, (chunk) => {
24
+ text += decoder.decode(chunk, { stream: true });
25
+ });
26
+ if (isFailure(read))
27
+ return read;
28
+ return success({ text: text + decoder.decode(), bytes: read.data });
29
+ }
30
+ /**
31
+ * Reads a body as bytes within a cap, for a caller that verifies a signature over the
32
+ * exact bytes or decodes them itself.
33
+ *
34
+ * @param message - The request or response whose body to read.
35
+ * @param options - The cap.
36
+ * @returns The bytes and their count, or the same failures as `readText`.
37
+ * @example let body = await readBytes(request, { maxBytes: 1024 * 1024 });
38
+ */
39
+ export async function readBytes(message, options) {
40
+ let chunks = [];
41
+ let read = await consume(message, options.maxBytes, (chunk) => {
42
+ chunks.push(chunk);
43
+ });
44
+ if (isFailure(read))
45
+ return read;
46
+ let data = new Uint8Array(new ArrayBuffer(read.data));
47
+ let offset = 0;
48
+ for (let chunk of chunks) {
49
+ data.set(chunk, offset);
50
+ offset += chunk.byteLength;
51
+ }
52
+ return success({ data, bytes: read.data });
53
+ }
54
+ /**
55
+ * Answers the same response with a body that errors with a `too-large` `OutboundError`
56
+ * at the first chunk past the cap, for a body passed through rather than read. A
57
+ * declared length over the cap errors the body before any of it is forwarded.
58
+ *
59
+ * @param response - The response whose body to cap.
60
+ * @param options - The cap.
61
+ * @returns A response with the same status and headers, and the capped body.
62
+ * @example return limitBody(upstream, { maxBytes: 5 * 1024 * 1024 });
63
+ */
64
+ export function limitBody(response, options) {
65
+ if (response.body === null)
66
+ return response;
67
+ let { maxBytes } = options;
68
+ let url = response.url;
69
+ let declared = declaredLength(response);
70
+ let seen = 0;
71
+ let capped = new TransformStream({
72
+ start(controller) {
73
+ if (declared !== undefined && declared > maxBytes)
74
+ controller.error(tooLarge(url, maxBytes));
75
+ },
76
+ transform(chunk, controller) {
77
+ seen += chunk.byteLength;
78
+ if (seen > maxBytes)
79
+ controller.error(tooLarge(url, maxBytes));
80
+ else
81
+ controller.enqueue(chunk);
82
+ },
83
+ });
84
+ return withBody(response, response.body.pipeThrough(capped));
85
+ }
86
+ /**
87
+ * Lets go of a body left unread, telling the origin it may stop sending. The
88
+ * cancellation runs on its own, so a stream that stalls costs the caller nothing.
89
+ *
90
+ * @param source - A body, a reader of one, or `null` for a message with none.
91
+ * @example release(response.body);
92
+ */
93
+ export function release(source) {
94
+ void source?.cancel().catch(() => undefined);
95
+ }
96
+ /**
97
+ * The same response around a new body, keeping the URL it came from, so an error
98
+ * reading the new body still names where it was fetched.
99
+ */
100
+ export function withBody(response, body) {
101
+ let replaced = new Response(body, response);
102
+ Object.defineProperty(replaced, "url", { value: response.url });
103
+ return replaced;
104
+ }
105
+ /** Reads the length a message claims, for the refusal that costs no bytes at all. */
106
+ export function declaredLength(message) {
107
+ let header = message.headers.get("content-length");
108
+ if (header === null)
109
+ return undefined;
110
+ let length = Number(header);
111
+ return Number.isFinite(length) ? length : undefined;
112
+ }
113
+ /**
114
+ * Hands every chunk of a body to `take` and answers how many bytes it read. A
115
+ * declared length over the cap fails before a byte is read, and crossing the cap
116
+ * cancels the stream so the origin stops sending.
117
+ */
118
+ async function consume(message, maxBytes, take) {
119
+ let url = message.url;
120
+ let declared = declaredLength(message);
121
+ if (declared !== undefined && declared > maxBytes) {
122
+ release(message.body);
123
+ return failure(tooLarge(url, maxBytes));
124
+ }
125
+ if (message.body === null)
126
+ return success(0);
127
+ let reader = message.body.getReader();
128
+ let bytes = 0;
129
+ try {
130
+ for (;;) {
131
+ let { done, value } = await reader.read();
132
+ if (done || value === undefined)
133
+ break;
134
+ bytes += value.byteLength;
135
+ if (bytes > maxBytes) {
136
+ release(reader);
137
+ return failure(tooLarge(url, maxBytes));
138
+ }
139
+ take(value);
140
+ }
141
+ }
142
+ catch (error) {
143
+ return failure(failedWith(url, error));
144
+ }
145
+ return success(bytes);
146
+ }
147
+ /** The failure for a body past its cap. */
148
+ function tooLarge(url, maxBytes) {
149
+ let label = url === "" ? "the body" : url;
150
+ return new OutboundError("too-large", url, `Refused ${label}: it exceeded the ${maxBytes} byte cap`);
151
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Resolves a URL's host over DNS-over-HTTPS and refuses it unless every address it
3
+ * answers with is public, which is what catches a public name whose records point
4
+ * inside a network. A Worker cannot pin a connection to the addresses checked here.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import { OutboundError } from "./error.js";
11
+ /** What a lookup may spend. */
12
+ export interface ResolveHostOptions {
13
+ /** Aborts both lookups, so the caller's deadline covers resolution too. */
14
+ signal?: AbortSignal;
15
+ }
16
+ /**
17
+ * Resolves the `A` and `AAAA` records of a URL's host. An address literal answers
18
+ * itself. A record the resolver could not parse is refused with the rest, since an
19
+ * address that cannot be read cannot be judged public.
20
+ *
21
+ * @param url - A URL, normally one `checkUrl` already let through.
22
+ * @param options - The signal both lookups carry.
23
+ * @returns Every address the host resolved to, all public; `refused-host` for a name
24
+ * with no address, `refused-address` for any address that is not public, and
25
+ * `network` when the resolver could not answer.
26
+ * @example let addresses = await resolveHost(new URL("https://example.com/"));
27
+ */
28
+ export declare function resolveHost(url: URL, options?: ResolveHostOptions): Promise<Result<string[], OutboundError>>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Resolves a URL's host over DNS-over-HTTPS and refuses it unless every address it
3
+ * answers with is public, which is what catches a public name whose records point
4
+ * inside a network. A Worker cannot pin a connection to the addresses checked here.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { NameNotFoundError, resolve } from "@sdxc/doh";
10
+ import { IP } from "@sdxc/ip";
11
+ import { failure, isFailure, isSuccess, success } from "@sdxc/result";
12
+ import { OutboundError } from "./error.js";
13
+ /**
14
+ * Resolves the `A` and `AAAA` records of a URL's host. An address literal answers
15
+ * itself. A record the resolver could not parse is refused with the rest, since an
16
+ * address that cannot be read cannot be judged public.
17
+ *
18
+ * @param url - A URL, normally one `checkUrl` already let through.
19
+ * @param options - The signal both lookups carry.
20
+ * @returns Every address the host resolved to, all public; `refused-host` for a name
21
+ * with no address, `refused-address` for any address that is not public, and
22
+ * `network` when the resolver could not answer.
23
+ * @example let addresses = await resolveHost(new URL("https://example.com/"));
24
+ */
25
+ export async function resolveHost(url, options = {}) {
26
+ let literal = IP.parse(url.hostname);
27
+ if (isSuccess(literal))
28
+ return success([literal.data.toString()]);
29
+ let host = url.hostname.replace(/\.+$/u, "");
30
+ let lookup = options.signal ? { signal: options.signal } : {};
31
+ let [a, aaaa] = await Promise.all([resolve(host, "A", lookup), resolve(host, "AAAA", lookup)]);
32
+ if (isFailure(a))
33
+ return failure(lookupFailed(url, host, a.error));
34
+ if (isFailure(aaaa))
35
+ return failure(lookupFailed(url, host, aaaa.error));
36
+ if (a.data.unparsed.length > 0 || aaaa.data.unparsed.length > 0) {
37
+ return failure(new OutboundError("refused-address", url.href, `Refused ${url.href}: ${host} answered an unreadable address`));
38
+ }
39
+ let addresses = [...a.data.records, ...aaaa.data.records].map((record) => record.address);
40
+ if (addresses.length === 0) {
41
+ return failure(new OutboundError("refused-host", url.href, `Refused ${url.href}: ${host} has no address`));
42
+ }
43
+ for (let address of addresses) {
44
+ let ip = IP.parse(address);
45
+ if (isSuccess(ip) && ip.data.isPublic)
46
+ continue;
47
+ return failure(new OutboundError("refused-address", url.href, `Refused ${url.href}: ${host} resolves to ${address}`));
48
+ }
49
+ return success(addresses);
50
+ }
51
+ /**
52
+ * Reads a failed lookup: NXDOMAIN is the name saying it does not exist, which no
53
+ * retry changes, and every other failure is the resolver's.
54
+ */
55
+ function lookupFailed(url, host, error) {
56
+ if (error instanceof NameNotFoundError) {
57
+ return new OutboundError("refused-host", url.href, `Refused ${url.href}: ${host} does not exist`);
58
+ }
59
+ return new OutboundError("network", url.href, `Failed to resolve ${host}: ${error.message}`, {
60
+ cause: error,
61
+ });
62
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
- "name": "@sdxc/outbound",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@sdxc/outbound",
3
+ "version": "2026.10.6",
4
+ "description": "Check, follow and read URLs a stranger chose: public hosts on every redirect, one deadline, bounded bodies",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./dist/index.js"
9
+ },
10
+ "dependencies": {
11
+ "@sdxc/doh": "2026.10.6",
12
+ "@sdxc/duration": "2026.10.6",
13
+ "@sdxc/ip": "2026.10.6",
14
+ "@sdxc/result": "2026.10.6"
15
+ },
16
+ "gitHead": "b1067eb20fc47ec51ff8c4de47d07dc3f9c00466",
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/sergiodxa/monorepo.git",
23
+ "directory": "packages/outbound"
24
+ },
25
+ "homepage": "https://sdxc.sergiodxa.com/api/outbound",
26
+ "author": {
27
+ "name": "Sergio Xalambrí",
28
+ "url": "https://sergiodxa.com"
29
+ },
30
+ "funding": {
31
+ "type": "github",
32
+ "url": "https://github.com/sponsors/sergiodxa"
33
+ }
34
+ }