@zeldrisho/pi-web-fetch 0.14.1 → 0.16.0
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/CHANGELOG.md +28 -1
- package/README.md +3 -5
- package/package.json +5 -2
- package/src/acquisition/fetch.ts +167 -12
- package/src/acquisition/llms.ts +42 -11
- package/src/challenges/aws-waf-resolver.ts +806 -0
- package/src/challenges/aws-waf-solver.ts +65 -0
- package/src/challenges/aws-waf-worker.ts +14 -0
- package/src/challenges/aws-waf.ts +34 -0
- package/src/document/content.ts +4 -0
- package/src/document/focus.ts +117 -11
- package/src/document/openapi.ts +80 -0
- package/src/document/source-format.ts +180 -0
- package/src/index.ts +7 -0
- package/src/network/cookies.ts +41 -0
- package/src/network/network-redirects.ts +15 -2
- package/src/network/network-transport.ts +21 -3
- package/src/service.ts +23 -5
- package/workers/aws-waf-solver.js +56 -0
- package/workers/aws-waf-worker.js +14 -0
|
@@ -8,6 +8,9 @@ import type { ValidatedTarget } from "./network-policy.ts";
|
|
|
8
8
|
/** Maximum size in bytes for raw HTTP response bodies. */
|
|
9
9
|
export const FETCH_MAX_BYTES = 5 * 1_024 * 1_024;
|
|
10
10
|
|
|
11
|
+
/** Fixed User-Agent used for page requests and challenge solvers. */
|
|
12
|
+
export const WEB_FETCH_USER_AGENT = "Mozilla/5.0 (compatible; PiWebFetch/1.0; +https://pi.dev)";
|
|
13
|
+
|
|
11
14
|
/**
|
|
12
15
|
* Per-address connect deadline. A hanging address is abandoned after this
|
|
13
16
|
* budget so the next validated address can be tried inside the overall
|
|
@@ -67,6 +70,8 @@ async function requestOnce(
|
|
|
67
70
|
signal: AbortSignal,
|
|
68
71
|
attemptTimeoutMs: number,
|
|
69
72
|
headers: Readonly<Record<string, string>>,
|
|
73
|
+
method = "GET",
|
|
74
|
+
body?: string | Uint8Array,
|
|
70
75
|
): Promise<IncomingMessage> {
|
|
71
76
|
const family = isIP(address);
|
|
72
77
|
|
|
@@ -114,11 +119,12 @@ async function requestOnce(
|
|
|
114
119
|
target.url,
|
|
115
120
|
{
|
|
116
121
|
lookup,
|
|
122
|
+
method,
|
|
117
123
|
signal: controller.signal,
|
|
118
124
|
headers: {
|
|
119
125
|
Accept:
|
|
120
126
|
"text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, application/xhtml+xml;q=0.7, application/json;q=0.7, application/xml;q=0.7, */*;q=0.1",
|
|
121
|
-
"User-Agent":
|
|
127
|
+
"User-Agent": WEB_FETCH_USER_AGENT,
|
|
122
128
|
...headers,
|
|
123
129
|
},
|
|
124
130
|
},
|
|
@@ -139,7 +145,7 @@ async function requestOnce(
|
|
|
139
145
|
if (attemptExpired) finish(() => reject(unreachableError()));
|
|
140
146
|
else finish(() => reject(error));
|
|
141
147
|
});
|
|
142
|
-
outgoing.end();
|
|
148
|
+
outgoing.end(body);
|
|
143
149
|
});
|
|
144
150
|
}
|
|
145
151
|
|
|
@@ -148,6 +154,10 @@ export interface RequestPinnedOptions {
|
|
|
148
154
|
attemptTimeoutMs?: number;
|
|
149
155
|
/** Additional fixed request headers, used for conditional cache revalidation. */
|
|
150
156
|
headers?: Readonly<Record<string, string>>;
|
|
157
|
+
/** HTTP method; defaults to GET. */
|
|
158
|
+
method?: string;
|
|
159
|
+
/** Request body for non-GET requests. */
|
|
160
|
+
body?: string | Uint8Array;
|
|
151
161
|
}
|
|
152
162
|
|
|
153
163
|
/**
|
|
@@ -169,7 +179,15 @@ export async function requestPinned(
|
|
|
169
179
|
|
|
170
180
|
for (const address of addresses) {
|
|
171
181
|
try {
|
|
172
|
-
return await requestOnce(
|
|
182
|
+
return await requestOnce(
|
|
183
|
+
target,
|
|
184
|
+
address,
|
|
185
|
+
signal,
|
|
186
|
+
attemptTimeoutMs,
|
|
187
|
+
options.headers ?? {},
|
|
188
|
+
options.method,
|
|
189
|
+
options.body,
|
|
190
|
+
);
|
|
173
191
|
} catch (error) {
|
|
174
192
|
lastError = error;
|
|
175
193
|
|
package/src/service.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { sliceCompleteDocument, type CompleteDocument } from "./document/content
|
|
|
13
13
|
import { revalidateCompleteDocument, type FetchRemoteDependencies } from "./acquisition/fetch.ts";
|
|
14
14
|
import { fetchDocumentWithLlmsTxtSupport } from "./acquisition/llms.ts";
|
|
15
15
|
import { focusMarkdown, type FocusDetails } from "./document/focus.ts";
|
|
16
|
+
import { isCachedOpenapi } from "./document/openapi.ts";
|
|
16
17
|
import { resolveFragmentOffset, urlWithoutFragment } from "./document/fragments.ts";
|
|
17
18
|
import { InflightCoalescer } from "./inflight";
|
|
18
19
|
import { createDocumentOutline, type DocumentOutline } from "./document/outline.ts";
|
|
@@ -43,6 +44,7 @@ const encoder = new TextEncoder();
|
|
|
43
44
|
export type ContentKind =
|
|
44
45
|
| "repository-readme"
|
|
45
46
|
| "code-file"
|
|
47
|
+
| "openapi-spec"
|
|
46
48
|
| "directory-listing"
|
|
47
49
|
| "llms-index"
|
|
48
50
|
| "article"
|
|
@@ -73,14 +75,24 @@ function safeUrl(value: string): URL | undefined {
|
|
|
73
75
|
* @param url - The fetched URL
|
|
74
76
|
* @param extractor - Extraction method used
|
|
75
77
|
* @param shellSuspected - Whether the page appears to be an app shell
|
|
78
|
+
* @param sourceLanguage - Code-fence language when the body was reformatted as source
|
|
76
79
|
* @returns Content classification
|
|
77
80
|
*/
|
|
78
81
|
export function classifyContentKind(
|
|
79
82
|
url: string,
|
|
80
83
|
extractor: CompleteDocument["extractor"],
|
|
81
84
|
shellSuspected: boolean,
|
|
85
|
+
sourceLanguage?: string,
|
|
86
|
+
openapiSpec = false,
|
|
82
87
|
): ContentKind {
|
|
83
88
|
if (shellSuspected) return "markup-shell";
|
|
89
|
+
|
|
90
|
+
if (openapiSpec) return "openapi-spec";
|
|
91
|
+
|
|
92
|
+
// A reformatted body is code whatever host served it: the response was read as
|
|
93
|
+
// source, not as page prose.
|
|
94
|
+
if (sourceLanguage !== undefined) return "code-file";
|
|
95
|
+
|
|
84
96
|
const parsed = safeUrl(url);
|
|
85
97
|
const host = parsed?.hostname ?? "";
|
|
86
98
|
const path = parsed?.pathname ?? "";
|
|
@@ -423,6 +435,7 @@ export async function executeWebFetch(
|
|
|
423
435
|
|
|
424
436
|
const fresh = Boolean(
|
|
425
437
|
document &&
|
|
438
|
+
!document.llmsTxtFallback &&
|
|
426
439
|
!document.extractionDiagnostics?.botWall &&
|
|
427
440
|
(document.cachedAt === undefined || now - document.cachedAt < CACHE_TTL_MS),
|
|
428
441
|
);
|
|
@@ -576,7 +589,15 @@ export async function executeWebFetch(
|
|
|
576
589
|
const rawFinalUrl = result.url;
|
|
577
590
|
const finalUrl = redactUrlForDisplay(rawFinalUrl);
|
|
578
591
|
const shellSuspected = result.shellSuspected;
|
|
579
|
-
|
|
592
|
+
|
|
593
|
+
const contentKind = classifyContentKind(
|
|
594
|
+
rawFinalUrl,
|
|
595
|
+
result.extractor,
|
|
596
|
+
shellSuspected,
|
|
597
|
+
result.sourceLanguage,
|
|
598
|
+
document.openapiSpec ?? isCachedOpenapi(document.markdown, document.contentType),
|
|
599
|
+
);
|
|
600
|
+
|
|
580
601
|
const confidence = classifyConfidence(result.extractor, shellSuspected, result.markdown.length);
|
|
581
602
|
|
|
582
603
|
const requestedPath = safeUrl(params.url)?.pathname.replace(/\/+$/u, "").toLowerCase();
|
|
@@ -603,10 +624,7 @@ export async function executeWebFetch(
|
|
|
603
624
|
]
|
|
604
625
|
: []),
|
|
605
626
|
...(showLlmsNotice && result.llmsTxtIndexUrl
|
|
606
|
-
? [
|
|
607
|
-
`Documentation index available: ${redactUrlForDisplay(result.llmsTxtIndexUrl)}. Use it if this page does not cover the topic.`,
|
|
608
|
-
"",
|
|
609
|
-
]
|
|
627
|
+
? [`Index: ${redactUrlForDisplay(result.llmsTxtIndexUrl)}`, ""]
|
|
610
628
|
: []),
|
|
611
629
|
...(resolvedFragment.fragment !== undefined && !fragmentMatched
|
|
612
630
|
? [
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash, scryptSync } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Returns whether the first `difficulty` bits of a hash are zero. */
|
|
4
|
+
export function hasLeadingZeroBits(hash, difficulty) {
|
|
5
|
+
for (let bit = 0; bit < difficulty; bit += 1) {
|
|
6
|
+
const byte = hash[Math.floor(bit / 8)];
|
|
7
|
+
|
|
8
|
+
if (byte === undefined || (byte & (1 << (7 - (bit % 8)))) !== 0) return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Solves the bandwidth challenge using a bounded zero-filled buffer. */
|
|
15
|
+
export function solveAwsWafBandwidth(difficulty) {
|
|
16
|
+
const sizes = [1_024, 1_024, 10_240, 102_400, 1_048_576, 10_485_760];
|
|
17
|
+
|
|
18
|
+
return Buffer.alloc(sizes[Math.min(5, Math.max(0, difficulty))] ?? 1_024).toString("base64");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Computes bounded proof-of-work inside a disposable worker. */
|
|
22
|
+
export function solveAwsWafWork(work) {
|
|
23
|
+
if (!Number.isInteger(work.difficulty) || work.difficulty < 1 || work.difficulty > 32)
|
|
24
|
+
return undefined;
|
|
25
|
+
|
|
26
|
+
if (work.type === "NetworkBandwidth") return solveAwsWafBandwidth(work.difficulty);
|
|
27
|
+
|
|
28
|
+
if (work.type === "SHA256") {
|
|
29
|
+
for (let nonce = 0; nonce < 2_000_000; nonce += 1) {
|
|
30
|
+
const digest = createHash("sha256").update(`${work.input}${work.checksum}${nonce}`).digest();
|
|
31
|
+
|
|
32
|
+
if (hasLeadingZeroBits(digest, work.difficulty)) return String(nonce);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (work.type === "HashcashScrypt") {
|
|
37
|
+
const cost = work.memory > 0 ? work.memory : 128;
|
|
38
|
+
|
|
39
|
+
// Reject large or invalid costs before invoking OpenSSL.
|
|
40
|
+
if (!Number.isInteger(cost) || cost < 2 || cost > 16_384 || (cost & (cost - 1)) !== 0)
|
|
41
|
+
return undefined;
|
|
42
|
+
|
|
43
|
+
for (let nonce = 0; nonce < 100_000; nonce += 1) {
|
|
44
|
+
const digest = scryptSync(`${work.input}${work.checksum}${nonce}`, work.checksum, 32, {
|
|
45
|
+
N: cost,
|
|
46
|
+
r: 8,
|
|
47
|
+
p: 1,
|
|
48
|
+
maxmem: 64 * 1_024 * 1_024,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (hasLeadingZeroBits(digest, work.difficulty)) return String(nonce);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* v8 ignore file -- exercised through worker integration tests. */
|
|
2
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
3
|
+
import { solveAwsWafWork } from "./aws-waf-solver.js";
|
|
4
|
+
|
|
5
|
+
const work = workerData;
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
parentPort?.postMessage({ kind: "solved", solution: solveAwsWafWork(work) });
|
|
9
|
+
} catch (error) {
|
|
10
|
+
parentPort?.postMessage({
|
|
11
|
+
kind: "error",
|
|
12
|
+
message: error instanceof Error ? error.message : "AWS WAF worker failed.",
|
|
13
|
+
});
|
|
14
|
+
}
|