@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.
@@ -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": "Mozilla/5.0 (compatible; PiWebFetch/1.0; +https://pi.dev)",
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(target, address, signal, attemptTimeoutMs, options.headers ?? {});
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
- const contentKind = classifyContentKind(rawFinalUrl, result.extractor, shellSuspected);
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
+ }