@hydraharness/harness-web-fetch-http 0.1.1-rc.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 +21 -0
- package/README.md +54 -0
- package/lib/index.js +513 -0
- package/lib/invariant.js +23 -0
- package/lib/types/index.d.ts +39 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/network.d.ts +21 -0
- package/lib/types/policy.d.ts +63 -0
- package/lib/types/provider.d.ts +51 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
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
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @hydraharness/harness-web-fetch-http
|
|
2
|
+
|
|
3
|
+
An anonymous public HTTP(S) `WebFetchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It retrieves a concrete URL and returns a status code plus bounded decoded content.
|
|
4
|
+
|
|
5
|
+
This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. It is a function/namespace plugin (`inject: ['web']`).
|
|
6
|
+
|
|
7
|
+
## Responsibility split
|
|
8
|
+
|
|
9
|
+
The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, a resource-backstop timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `@hydraharness/harness-tool-web` owns **presentation** (HTML→markdown, truncation formatting). A non-2xx HTTP response is a *result* (status code + decoded body), not an error; `WebError` is reserved for failures to safely retrieve or represent the resource.
|
|
10
|
+
|
|
11
|
+
The provider's `timeoutMs` is a resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments, not the model-facing tool-call budget. [`@hydraharness/harness-tool-call-timeout-policy`](../../guard/timeout-policy/README.md) owns the `web_fetch` tool-call budget by arming `exec.signal`.
|
|
12
|
+
|
|
13
|
+
A shipping web-tool deployment sets the provider backstop above the tool budget, so model calls normally return `TOOL_TIMEOUT`. If the outer deadline reaches the provider first, the provider reports `WEB_ABORTED` and the outer policy replaces it with `TOOL_TIMEOUT`. `WEB_FETCH_TIMEOUT` therefore identifies a direct service caller whose provider budget elapsed.
|
|
14
|
+
|
|
15
|
+
## Transport hygiene
|
|
16
|
+
|
|
17
|
+
Connections use a private direct dispatcher and validate every DNS answer inside socket lookup. Only the validated addresses reach the socket; there is no second DNS lookup between checking and connecting. Literal IP URLs receive the same checks, and each redirect opens a separately checked connection. Private, loopback, link-local, multicast, reserved, and IPv6 transition destinations fail with `WEB_BLOCKED_URL`. Ambient proxies, global dispatchers, browser cookies, and credentials are not used.
|
|
18
|
+
|
|
19
|
+
`allowedOrigins` grants exact operator-configured HTTP(S) origins access to non-public addresses. It defaults to an empty list and is never a tool argument. Ports are part of the grant; paths, credentials, and wildcards are rejected. Local fixture compositions explicitly grant their test server origin.
|
|
20
|
+
|
|
21
|
+
- Accepts only `http:` and `https:` URLs; rejects credentials in URLs (`WEB_BLOCKED_URL`) and over-long/malformed URLs (`WEB_INVALID_URL`).
|
|
22
|
+
- Enforces a max URL length, response byte cap (`WEB_FETCH_TOO_LARGE`), decoded body character cap, timeout (`WEB_FETCH_TIMEOUT`), and redirect hop cap.
|
|
23
|
+
- Propagates the caller's abort signal (`WEB_ABORTED`) into the network request and the streaming read.
|
|
24
|
+
- Follows only **same-origin** redirects; a cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call (the model of Claude Code's WebFetch).
|
|
25
|
+
- Sends an explicit product `User-Agent`, never a browser disguise.
|
|
26
|
+
- Rejects unsupported (e.g. binary) content types with `WEB_UNSUPPORTED_CONTENT_TYPE`.
|
|
27
|
+
|
|
28
|
+
## Config
|
|
29
|
+
|
|
30
|
+
| Key | Default | Meaning |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `maxUrlLength` | `2048` | Maximum accepted request URL length. |
|
|
33
|
+
| `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. |
|
|
34
|
+
| `maxBodyChars` | `100_000` | Maximum decoded body length in characters. |
|
|
35
|
+
| `timeoutMs` | `30_000` | Fetch timeout within Node's timer range — a resource backstop for direct `ctx.web.fetch()` callers, not the model-facing tool-call budget (that is `@hydraharness/harness-tool-call-timeout-policy`). |
|
|
36
|
+
| `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). |
|
|
37
|
+
| `allowedOrigins` | `[]` | Exact HTTP(S) origins permitted to connect to non-public addresses. |
|
|
38
|
+
| `userAgent` | `hydra-harness/…` | `User-Agent` header. |
|
|
39
|
+
|
|
40
|
+
The numeric limits are validated at plugin construction: every cap except `maxRedirects` must be a positive finite number, and `maxRedirects` must be a non-negative integer. An invalid value throws rather than silently constructing a provider with nonsensical limits.
|
|
41
|
+
|
|
42
|
+
## Model Experience
|
|
43
|
+
|
|
44
|
+
Indirectly, through [`@hydraharness/harness-tool-web`](../tool-web/README.md), which places this provider's `maxBodyChars`-bounded decoded text or markdown-shaped HTML under its fetch-result wrapper and retains provider failures while redirects, headers, and transport mechanics remain hidden.
|
|
45
|
+
|
|
46
|
+
#### KV Cache effect
|
|
47
|
+
|
|
48
|
+
No direct invalidation; the named consumer owns any request-prefix changes.
|
|
49
|
+
|
|
50
|
+
## Known Limitations and Deferred Work
|
|
51
|
+
|
|
52
|
+
- **Public address policy is conservative** — special-use IPv4 and IPv6 transition/reserved ranges are refused even when a particular address is globally reachable. Network-level routing and explicitly granted private origins remain deployment responsibilities.
|
|
53
|
+
- **Only textual content decodes** — html/xhtml and `text/*`-plus-JSON/XML families; a missing `Content-Type` or any binary type throws `WEB_UNSUPPORTED_CONTENT_TYPE`, and text-extractable PDF decoding is named deferred work.
|
|
54
|
+
- **Charset comes only from the `Content-Type` header** (UTF-8 default) — an HTML `<meta charset>` declaration is ignored, and a declared-but-unrecognized charset label throws rather than falling back.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import z from "@hydraharness/schemastery";
|
|
2
|
+
import { WebError } from "@hydraharness/harness-web";
|
|
3
|
+
import { deadline, timeoutOf } from "@hydraharness/harness-timeout";
|
|
4
|
+
import { lookup } from "node:dns";
|
|
5
|
+
import { BlockList, isIP } from "node:net";
|
|
6
|
+
import { Agent } from "undici";
|
|
7
|
+
//#region lib/types/policy.js
|
|
8
|
+
/**
|
|
9
|
+
* URL validation and content-type classification for the local HTTP(S) fetch
|
|
10
|
+
* provider — the pure, network-free half. The provider's `fetch()` composes
|
|
11
|
+
* these with transport (redirect following, byte caps, decoding).
|
|
12
|
+
*
|
|
13
|
+
* @module @hydraharness/harness-web-fetch-http/policy
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Validate a request URL against the basic transport hygiene the provider
|
|
17
|
+
* enforces before any network access: http(s) only, no embedded credentials,
|
|
18
|
+
* bounded length. Returns the parsed `URL`. Throws {@link WebError} otherwise.
|
|
19
|
+
* Destination IP checks additionally run at connection time in the HTTP provider.
|
|
20
|
+
*
|
|
21
|
+
* @param input - the raw URL string from the fetch request.
|
|
22
|
+
* @param maxUrlLength - inclusive upper bound on `input`'s length.
|
|
23
|
+
* @returns the parsed `URL`.
|
|
24
|
+
*/
|
|
25
|
+
function validateFetchUrl(input, maxUrlLength) {
|
|
26
|
+
if (input.length > maxUrlLength) throw new WebError(`URL exceeds the maximum length of ${maxUrlLength}`, "WEB_INVALID_URL");
|
|
27
|
+
let url;
|
|
28
|
+
try {
|
|
29
|
+
url = new URL(input);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new WebError(`invalid URL: ${input}`, "WEB_INVALID_URL", { cause: error });
|
|
32
|
+
}
|
|
33
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new WebError(`unsupported URL scheme "${url.protocol}" (only http and https are allowed)`, "WEB_INVALID_URL");
|
|
34
|
+
if (url.username.length > 0 || url.password.length > 0) throw new WebError("credentials in URLs are not allowed", "WEB_BLOCKED_URL");
|
|
35
|
+
return url;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Two URLs are same-origin when scheme, hostname, and port match. A redirect
|
|
39
|
+
* that crosses origins is refused so each new origin requires a fresh tool call
|
|
40
|
+
* (and thus a fresh provider/permission decision).
|
|
41
|
+
*
|
|
42
|
+
* @param a - one of the two URLs to compare.
|
|
43
|
+
* @param b - the other URL to compare.
|
|
44
|
+
* @returns true when `a` and `b` share scheme, hostname, and port.
|
|
45
|
+
*/
|
|
46
|
+
function isSameOrigin(a, b) {
|
|
47
|
+
return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Classify a response `Content-Type` into a decodable body kind, or `undefined`
|
|
51
|
+
* for an unsupported (e.g. binary) type. `text/html` and `application/xhtml+xml`
|
|
52
|
+
* are `html`; other `text/*` plus a few structured text types are `text`.
|
|
53
|
+
*
|
|
54
|
+
* @param contentType - the raw `Content-Type` header, or `null` when the
|
|
55
|
+
* response carries none (unsupported).
|
|
56
|
+
* @returns the decodable kind, or `undefined` for an unsupported type.
|
|
57
|
+
*/
|
|
58
|
+
function classifyContentType(contentType) {
|
|
59
|
+
const mime = (contentType ?? "").replace(/;.*$/s, "").trim().toLowerCase();
|
|
60
|
+
if (mime === "text/html" || mime === "application/xhtml+xml") return "html";
|
|
61
|
+
if (mime.startsWith("text/")) return "text";
|
|
62
|
+
if (mime === "application/json" || mime === "application/xml" || mime.endsWith("+json") || mime.endsWith("+xml")) return "text";
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Extract the `charset` parameter from a response `Content-Type`, lower-cased,
|
|
66
|
+
* or `undefined` when absent. The provider feeds this label to `TextDecoder`
|
|
67
|
+
* so a non-UTF-8 response is decoded with its declared encoding rather than
|
|
68
|
+
* silently mangled into replacement characters.
|
|
69
|
+
*
|
|
70
|
+
* @param contentType - the raw `Content-Type` header, or `null` when the
|
|
71
|
+
* response carries none.
|
|
72
|
+
* @returns the lower-cased charset label, or `undefined` when none is declared.
|
|
73
|
+
*/
|
|
74
|
+
function parseCharset(contentType) {
|
|
75
|
+
return /;\s*charset\s*=\s*"?([^";]+)"?/i.exec(contentType ?? "")?.[1]?.trim().toLowerCase();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build a `TextDecoder` for the declared charset, falling back to UTF-8 when
|
|
79
|
+
* none is declared. Throws {@link WebError} `WEB_UNSUPPORTED_CONTENT_TYPE` when
|
|
80
|
+
* the label is present but not a charset `TextDecoder` recognizes — better to
|
|
81
|
+
* fail loudly than return mojibake.
|
|
82
|
+
*
|
|
83
|
+
* @param charset - the declared charset label (from {@link parseCharset}), or
|
|
84
|
+
* `undefined` to default to UTF-8.
|
|
85
|
+
* @returns a decoder for the declared (or defaulted) encoding.
|
|
86
|
+
*/
|
|
87
|
+
function decoderForCharset(charset) {
|
|
88
|
+
if (charset === void 0) return new TextDecoder("utf-8");
|
|
89
|
+
try {
|
|
90
|
+
return new TextDecoder(charset);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw new WebError(`unsupported charset "${charset}"`, "WEB_UNSUPPORTED_CONTENT_TYPE", { cause: error });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region lib/types/network.js
|
|
97
|
+
/** Public-destination enforcement at socket lookup; explicit origins may reach private services. */
|
|
98
|
+
const blocked = new BlockList();
|
|
99
|
+
for (const [address, prefix] of [
|
|
100
|
+
["0.0.0.0", 8],
|
|
101
|
+
["10.0.0.0", 8],
|
|
102
|
+
["100.64.0.0", 10],
|
|
103
|
+
["127.0.0.0", 8],
|
|
104
|
+
["169.254.0.0", 16],
|
|
105
|
+
["172.16.0.0", 12],
|
|
106
|
+
["192.0.0.0", 24],
|
|
107
|
+
["192.0.2.0", 24],
|
|
108
|
+
["192.88.99.0", 24],
|
|
109
|
+
["192.168.0.0", 16],
|
|
110
|
+
["198.18.0.0", 15],
|
|
111
|
+
["198.51.100.0", 24],
|
|
112
|
+
["203.0.113.0", 24],
|
|
113
|
+
["224.0.0.0", 4],
|
|
114
|
+
["240.0.0.0", 4]
|
|
115
|
+
]) blocked.addSubnet(address, prefix, "ipv4");
|
|
116
|
+
for (const [address, prefix] of [
|
|
117
|
+
["2001::", 23],
|
|
118
|
+
["2001:db8::", 32],
|
|
119
|
+
["2002::", 16],
|
|
120
|
+
["3fff::", 20]
|
|
121
|
+
]) blocked.addSubnet(address, prefix, "ipv6");
|
|
122
|
+
const globalV6 = new BlockList();
|
|
123
|
+
globalV6.addSubnet("2000::", 3, "ipv6");
|
|
124
|
+
/**
|
|
125
|
+
* Reject special-use addresses, including mapped IPv4 and IPv6 transition ranges.
|
|
126
|
+
* @param address - a literal IP address from URL parsing or the system resolver.
|
|
127
|
+
* @throws {WebError} when the address is not public unicast.
|
|
128
|
+
*/
|
|
129
|
+
function assertPublicAddress(address) {
|
|
130
|
+
const family = isIP(address);
|
|
131
|
+
if (!(family === 4 ? !blocked.check(address, "ipv4") : family === 6 && globalV6.check(address, "ipv6") && !blocked.check(address, "ipv6"))) throw new WebError("web fetch destination is not a public IP address", "WEB_BLOCKED_URL");
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Validate operator-granted exact origins; paths, credentials and wildcards are rejected.
|
|
135
|
+
* @param origins - configured exceptions to public-only destination enforcement.
|
|
136
|
+
* @returns canonical origins used for exact comparison on every redirect.
|
|
137
|
+
*/
|
|
138
|
+
function resolveAllowedOrigins(origins) {
|
|
139
|
+
return origins.map((origin) => {
|
|
140
|
+
const url = new URL(origin);
|
|
141
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash || url.hostname.includes("*")) throw new Error("web-fetch-http: allowedOrigins must contain exact HTTP(S) origins without credentials, paths, or wildcards");
|
|
142
|
+
return url.origin;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** Resolve once inside connect and pass only that validated address set to the socket. */
|
|
146
|
+
const publicLookup = (hostname, options, callback) => {
|
|
147
|
+
lookup(hostname, {
|
|
148
|
+
all: true,
|
|
149
|
+
order: "verbatim"
|
|
150
|
+
}, (error, addresses) => {
|
|
151
|
+
if (error) {
|
|
152
|
+
callback(error, [], void 0);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
if (addresses.length === 0) throw new WebError("web fetch DNS returned no addresses", "WEB_PROVIDER_ERROR");
|
|
157
|
+
for (const entry of addresses) assertPublicAddress(entry.address);
|
|
158
|
+
} catch (failure) {
|
|
159
|
+
callback(failure, [], void 0);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const first = addresses[0];
|
|
163
|
+
if (options.all) callback(null, addresses, void 0);
|
|
164
|
+
else callback(null, first.address, first.family);
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* Create a direct dispatcher, independent of ambient proxies or global dispatchers.
|
|
169
|
+
* @param url - validated request URL.
|
|
170
|
+
* @param allowedOrigins - operator-granted exceptions, never model-controlled.
|
|
171
|
+
* @returns dispatcher the caller must destroy after consuming or cancelling the response.
|
|
172
|
+
*/
|
|
173
|
+
function publicDispatcher(url, allowedOrigins) {
|
|
174
|
+
if (allowedOrigins.includes(url.origin)) return new Agent();
|
|
175
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
176
|
+
if (isIP(hostname)) assertPublicAddress(hostname);
|
|
177
|
+
return new Agent({ connect: { lookup: publicLookup } });
|
|
178
|
+
}
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region lib/types/provider.js
|
|
181
|
+
/**
|
|
182
|
+
* Safe HTTP(S) retrieval for `ctx.web`: validates URLs, follows only same-origin redirects,
|
|
183
|
+
* enforces time and size limits, classifies and decodes text, and leaves presentation to
|
|
184
|
+
* `@hydraharness/harness-tool-web`. Requests carry no browser cookies or ambient credentials.
|
|
185
|
+
*
|
|
186
|
+
* Connections enforce public IP destinations unless the operator grants an exact origin.
|
|
187
|
+
* @module @hydraharness/harness-web-fetch-http/provider
|
|
188
|
+
*/
|
|
189
|
+
var __addDisposableResource = function(env, value, async) {
|
|
190
|
+
if (value !== null && value !== void 0) {
|
|
191
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
192
|
+
var dispose, inner;
|
|
193
|
+
if (async) {
|
|
194
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
195
|
+
dispose = value[Symbol.asyncDispose];
|
|
196
|
+
}
|
|
197
|
+
if (dispose === void 0) {
|
|
198
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
199
|
+
dispose = value[Symbol.dispose];
|
|
200
|
+
if (async) inner = dispose;
|
|
201
|
+
}
|
|
202
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
203
|
+
if (inner) dispose = function() {
|
|
204
|
+
try {
|
|
205
|
+
inner.call(this);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return Promise.reject(e);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
env.stack.push({
|
|
211
|
+
value,
|
|
212
|
+
dispose,
|
|
213
|
+
async
|
|
214
|
+
});
|
|
215
|
+
} else if (async) env.stack.push({ async: true });
|
|
216
|
+
return value;
|
|
217
|
+
};
|
|
218
|
+
var __disposeResources = (function(SuppressedError) {
|
|
219
|
+
return function(env) {
|
|
220
|
+
function fail(e) {
|
|
221
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
222
|
+
env.hasError = true;
|
|
223
|
+
}
|
|
224
|
+
var r, s = 0;
|
|
225
|
+
function next() {
|
|
226
|
+
while (r = env.stack.pop()) try {
|
|
227
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
228
|
+
if (r.dispose) {
|
|
229
|
+
var result = r.dispose.call(r.value);
|
|
230
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
|
|
231
|
+
fail(e);
|
|
232
|
+
return next();
|
|
233
|
+
});
|
|
234
|
+
} else s |= 1;
|
|
235
|
+
} catch (e) {
|
|
236
|
+
fail(e);
|
|
237
|
+
}
|
|
238
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
239
|
+
if (env.hasError) throw env.error;
|
|
240
|
+
}
|
|
241
|
+
return next();
|
|
242
|
+
};
|
|
243
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
|
|
244
|
+
var e = new Error(message);
|
|
245
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
246
|
+
});
|
|
247
|
+
/** Stable id this provider registers under. */
|
|
248
|
+
const LOCAL_FETCH_PROVIDER_ID = "http";
|
|
249
|
+
/** The anonymous public HTTP(S) fetch provider. */
|
|
250
|
+
var HttpFetchProvider = class {
|
|
251
|
+
limits;
|
|
252
|
+
id = LOCAL_FETCH_PROVIDER_ID;
|
|
253
|
+
allowedOrigins;
|
|
254
|
+
constructor(limits) {
|
|
255
|
+
this.limits = limits;
|
|
256
|
+
this.allowedOrigins = resolveAllowedOrigins(limits.allowedOrigins ?? []);
|
|
257
|
+
}
|
|
258
|
+
/** No credentials to check — an anonymous public fetcher is always usable. */
|
|
259
|
+
available() {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
async fetch(request, signal) {
|
|
263
|
+
const env_1 = {
|
|
264
|
+
stack: [],
|
|
265
|
+
error: void 0,
|
|
266
|
+
hasError: false
|
|
267
|
+
};
|
|
268
|
+
try {
|
|
269
|
+
if (signal?.aborted) throw new WebError("web fetch aborted", "WEB_ABORTED");
|
|
270
|
+
const d = __addDisposableResource(env_1, deadline(signal, this.limits.timeoutMs, "WEB_FETCH_TIMEOUT"), false);
|
|
271
|
+
return await this.followAndRead(request.url, d.signal);
|
|
272
|
+
} catch (e_1) {
|
|
273
|
+
env_1.error = e_1;
|
|
274
|
+
env_1.hasError = true;
|
|
275
|
+
} finally {
|
|
276
|
+
__disposeResources(env_1);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** Follow same-origin redirects up to the hop cap, then read the final response. */
|
|
280
|
+
async followAndRead(initialUrl, signal) {
|
|
281
|
+
let currentUrl = validateFetchUrl(initialUrl, this.limits.maxUrlLength);
|
|
282
|
+
let redirectsFollowed = 0;
|
|
283
|
+
for (;;) {
|
|
284
|
+
const dispatcher = publicDispatcher(currentUrl, this.allowedOrigins);
|
|
285
|
+
try {
|
|
286
|
+
const response = await this.requestOnce(currentUrl, signal, dispatcher);
|
|
287
|
+
if (isRedirectStatus(response.status)) {
|
|
288
|
+
if (redirectsFollowed >= this.limits.maxRedirects) {
|
|
289
|
+
await response.body?.cancel();
|
|
290
|
+
throw new WebError(`exceeded the maximum of ${this.limits.maxRedirects} redirects`, "WEB_REDIRECT_BLOCKED");
|
|
291
|
+
}
|
|
292
|
+
const location = response.headers.get("location");
|
|
293
|
+
if (location === null) {
|
|
294
|
+
await response.body?.cancel();
|
|
295
|
+
throw new WebError(`redirect response (HTTP ${response.status}) without a Location header`, "WEB_PROVIDER_ERROR");
|
|
296
|
+
}
|
|
297
|
+
const target = resolveRedirect(location, currentUrl);
|
|
298
|
+
let validatedTarget;
|
|
299
|
+
try {
|
|
300
|
+
validatedTarget = validateFetchUrl(target.toString(), this.limits.maxUrlLength);
|
|
301
|
+
if (!isSameOrigin(validatedTarget, currentUrl)) throw new WebError(`cross-origin redirect to ${validatedTarget.origin} is not followed automatically; retry against that URL directly`, "WEB_REDIRECT_BLOCKED");
|
|
302
|
+
} catch (error) {
|
|
303
|
+
await response.body?.cancel();
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
await response.body?.cancel();
|
|
307
|
+
currentUrl = validatedTarget;
|
|
308
|
+
redirectsFollowed++;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
return await this.readBody(response, currentUrl, signal);
|
|
312
|
+
} finally {
|
|
313
|
+
await dispatcher.destroy();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async requestOnce(url, signal, dispatcher) {
|
|
318
|
+
try {
|
|
319
|
+
const init = {
|
|
320
|
+
method: "GET",
|
|
321
|
+
redirect: "manual",
|
|
322
|
+
headers: {
|
|
323
|
+
"user-agent": this.limits.userAgent,
|
|
324
|
+
"accept": "text/html,application/xhtml+xml,text/*;q=0.9,application/json;q=0.8"
|
|
325
|
+
},
|
|
326
|
+
signal,
|
|
327
|
+
dispatcher
|
|
328
|
+
};
|
|
329
|
+
return await fetch(url, init);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
throw translateAbortOrNetwork(error, signal);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** Read, byte-cap, classify, and decode the final response body. */
|
|
335
|
+
async readBody(response, finalUrl, signal) {
|
|
336
|
+
const contentType = response.headers.get("content-type");
|
|
337
|
+
const kind = classifyContentType(contentType);
|
|
338
|
+
if (kind === void 0) {
|
|
339
|
+
await response.body?.cancel();
|
|
340
|
+
throw new WebError(`unsupported content type "${contentType ?? "unknown"}"`, "WEB_UNSUPPORTED_CONTENT_TYPE");
|
|
341
|
+
}
|
|
342
|
+
let decoder;
|
|
343
|
+
try {
|
|
344
|
+
decoder = decoderForCharset(parseCharset(contentType));
|
|
345
|
+
} catch (error) {
|
|
346
|
+
await response.body?.cancel();
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
const { bytes, truncatedByBytes } = await this.readCapped(response, signal);
|
|
350
|
+
const decoded = decoder.decode(bytes);
|
|
351
|
+
const truncatedByChars = decoded.length > this.limits.maxBodyChars;
|
|
352
|
+
const content = truncatedByChars ? decoded.slice(0, this.limits.maxBodyChars) : decoded;
|
|
353
|
+
const body = kind === "html" ? {
|
|
354
|
+
kind: "html",
|
|
355
|
+
content
|
|
356
|
+
} : {
|
|
357
|
+
kind: "text",
|
|
358
|
+
content
|
|
359
|
+
};
|
|
360
|
+
return {
|
|
361
|
+
url: finalUrl.toString(),
|
|
362
|
+
statusCode: response.status,
|
|
363
|
+
body,
|
|
364
|
+
truncated: truncatedByBytes || truncatedByChars
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Read the response stream up to `maxResponseBytes`. A `Content-Length` over
|
|
369
|
+
* the cap rejects immediately with `WEB_FETCH_TOO_LARGE`; a stream that grows
|
|
370
|
+
* past the cap is cut short (`truncatedByBytes`) rather than rejected, so a
|
|
371
|
+
* server that under-reports still yields a bounded usable body.
|
|
372
|
+
*/
|
|
373
|
+
async readCapped(response, signal) {
|
|
374
|
+
const declared = response.headers.get("content-length");
|
|
375
|
+
if (declared !== null) {
|
|
376
|
+
const length = Number(declared);
|
|
377
|
+
if (Number.isFinite(length) && length > this.limits.maxResponseBytes) {
|
|
378
|
+
await response.body?.cancel();
|
|
379
|
+
throw new WebError(`response exceeds the maximum of ${this.limits.maxResponseBytes} bytes`, "WEB_FETCH_TOO_LARGE");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/* v8 ignore next -- a 2xx Response from fetch always exposes a body stream; the null guard is defensive. */
|
|
383
|
+
if (response.body === null) return {
|
|
384
|
+
bytes: new Uint8Array(0),
|
|
385
|
+
truncatedByBytes: false
|
|
386
|
+
};
|
|
387
|
+
const chunks = [];
|
|
388
|
+
let total = 0;
|
|
389
|
+
let truncatedByBytes = false;
|
|
390
|
+
const reader = response.body.getReader();
|
|
391
|
+
try {
|
|
392
|
+
for (;;) {
|
|
393
|
+
const { done, value } = await reader.read();
|
|
394
|
+
if (done) break;
|
|
395
|
+
const remaining = this.limits.maxResponseBytes - total;
|
|
396
|
+
if (value.byteLength > remaining) {
|
|
397
|
+
chunks.push(value.subarray(0, remaining));
|
|
398
|
+
total += remaining;
|
|
399
|
+
truncatedByBytes = true;
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
chunks.push(value);
|
|
403
|
+
total += value.byteLength;
|
|
404
|
+
}
|
|
405
|
+
} catch (error) {
|
|
406
|
+
/* v8 ignore next -- mid-stream read fault needs a network drop after headers; translate path covered by request-phase tests. */
|
|
407
|
+
throw translateAbortOrNetwork(error, signal);
|
|
408
|
+
} finally {
|
|
409
|
+
/* v8 ignore next 4 -- cancel() after a completed/broken read settles without rejecting; unobserved best-effort cleanup. */
|
|
410
|
+
await reader.cancel().catch(() => {});
|
|
411
|
+
}
|
|
412
|
+
const bytes = new Uint8Array(total);
|
|
413
|
+
let offset = 0;
|
|
414
|
+
for (const chunk of chunks) {
|
|
415
|
+
bytes.set(chunk, offset);
|
|
416
|
+
offset += chunk.byteLength;
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
bytes,
|
|
420
|
+
truncatedByBytes
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
/** HTTP redirect status codes that carry a `Location`. */
|
|
425
|
+
function isRedirectStatus(status) {
|
|
426
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
427
|
+
}
|
|
428
|
+
/** Resolve a (possibly relative) `Location` against the current URL. */
|
|
429
|
+
function resolveRedirect(location, base) {
|
|
430
|
+
try {
|
|
431
|
+
return new URL(location, base);
|
|
432
|
+
} catch (error) {
|
|
433
|
+
/* v8 ignore next 2 -- URL resolution against a valid absolute base effectively never throws; defensive guard. */
|
|
434
|
+
throw new WebError(`invalid redirect Location "${location}"`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Translate a thrown fetch/stream error into a `WebError`, classified by the
|
|
439
|
+
* deadline signal rather than the thrown value (which differs by phase: the
|
|
440
|
+
* request-phase `fetch` rejects with the abort reason, while the read-phase
|
|
441
|
+
* reader surfaces a bare `AbortError`). `timeoutOf(signal, 'WEB_FETCH_TIMEOUT')`
|
|
442
|
+
* recovering OUR reason means our timeout fired (`WEB_FETCH_TIMEOUT`); any other
|
|
443
|
+
* abort — an upstream cancel, or a foreign/outer deadline's timeout under
|
|
444
|
+
* nesting — is `WEB_ABORTED`; a throw with the signal NOT aborted is a
|
|
445
|
+
* transport/network failure (`WEB_PROVIDER_ERROR`).
|
|
446
|
+
*/
|
|
447
|
+
function translateAbortOrNetwork(error, signal) {
|
|
448
|
+
const timeout = timeoutOf(signal, "WEB_FETCH_TIMEOUT");
|
|
449
|
+
if (timeout !== void 0) return new WebError("web fetch timed out", "WEB_FETCH_TIMEOUT", { cause: timeout });
|
|
450
|
+
if (signal.aborted) return new WebError("web fetch aborted", "WEB_ABORTED", { cause: error });
|
|
451
|
+
if (error instanceof Error && error.cause instanceof WebError) return error.cause;
|
|
452
|
+
return new WebError(`web fetch failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
453
|
+
}
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region lib/types/index.js
|
|
456
|
+
/**
|
|
457
|
+
* `@hydraharness/harness-web-fetch-http`: registers an anonymous public HTTP(S)
|
|
458
|
+
* `WebFetchProvider` with `ctx.web`. A function/namespace plugin (NOT a
|
|
459
|
+
* default-export service): it registers INTO the seam's fetch registry, like the
|
|
460
|
+
* search providers register into the search registry.
|
|
461
|
+
*
|
|
462
|
+
* @module @hydraharness/harness-web-fetch-http
|
|
463
|
+
*/
|
|
464
|
+
const MAX_NODE_TIMER_DELAY_MS = 2147483647;
|
|
465
|
+
/** Default `User-Agent`: an explicit product agent, never a browser disguise. */
|
|
466
|
+
const DEFAULT_USER_AGENT = "hydra-harness/0.0.1 (+https://github.com/MaiHongPhong1902/Hydra-Harness)";
|
|
467
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
468
|
+
const name = "web-fetch-http";
|
|
469
|
+
/** The web seam this provider registers into. */
|
|
470
|
+
const inject = ["web"];
|
|
471
|
+
const Config = z.object({
|
|
472
|
+
maxUrlLength: z.number().default(2048),
|
|
473
|
+
maxResponseBytes: z.number().default(5e6),
|
|
474
|
+
maxBodyChars: z.number().default(1e5),
|
|
475
|
+
timeoutMs: z.number().default(3e4),
|
|
476
|
+
maxRedirects: z.number().default(5),
|
|
477
|
+
userAgent: z.string().default(DEFAULT_USER_AGENT),
|
|
478
|
+
allowedOrigins: z.array(z.string()).default([])
|
|
479
|
+
});
|
|
480
|
+
/** A resource limit (byte/char/length/timeout cap) must be a positive finite number. */
|
|
481
|
+
function assertPositiveFinite(name, value) {
|
|
482
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`web-fetch-http: ${name} must be a positive finite number`);
|
|
483
|
+
}
|
|
484
|
+
/** Node coerces larger timer delays to 1 ms, so reject them at configuration time. */
|
|
485
|
+
function assertTimeoutMs(value) {
|
|
486
|
+
assertPositiveFinite("timeoutMs", value);
|
|
487
|
+
if (value > MAX_NODE_TIMER_DELAY_MS) throw new Error(`web-fetch-http: timeoutMs must be no greater than ${MAX_NODE_TIMER_DELAY_MS}`);
|
|
488
|
+
}
|
|
489
|
+
/** The redirect hop cap must be a non-negative integer (0 follows no redirects). */
|
|
490
|
+
function assertNonNegativeInteger(name, value) {
|
|
491
|
+
if (!Number.isInteger(value) || value < 0) throw new Error(`web-fetch-http: ${name} must be a non-negative integer`);
|
|
492
|
+
}
|
|
493
|
+
/** Register the local HTTP(S) fetch provider with `ctx.web`. */
|
|
494
|
+
function apply(ctx, config) {
|
|
495
|
+
const resolved = config;
|
|
496
|
+
assertPositiveFinite("maxUrlLength", resolved.maxUrlLength);
|
|
497
|
+
assertPositiveFinite("maxResponseBytes", resolved.maxResponseBytes);
|
|
498
|
+
assertPositiveFinite("maxBodyChars", resolved.maxBodyChars);
|
|
499
|
+
assertTimeoutMs(resolved.timeoutMs);
|
|
500
|
+
assertNonNegativeInteger("maxRedirects", resolved.maxRedirects);
|
|
501
|
+
const limits = {
|
|
502
|
+
maxUrlLength: resolved.maxUrlLength,
|
|
503
|
+
maxResponseBytes: resolved.maxResponseBytes,
|
|
504
|
+
maxBodyChars: resolved.maxBodyChars,
|
|
505
|
+
timeoutMs: resolved.timeoutMs,
|
|
506
|
+
maxRedirects: resolved.maxRedirects,
|
|
507
|
+
userAgent: resolved.userAgent,
|
|
508
|
+
allowedOrigins: resolved.allowedOrigins
|
|
509
|
+
};
|
|
510
|
+
ctx.web.registerFetchProvider(new HttpFetchProvider(limits));
|
|
511
|
+
}
|
|
512
|
+
//#endregion
|
|
513
|
+
export { Config, DEFAULT_USER_AGENT, HttpFetchProvider, LOCAL_FETCH_PROVIDER_ID, apply, inject, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@hydraharness/harness-web-fetch-http`.
|
|
4
|
+
* @module @hydraharness/harness-web-fetch-http/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@hydraharness/harness-web-fetch-http";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "web-fetch-http-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
|
13
|
+
* beyond contracts enforced at its owning seam.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => {};
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@hydraharness/harness-web-fetch-http`: registers an anonymous public HTTP(S)
|
|
3
|
+
* `WebFetchProvider` with `ctx.web`. A function/namespace plugin (NOT a
|
|
4
|
+
* default-export service): it registers INTO the seam's fetch registry, like the
|
|
5
|
+
* search providers register into the search registry.
|
|
6
|
+
*
|
|
7
|
+
* @module @hydraharness/harness-web-fetch-http
|
|
8
|
+
*/
|
|
9
|
+
import type { Context } from '@hydraharness/cordis';
|
|
10
|
+
import z from '@hydraharness/schemastery';
|
|
11
|
+
export { LOCAL_FETCH_PROVIDER_ID, HttpFetchProvider, } from './provider.ts';
|
|
12
|
+
export type { HttpFetchLimits } from './provider.ts';
|
|
13
|
+
/** Default `User-Agent`: an explicit product agent, never a browser disguise. */
|
|
14
|
+
export declare const DEFAULT_USER_AGENT = "hydra-harness/0.0.1 (+https://github.com/MaiHongPhong1902/Hydra-Harness)";
|
|
15
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
16
|
+
export declare const name = "web-fetch-http";
|
|
17
|
+
/** The web seam this provider registers into. */
|
|
18
|
+
export declare const inject: string[];
|
|
19
|
+
/** Plugin config: the provider's transport and size limits plus its `User-Agent` (all defaulted). */
|
|
20
|
+
export interface Config {
|
|
21
|
+
/** Maximum accepted request URL length. */
|
|
22
|
+
maxUrlLength?: number;
|
|
23
|
+
/** Maximum response body size in bytes. */
|
|
24
|
+
maxResponseBytes?: number;
|
|
25
|
+
/** Maximum decoded body length in characters. */
|
|
26
|
+
maxBodyChars?: number;
|
|
27
|
+
/** Default fetch timeout in milliseconds, within Node's timer range. */
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
/** Maximum number of same-origin redirect hops to follow. */
|
|
30
|
+
maxRedirects?: number;
|
|
31
|
+
/** `User-Agent` header sent on every request. */
|
|
32
|
+
userAgent?: string;
|
|
33
|
+
/** Exact HTTP(S) origins permitted to reach private addresses. Defaults to none. */
|
|
34
|
+
allowedOrigins?: string[];
|
|
35
|
+
}
|
|
36
|
+
export declare const Config: z<Config>;
|
|
37
|
+
/** Register the local HTTP(S) fetch provider with `ctx.web`. */
|
|
38
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
39
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@hydraharness/harness-web-fetch-http`.
|
|
3
|
+
* @module @hydraharness/harness-web-fetch-http/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@hydraharness/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "web-fetch-http-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Agent } from 'undici';
|
|
2
|
+
/**
|
|
3
|
+
* Reject special-use addresses, including mapped IPv4 and IPv6 transition ranges.
|
|
4
|
+
* @param address - a literal IP address from URL parsing or the system resolver.
|
|
5
|
+
* @throws {WebError} when the address is not public unicast.
|
|
6
|
+
*/
|
|
7
|
+
export declare function assertPublicAddress(address: string): void;
|
|
8
|
+
/**
|
|
9
|
+
* Validate operator-granted exact origins; paths, credentials and wildcards are rejected.
|
|
10
|
+
* @param origins - configured exceptions to public-only destination enforcement.
|
|
11
|
+
* @returns canonical origins used for exact comparison on every redirect.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveAllowedOrigins(origins: readonly string[]): readonly string[];
|
|
14
|
+
/**
|
|
15
|
+
* Create a direct dispatcher, independent of ambient proxies or global dispatchers.
|
|
16
|
+
* @param url - validated request URL.
|
|
17
|
+
* @param allowedOrigins - operator-granted exceptions, never model-controlled.
|
|
18
|
+
* @returns dispatcher the caller must destroy after consuming or cancelling the response.
|
|
19
|
+
*/
|
|
20
|
+
export declare function publicDispatcher(url: URL, allowedOrigins: readonly string[]): Agent;
|
|
21
|
+
//# sourceMappingURL=network.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL validation and content-type classification for the local HTTP(S) fetch
|
|
3
|
+
* provider — the pure, network-free half. The provider's `fetch()` composes
|
|
4
|
+
* these with transport (redirect following, byte caps, decoding).
|
|
5
|
+
*
|
|
6
|
+
* @module @hydraharness/harness-web-fetch-http/policy
|
|
7
|
+
*/
|
|
8
|
+
/** The body kinds this provider decodes. */
|
|
9
|
+
export type FetchableKind = 'html' | 'text';
|
|
10
|
+
/**
|
|
11
|
+
* Validate a request URL against the basic transport hygiene the provider
|
|
12
|
+
* enforces before any network access: http(s) only, no embedded credentials,
|
|
13
|
+
* bounded length. Returns the parsed `URL`. Throws {@link WebError} otherwise.
|
|
14
|
+
* Destination IP checks additionally run at connection time in the HTTP provider.
|
|
15
|
+
*
|
|
16
|
+
* @param input - the raw URL string from the fetch request.
|
|
17
|
+
* @param maxUrlLength - inclusive upper bound on `input`'s length.
|
|
18
|
+
* @returns the parsed `URL`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function validateFetchUrl(input: string, maxUrlLength: number): URL;
|
|
21
|
+
/**
|
|
22
|
+
* Two URLs are same-origin when scheme, hostname, and port match. A redirect
|
|
23
|
+
* that crosses origins is refused so each new origin requires a fresh tool call
|
|
24
|
+
* (and thus a fresh provider/permission decision).
|
|
25
|
+
*
|
|
26
|
+
* @param a - one of the two URLs to compare.
|
|
27
|
+
* @param b - the other URL to compare.
|
|
28
|
+
* @returns true when `a` and `b` share scheme, hostname, and port.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isSameOrigin(a: URL, b: URL): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Classify a response `Content-Type` into a decodable body kind, or `undefined`
|
|
33
|
+
* for an unsupported (e.g. binary) type. `text/html` and `application/xhtml+xml`
|
|
34
|
+
* are `html`; other `text/*` plus a few structured text types are `text`.
|
|
35
|
+
*
|
|
36
|
+
* @param contentType - the raw `Content-Type` header, or `null` when the
|
|
37
|
+
* response carries none (unsupported).
|
|
38
|
+
* @returns the decodable kind, or `undefined` for an unsupported type.
|
|
39
|
+
*/
|
|
40
|
+
export declare function classifyContentType(contentType: string | null): FetchableKind | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Extract the `charset` parameter from a response `Content-Type`, lower-cased,
|
|
43
|
+
* or `undefined` when absent. The provider feeds this label to `TextDecoder`
|
|
44
|
+
* so a non-UTF-8 response is decoded with its declared encoding rather than
|
|
45
|
+
* silently mangled into replacement characters.
|
|
46
|
+
*
|
|
47
|
+
* @param contentType - the raw `Content-Type` header, or `null` when the
|
|
48
|
+
* response carries none.
|
|
49
|
+
* @returns the lower-cased charset label, or `undefined` when none is declared.
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseCharset(contentType: string | null): string | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* Build a `TextDecoder` for the declared charset, falling back to UTF-8 when
|
|
54
|
+
* none is declared. Throws {@link WebError} `WEB_UNSUPPORTED_CONTENT_TYPE` when
|
|
55
|
+
* the label is present but not a charset `TextDecoder` recognizes — better to
|
|
56
|
+
* fail loudly than return mojibake.
|
|
57
|
+
*
|
|
58
|
+
* @param charset - the declared charset label (from {@link parseCharset}), or
|
|
59
|
+
* `undefined` to default to UTF-8.
|
|
60
|
+
* @returns a decoder for the declared (or defaulted) encoding.
|
|
61
|
+
*/
|
|
62
|
+
export declare function decoderForCharset(charset: string | undefined): TextDecoder;
|
|
63
|
+
//# sourceMappingURL=policy.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe HTTP(S) retrieval for `ctx.web`: validates URLs, follows only same-origin redirects,
|
|
3
|
+
* enforces time and size limits, classifies and decodes text, and leaves presentation to
|
|
4
|
+
* `@hydraharness/harness-tool-web`. Requests carry no browser cookies or ambient credentials.
|
|
5
|
+
*
|
|
6
|
+
* Connections enforce public IP destinations unless the operator grants an exact origin.
|
|
7
|
+
* @module @hydraharness/harness-web-fetch-http/provider
|
|
8
|
+
*/
|
|
9
|
+
import type { WebFetchProvider, WebFetchRequest, WebFetchResult } from '@hydraharness/harness-web';
|
|
10
|
+
/** Resolved provider limits (the plugin's schemastery Config supplies defaults). */
|
|
11
|
+
export interface HttpFetchLimits {
|
|
12
|
+
/** Maximum accepted request URL length. */
|
|
13
|
+
maxUrlLength: number;
|
|
14
|
+
/** Maximum response body size in bytes (read is aborted past this). */
|
|
15
|
+
maxResponseBytes: number;
|
|
16
|
+
/** Maximum decoded body length in characters (truncated past this). */
|
|
17
|
+
maxBodyChars: number;
|
|
18
|
+
/** Default fetch timeout in milliseconds. */
|
|
19
|
+
timeoutMs: number;
|
|
20
|
+
/** Maximum number of (same-origin) redirect hops to follow. */
|
|
21
|
+
maxRedirects: number;
|
|
22
|
+
/** `User-Agent` header sent on every request. */
|
|
23
|
+
userAgent: string;
|
|
24
|
+
/** Exact operator-granted origins allowed to reach non-public addresses. Defaults to none. */
|
|
25
|
+
allowedOrigins?: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
/** Stable id this provider registers under. */
|
|
28
|
+
export declare const LOCAL_FETCH_PROVIDER_ID = "http";
|
|
29
|
+
/** The anonymous public HTTP(S) fetch provider. */
|
|
30
|
+
export declare class HttpFetchProvider implements WebFetchProvider {
|
|
31
|
+
private readonly limits;
|
|
32
|
+
readonly id = "http";
|
|
33
|
+
private readonly allowedOrigins;
|
|
34
|
+
constructor(limits: HttpFetchLimits);
|
|
35
|
+
/** No credentials to check — an anonymous public fetcher is always usable. */
|
|
36
|
+
available(): boolean;
|
|
37
|
+
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>;
|
|
38
|
+
/** Follow same-origin redirects up to the hop cap, then read the final response. */
|
|
39
|
+
private followAndRead;
|
|
40
|
+
private requestOnce;
|
|
41
|
+
/** Read, byte-cap, classify, and decode the final response body. */
|
|
42
|
+
private readBody;
|
|
43
|
+
/**
|
|
44
|
+
* Read the response stream up to `maxResponseBytes`. A `Content-Length` over
|
|
45
|
+
* the cap rejects immediately with `WEB_FETCH_TOO_LARGE`; a stream that grows
|
|
46
|
+
* past the cap is cut short (`truncatedByBytes`) rather than rejected, so a
|
|
47
|
+
* server that under-reports still yields a bounded usable body.
|
|
48
|
+
*/
|
|
49
|
+
private readCapped;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=provider.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hydraharness/harness-web-fetch-http",
|
|
3
|
+
"description": "Anonymous public HTTP(S) fetch provider for the Hydra harness web capability seam (ctx.web)",
|
|
4
|
+
"hydra": {
|
|
5
|
+
"plugin": {
|
|
6
|
+
"application": "Retrieve publicly accessible HTTP or HTTPS pages without account credentials."
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"version": "0.1.1-rc.6",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/MaiHongPhong1902/Hydra-Harness.git",
|
|
16
|
+
"directory": "packages/web/web-fetch-http"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "lib/index.js",
|
|
20
|
+
"types": "lib/types/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./lib/types/index.d.ts",
|
|
24
|
+
"default": "./lib/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./invariant": {
|
|
27
|
+
"types": "./lib/types/invariant.d.ts",
|
|
28
|
+
"default": "./lib/invariant.js"
|
|
29
|
+
},
|
|
30
|
+
"./src/*": "./src/*",
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"lib/index.js",
|
|
35
|
+
"lib/invariant.js",
|
|
36
|
+
"lib/types/**/*.d.ts"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.6",
|
|
41
|
+
"@hydraharness/harness-timeout": "^0.1.1-rc.6",
|
|
42
|
+
"@hydraharness/harness-web": "^0.1.1-rc.6",
|
|
43
|
+
"@hydraharness/cordis": "^4.0.2"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"undici": "^7.28.0",
|
|
47
|
+
"@hydraharness/schemastery": "^3.18.2"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@hydraharness/harness-timeout": "^0.1.1-rc.6",
|
|
51
|
+
"@hydraharness/cordis": "^4.0.2",
|
|
52
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.6",
|
|
53
|
+
"@hydraharness/harness-web": "^0.1.1-rc.6"
|
|
54
|
+
}
|
|
55
|
+
}
|