pi-unsloth-webtools 0.3.0 → 0.3.1

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/README.md CHANGED
@@ -39,6 +39,8 @@ Mirrors Unsloth Studio's `web_search` tool:
39
39
  - Transient engine failures (network errors or null responses) are retried once with a short
40
40
  backoff inside the same timeout budget (a retry that cannot fit in the remaining budget is
41
41
  skipped); timeouts and cancellations are never retried.
42
+ - Sweeps stop as soon as enough results are gathered: engines still in flight are aborted
43
+ instead of being allowed to run to their timeout.
42
44
 
43
45
  ### web_fetch
44
46
 
@@ -48,11 +50,16 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
48
50
  - URL validation: http/https only, no credentials or encoded hostnames, hostname/port checks (any
49
51
  port 1–65535 is permitted; SSRF protection is enforced at the resolved-IP layer, not by port
50
52
  allowlists).
53
+ Canonical public IPv4 literals are accepted like IPv6 literals; private literals are still
54
+ blocked at the resolved-IP layer.
51
55
  - DNS resolution with SSRF protection: every resolved address is validated against
52
56
  private/loopback/link-local/CGNAT/documentation/multicast/reserved ranges, then the validated IP
53
57
  is pinned for the connection (custom `lookup` + SNI `servername`), so DNS cannot rebind between
54
58
  validation and fetch; resolution shares the caller's abort signal and the overall deadline,
55
59
  so a stuck resolver cannot outlive the fetch.
60
+ When a host publishes both IPv4 and IPv6 addresses, IPv4 is preferred (broken IPv6 routes
61
+ cannot stall a fetch), and a connection failure falls back to the next validated address for
62
+ the same host before giving up.
56
63
  - GitHub repo root pages are rewritten to the unauthenticated README API
57
64
  (`Accept: application/vnd.github.raw+json`), falling back to the raw README URL
58
65
  (`raw.githubusercontent.com`, no API rate limit) and then to the HTML page on failure.
package/engines.ts CHANGED
@@ -863,6 +863,7 @@ export async function autoTextSearch(
863
863
  const seenProviders = new Set<string>();
864
864
  const aggregator = new ResultsAggregator();
865
865
  const ctx: EngineContext = { region: "us-en", safesearch: "moderate" };
866
+ const controller = new AbortController();
866
867
  let timedOut = false;
867
868
  let cancelled = false;
868
869
  const uniqueProviders = new Set(engines.map((e) => e.provider)).size;
@@ -872,14 +873,19 @@ export async function autoTextSearch(
872
873
  const run = async (engine: Engine) => {
873
874
  let results: SearchResult[] | null = null;
874
875
  for (let attempt = 0; attempt < 2 && results === null; attempt++) {
876
+ if (controller.signal.aborted) return;
875
877
  const budgetLeft = deadline - Date.now();
876
878
  if (budgetLeft <= 0) return;
877
879
  if (attempt > 0 && budgetLeft < ENGINE_RETRY_BACKOFF_MS) return;
878
880
  const remaining = Math.max(1, budgetLeft);
881
+ const engineSignal = signal
882
+ ? AbortSignal.any([signal, controller.signal])
883
+ : controller.signal;
879
884
  try {
880
- results = await engine.search(query, ctx, remaining, signal);
885
+ results = await engine.search(query, ctx, remaining, engineSignal);
881
886
  } catch (e) {
882
887
  if (e instanceof SearchCancelled) {
888
+ if (controller.signal.aborted && !signal?.aborted) return;
883
889
  cancelled = true;
884
890
  return;
885
891
  }
@@ -895,15 +901,20 @@ export async function autoTextSearch(
895
901
  cancelled = true;
896
902
  return;
897
903
  }
904
+ if (controller.signal.aborted) return;
898
905
  }
899
906
  }
900
907
  if (results && results.length) {
901
908
  aggregator.extend(results);
902
909
  seenProviders.add(engine.provider);
910
+ if (aggregator.size >= maxResults) controller.abort();
903
911
  }
904
912
  };
905
913
  while (i < engines.length) {
906
- if (aggregator.size >= maxResults || cancelled) break;
914
+ if (aggregator.size >= maxResults || cancelled) {
915
+ controller.abort();
916
+ break;
917
+ }
907
918
  const engine = engines[i++];
908
919
  if (seenProviders.has(engine.provider)) continue;
909
920
  pending.push(run(engine));
package/html-to-md.ts CHANGED
@@ -228,8 +228,11 @@ interface HtmlHandlers {
228
228
  handleCharRef(name: string): void;
229
229
  }
230
230
 
231
- const START_TAG_NAME_RE = /^[a-zA-Z][^\s/>]*/;
232
- const ATTR_NAME_RE = /^[^\s=/>]+/;
231
+ const START_TAG_NAME_RE = /[a-zA-Z][^\s/>]*/y;
232
+ const ATTR_NAME_RE = /[^\s=/>]+/y;
233
+ const ENTITY_NAMED_RE = /&([A-Za-z][A-Za-z0-9.-]*);/y;
234
+ const ENTITY_NUMERIC_RE = /&#([xX][0-9a-fA-F]+|[0-9]+);?/y;
235
+ const ENTITY_LEGACY_RE = /&([A-Za-z][A-Za-z0-9.-]*)(?=[^A-Za-z0-9]|$)/y;
233
236
 
234
237
  const RAW_TEXT_TAGS = [
235
238
  "script",
@@ -242,9 +245,23 @@ const RAW_TEXT_TAGS = [
242
245
  "noframes",
243
246
  ];
244
247
 
245
- const RAW_TEXT_CLOSERS: Record<string, RegExp> = Object.fromEntries(
246
- RAW_TEXT_TAGS.map((name) => [name, new RegExp(`</${name}\\s*>`, "i")]),
247
- );
248
+ function findRawTextClose(
249
+ input: string,
250
+ lowerInput: string,
251
+ from: number,
252
+ name: string,
253
+ ): { start: number; end: number } | null {
254
+ const needle = `</${name}`;
255
+ let pos = from;
256
+ while (true) {
257
+ const hit = lowerInput.indexOf(needle, pos);
258
+ if (hit === -1) return null;
259
+ let j = hit + needle.length;
260
+ while (j < input.length && /\s/.test(input[j])) j++;
261
+ if (j < input.length && input[j] === ">") return { start: hit, end: j + 1 };
262
+ pos = hit + 1;
263
+ }
264
+ }
248
265
 
249
266
  function parseAttrsUntilClose(input: string, pos: number): [AttrDict, number, boolean] {
250
267
  const attrs: AttrDict = {};
@@ -257,10 +274,11 @@ function parseAttrsUntilClose(input: string, pos: number): [AttrDict, number, bo
257
274
  pos++;
258
275
  continue;
259
276
  }
260
- const nameMatch = ATTR_NAME_RE.exec(input.slice(pos));
277
+ ATTR_NAME_RE.lastIndex = pos;
278
+ const nameMatch = ATTR_NAME_RE.exec(input);
261
279
  if (!nameMatch) return [attrs, -1, false];
262
280
  const name = nameMatch[0].toLowerCase();
263
- pos += nameMatch[0].length;
281
+ pos = nameMatch.index + nameMatch[0].length;
264
282
  while (pos < input.length && /\s/.test(input[pos])) pos++;
265
283
  let value: string | null = null;
266
284
  if (pos < input.length && input[pos] === "=") {
@@ -285,64 +303,69 @@ function parseAttrsUntilClose(input: string, pos: number): [AttrDict, number, bo
285
303
  }
286
304
 
287
305
  function scanTag(
288
- html: string,
306
+ input: string,
289
307
  i: number,
290
308
  ): { end: number; kind: "comment" | "decl" | "end" | "start" | "startend"; name?: string; attrs?: AttrDict } | null {
291
- const rest = html.slice(i + 1);
292
- if (rest.startsWith("!--")) {
293
- const close = html.indexOf("-->", i + 4);
309
+ if (input.startsWith("!--", i + 1)) {
310
+ const close = input.indexOf("-->", i + 4);
294
311
  if (close === -1) return null;
295
312
  return { end: close + 3, kind: "decl" };
296
313
  }
297
- if (rest.startsWith("!") || rest.startsWith("?")) {
314
+ const next = input[i + 1];
315
+ if (next === "!" || next === "?") {
298
316
  let j = i + 2;
299
- while (j < html.length && html[j] !== ">") j++;
300
- if (j >= html.length) return null;
317
+ while (j < input.length && input[j] !== ">") j++;
318
+ if (j >= input.length) return null;
301
319
  return { end: j + 1, kind: "decl" };
302
320
  }
303
- if (rest.startsWith("/")) {
321
+ if (next === "/") {
304
322
  let j = i + 2;
305
- while (j < html.length && /[\s>]/.test(html[j]) === false) j++;
306
- const name = html.slice(i + 2, j).toLowerCase();
323
+ while (j < input.length && !/[\s>]/.test(input[j])) j++;
324
+ const name = input.slice(i + 2, j).toLowerCase();
307
325
  if (!name) return null;
308
- while (j < html.length && html[j] !== ">") j++;
309
- if (j >= html.length) return null;
326
+ while (j < input.length && input[j] !== ">") j++;
327
+ if (j >= input.length) return null;
310
328
  return { end: j + 1, kind: "end", name };
311
329
  }
312
- const nameMatch = START_TAG_NAME_RE.exec(rest);
330
+ START_TAG_NAME_RE.lastIndex = i + 1;
331
+ const nameMatch = START_TAG_NAME_RE.exec(input);
313
332
  if (!nameMatch) return null;
314
333
  const name = nameMatch[0].toLowerCase();
315
- const [attrs, next, selfClosing] = parseAttrsUntilClose(html, i + 1 + nameMatch[0].length);
316
- if (next === -1) return null;
317
- return { end: next, kind: selfClosing ? "startend" : "start", name, attrs };
334
+ const [attrs, nextPos, selfClosing] = parseAttrsUntilClose(input, i + 1 + nameMatch[0].length);
335
+ if (nextPos === -1) return null;
336
+ return { end: nextPos, kind: selfClosing ? "startend" : "start", name, attrs };
318
337
  }
319
338
 
320
339
 
321
340
  export function feedHtml(input: string, handlers: HtmlHandlers): void {
322
- const emitText = (text: string) => {
323
- if (!text) return;
324
- let pos = 0;
325
- while (pos < text.length) {
326
- const amp = text.indexOf("&", pos);
327
- if (amp === -1) {
328
- handlers.handleData(text.slice(pos));
341
+ let lowerInput: string | null = null;
342
+ const emitTextRange = (start: number, end: number) => {
343
+ if (end <= start) return;
344
+ let pos = start;
345
+ while (pos < end) {
346
+ const amp = input.indexOf("&", pos);
347
+ if (amp === -1 || amp >= end) {
348
+ handlers.handleData(input.slice(pos, end));
329
349
  return;
330
350
  }
331
- if (amp > pos) handlers.handleData(text.slice(pos, amp));
332
- const named = /^&([A-Za-z][A-Za-z0-9.-]*);/.exec(text.slice(amp));
333
- if (named) {
351
+ if (amp > pos) handlers.handleData(input.slice(pos, amp));
352
+ ENTITY_NAMED_RE.lastIndex = amp;
353
+ const named = ENTITY_NAMED_RE.exec(input);
354
+ if (named && named.index === amp && amp + named[0].length <= end) {
334
355
  handlers.handleEntityRef(named[1]);
335
356
  pos = amp + named[0].length;
336
357
  continue;
337
358
  }
338
- const numeric = /^&#([xX][0-9a-fA-F]+|[0-9]+);?/.exec(text.slice(amp));
339
- if (numeric) {
359
+ ENTITY_NUMERIC_RE.lastIndex = amp;
360
+ const numeric = ENTITY_NUMERIC_RE.exec(input);
361
+ if (numeric && numeric.index === amp && amp + numeric[0].length <= end) {
340
362
  handlers.handleCharRef(numeric[1]);
341
363
  pos = amp + numeric[0].length;
342
364
  continue;
343
365
  }
344
- const legacy = /^&([A-Za-z][A-Za-z0-9.-]*)(?=[^A-Za-z0-9]|$)/.exec(text.slice(amp));
345
- if (legacy) {
366
+ ENTITY_LEGACY_RE.lastIndex = amp;
367
+ const legacy = ENTITY_LEGACY_RE.exec(input);
368
+ if (legacy && legacy.index === amp && amp + legacy[0].length <= end) {
346
369
  handlers.handleEntityRef(legacy[1]);
347
370
  pos = amp + legacy[0].length;
348
371
  continue;
@@ -364,28 +387,28 @@ export function feedHtml(input: string, handlers: HtmlHandlers): void {
364
387
  i++;
365
388
  continue;
366
389
  }
367
- emitText(input.slice(textStart, i));
390
+ emitTextRange(textStart, i);
368
391
  if (tag.kind === "start") {
369
- const rawCloser = RAW_TEXT_CLOSERS[tag.name!];
370
- if (rawCloser) {
371
- const after = input.slice(tag.end);
372
- const closeMatch = rawCloser.exec(after);
373
- if (closeMatch) {
374
- handlers.handleStartTag(tag.name!, tag.attrs!);
375
- emitText(after.slice(0, closeMatch.index));
376
- handlers.handleEndTag(tag.name!);
377
- textStart = tag.end + closeMatch.index + closeMatch[0].length;
392
+ const rawName = tag.name!;
393
+ if (RAW_TEXT_TAGS.includes(rawName)) {
394
+ lowerInput ??= input.toLowerCase();
395
+ const close = findRawTextClose(input, lowerInput, tag.end, rawName);
396
+ if (close) {
397
+ handlers.handleStartTag(rawName, tag.attrs!);
398
+ emitTextRange(tag.end, close.start);
399
+ handlers.handleEndTag(rawName);
400
+ textStart = close.end;
378
401
  i = textStart;
379
402
  continue;
380
403
  }
381
404
  }
382
- handlers.handleStartTag(tag.name!, tag.attrs!);
405
+ handlers.handleStartTag(rawName, tag.attrs!);
383
406
  } else if (tag.kind === "startend") handlers.handleStartEndTag(tag.name!, tag.attrs!);
384
407
  else if (tag.kind === "end") handlers.handleEndTag(tag.name!);
385
408
  textStart = tag.end;
386
409
  i = tag.end;
387
410
  }
388
- emitText(input.slice(textStart));
411
+ emitTextRange(textStart, input.length);
389
412
  }
390
413
 
391
414
  function popMarksAbove(marks: number[], index: number): void {
package/index.ts CHANGED
@@ -1,9 +1,9 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { webSearch } from "./web-search.ts";
4
- import { DEFAULT_FETCH_TIMEOUT_MS, fetchPageText } from "./web-fetch.ts";
3
+ import { webSearch as defaultWebSearch } from "./web-search.ts";
4
+ import { DEFAULT_FETCH_TIMEOUT_MS, fetchPageText as defaultFetchPageText } from "./web-fetch.ts";
5
5
 
6
- function positiveMaxChars(value: unknown): number | undefined {
6
+ function positiveNumber(value: unknown): number | undefined {
7
7
  return typeof value === "number" && value > 0 ? value : undefined;
8
8
  }
9
9
 
@@ -23,6 +23,19 @@ const WebSearchParams = Type.Object({
23
23
  "Truncate the fetched page to this many characters (only used with the url parameter)",
24
24
  }),
25
25
  ),
26
+ maxResults: Type.Optional(
27
+ Type.Number({
28
+ minimum: 1,
29
+ maximum: 20,
30
+ description: "Maximum number of search results to return (default: 5)",
31
+ }),
32
+ ),
33
+ timeoutMs: Type.Optional(
34
+ Type.Number({
35
+ minimum: 1000,
36
+ description: "Overall timeout in milliseconds for the search or fetch",
37
+ }),
38
+ ),
26
39
  });
27
40
 
28
41
  const WebFetchParams = Type.Object({
@@ -32,65 +45,88 @@ const WebFetchParams = Type.Object({
32
45
  description: "Truncate the returned content to this many characters (default: no limit)",
33
46
  }),
34
47
  ),
48
+ timeoutMs: Type.Optional(
49
+ Type.Number({
50
+ minimum: 1000,
51
+ description: "Overall timeout in milliseconds (default: 60000)",
52
+ }),
53
+ ),
35
54
  });
36
55
 
37
- export default function (pi: ExtensionAPI) {
38
- pi.registerTool({
39
- name: "web_search",
40
- label: "Web Search",
41
- description:
42
- "Search the web and fetch page content. Returns snippets for all results. " +
43
- "Use the url parameter to fetch full page text from a specific URL.",
44
- promptSnippet: "Search the web and fetch page content",
45
- promptGuidelines: [
46
- "Use web_search with the url parameter (e.g. {\"url\": \"<URL>\"}) to read the full text of a page found in search results.",
47
- ],
48
- parameters: WebSearchParams,
49
- async execute(_toolCallId, params, signal, onUpdate, _ctx) {
50
- if (params.url?.trim()) {
51
- const url = params.url.trim();
52
- onUpdate?.({ content: [{ type: "text", text: `Fetching ${url}...` }], details: {} });
53
- return {
54
- content: [
55
- {
56
- type: "text",
57
- text: await fetchPageText(url, {
58
- timeoutMs: DEFAULT_FETCH_TIMEOUT_MS,
59
- signal: signal ?? undefined,
60
- maxChars: positiveMaxChars(params.maxChars),
61
- }),
62
- },
63
- ],
64
- details: {},
65
- };
66
- }
67
- onUpdate?.({ content: [{ type: "text", text: "Searching the web..." }], details: {} });
68
- const text = await webSearch(params.query, { signal: signal ?? undefined });
69
- return { content: [{ type: "text", text }], details: {} };
70
- },
71
- });
56
+ export interface WebToolsDeps {
57
+ fetchPageText?: typeof defaultFetchPageText;
58
+ webSearch?: typeof defaultWebSearch;
59
+ }
72
60
 
73
- pi.registerTool({
74
- name: "web_fetch",
75
- label: "Web Fetch",
76
- description:
77
- "Fetch a URL and return its readable text. HTML pages are converted to Markdown using a " +
78
- "main-content heuristic: article/main scoping plus hidden-element and boilerplate " +
79
- "stripping. Non-HTML text is returned as-is. GitHub repo root pages are rewritten to the " +
80
- "README API, so the README is returned instead of the repo page's UI chrome. " +
81
- "Private/loopback/link-local targets are blocked (SSRF protection), and the download size " +
82
- "is capped.",
83
- promptSnippet: "Fetch a web page and return readable text content",
84
- parameters: WebFetchParams,
85
- async execute(_toolCallId, params, signal, onUpdate, _ctx) {
86
- const maxChars = positiveMaxChars(params.maxChars);
87
- onUpdate?.({ content: [{ type: "text", text: `Fetching ${params.url}...` }], details: {} });
88
- const text = await fetchPageText(params.url, {
89
- timeoutMs: DEFAULT_FETCH_TIMEOUT_MS,
90
- signal: signal ?? undefined,
91
- maxChars,
92
- });
93
- return { content: [{ type: "text", text }], details: {} };
94
- },
95
- });
61
+ export function createWebTools(deps: WebToolsDeps = {}) {
62
+ const fetchPageText = deps.fetchPageText ?? defaultFetchPageText;
63
+ const webSearch = deps.webSearch ?? defaultWebSearch;
64
+ return {
65
+ webSearchTool: defineTool({
66
+ name: "web_search",
67
+ label: "Web Search",
68
+ description:
69
+ "Search the web and fetch page content. Returns snippets for all results. " +
70
+ "Use the url parameter to fetch full page text from a specific URL.",
71
+ promptSnippet: "Search the web and fetch page content",
72
+ promptGuidelines: [
73
+ 'Use web_search with the url parameter (e.g. {"url": "<URL>"}) to read the full text of a page found in search results.',
74
+ ],
75
+ parameters: WebSearchParams,
76
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
77
+ if (params.url?.trim()) {
78
+ const url = params.url.trim();
79
+ onUpdate?.({ content: [{ type: "text", text: `Fetching ${url}...` }], details: {} });
80
+ return {
81
+ content: [
82
+ {
83
+ type: "text",
84
+ text: await fetchPageText(url, {
85
+ timeoutMs: positiveNumber(params.timeoutMs) ?? DEFAULT_FETCH_TIMEOUT_MS,
86
+ signal: signal ?? undefined,
87
+ maxChars: positiveNumber(params.maxChars),
88
+ }),
89
+ },
90
+ ],
91
+ details: {},
92
+ };
93
+ }
94
+ onUpdate?.({ content: [{ type: "text", text: "Searching the web..." }], details: {} });
95
+ const text = await webSearch(params.query, {
96
+ signal: signal ?? undefined,
97
+ timeoutMs: positiveNumber(params.timeoutMs),
98
+ maxResults: positiveNumber(params.maxResults),
99
+ });
100
+ return { content: [{ type: "text", text }], details: {} };
101
+ },
102
+ }),
103
+ webFetchTool: defineTool({
104
+ name: "web_fetch",
105
+ label: "Web Fetch",
106
+ description:
107
+ "Fetch a URL and return its readable text. HTML pages are converted to Markdown using a " +
108
+ "main-content heuristic: article/main scoping plus hidden-element and boilerplate " +
109
+ "stripping. Non-HTML text is returned as-is. GitHub repo root pages are rewritten to the " +
110
+ "README API, so the README is returned instead of the repo page's UI chrome. " +
111
+ "Private/loopback/link-local targets are blocked (SSRF protection), and the download size " +
112
+ "is capped.",
113
+ promptSnippet: "Fetch a web page and return readable text content",
114
+ parameters: WebFetchParams,
115
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
116
+ onUpdate?.({ content: [{ type: "text", text: `Fetching ${params.url}...` }], details: {} });
117
+ const text = await fetchPageText(params.url, {
118
+ timeoutMs: positiveNumber(params.timeoutMs) ?? DEFAULT_FETCH_TIMEOUT_MS,
119
+ signal: signal ?? undefined,
120
+ maxChars: positiveNumber(params.maxChars),
121
+ });
122
+ return { content: [{ type: "text", text }], details: {} };
123
+ },
124
+ }),
125
+ };
126
+ }
127
+
128
+ export default function (pi: ExtensionAPI) {
129
+ const { webSearchTool, webFetchTool } = createWebTools();
130
+ pi.registerTool(webSearchTool);
131
+ pi.registerTool(webFetchTool);
96
132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Pi extension: web_search and web_fetch tools ported from the Unsloth Studio codebase (DuckDuckGo search, SSRF-safe direct fetching, HTML-to-Markdown extraction)",
6
6
  "main": "index.ts",
package/pdf.ts CHANGED
@@ -697,6 +697,11 @@ function extractTextOps(bytes: Buffer): string {
697
697
  }
698
698
  }
699
699
  }
700
+ if (c === "'" || c === '"') {
701
+ if (inText) out.push("\n");
702
+ i++;
703
+ continue;
704
+ }
700
705
  const token = text.slice(i, i + 2);
701
706
  if (token === "BT") {
702
707
  inText = true;
package/web-access.ts CHANGED
@@ -37,6 +37,11 @@ export function normalizeDomain(value: unknown): string {
37
37
  throw new Error("Website limits must contain domains without schemes or ports");
38
38
  }
39
39
  const numericParts = stripped.split(".");
40
+ const canonicalIpv4 =
41
+ numericParts.length === 4 &&
42
+ numericParts.every((part) => /^(?:0|[1-9][0-9]{0,2})$/.test(part)) &&
43
+ numericParts.every((part) => Number(part) <= 255);
44
+ if (canonicalIpv4) return stripped;
40
45
  if (
41
46
  numericParts.length <= 4 &&
42
47
  numericParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/.test(part))
package/web-fetch.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { lookup as dnsLookup } from "node:dns/promises";
2
+ import type { LookupAllOptions } from "node:dns";
2
3
  import http from "node:http";
3
4
  import https from "node:https";
4
5
  import { createBrotliDecompress, createGunzip, createInflate, createInflateRaw } from "node:zlib";
@@ -165,6 +166,7 @@ export interface ResolvedHost {
165
166
  reason: string;
166
167
  ip: string;
167
168
  family: number;
169
+ alternates?: { ip: string; family: number }[];
168
170
  }
169
171
 
170
172
  export interface FetchSeams {
@@ -204,14 +206,25 @@ export interface RawFetchResult {
204
206
  }
205
207
 
206
208
  function htmlProbe(body: string, re: RegExp): boolean {
207
- let probe = body;
208
- while (true) {
209
- probe = probe.replace(/^[ \t\n\r\f\v]+/, "");
210
- const stripped = probe.replace(/^(?:<!--[\s\S]*?-->|<\?[\s\S]*?\?>)/, "");
211
- if (stripped === probe) break;
212
- probe = stripped;
209
+ let i = 0;
210
+ const n = body.length;
211
+ while (i < n) {
212
+ while (i < n && /[ \t\n\r\f\v]/.test(body[i])) i++;
213
+ if (body.startsWith("<!--", i)) {
214
+ const close = body.indexOf("-->", i + 4);
215
+ if (close === -1) break;
216
+ i = close + 3;
217
+ continue;
218
+ }
219
+ if (body.startsWith("<?", i)) {
220
+ const close = body.indexOf("?>", i + 2);
221
+ if (close === -1) break;
222
+ i = close + 2;
223
+ continue;
224
+ }
225
+ break;
213
226
  }
214
- return re.test(probe.slice(0, 256).toLowerCase());
227
+ return re.test(body.slice(i, i + 256).toLowerCase());
215
228
  }
216
229
 
217
230
  export function looksLikeHtml(body: string): boolean {
@@ -455,32 +468,15 @@ function decodeTis620(bytes: Buffer): string {
455
468
  }
456
469
 
457
470
 
458
- function withAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
459
- if (!signal) return promise;
460
- return new Promise<T>((resolve, reject) => {
461
- const onAbort = () => reject(new DOMException("aborted", "AbortError"));
462
- if (signal.aborted) {
463
- onAbort();
464
- return;
465
- }
466
- signal.addEventListener("abort", onAbort, { once: true });
467
- promise.then(
468
- (value) => {
469
- signal.removeEventListener("abort", onAbort);
470
- resolve(value);
471
- },
472
- (err) => {
473
- signal.removeEventListener("abort", onAbort);
474
- reject(err);
475
- },
476
- );
477
- });
478
- }
479
-
480
471
  async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promise<ResolvedHost> {
481
472
  let addresses: { address: string; family: number }[];
482
473
  try {
483
- addresses = await withAbort(dnsLookup(hostname, { all: true, verbatim: true }), signal);
474
+ const lookupOptions: LookupAllOptions & { signal?: AbortSignal } = {
475
+ all: true,
476
+ verbatim: true,
477
+ signal,
478
+ };
479
+ addresses = await dnsLookup(hostname, lookupOptions);
484
480
  } catch (err) {
485
481
  return { ok: false, reason: `Failed to resolve host: ${err}`, ip: "", family: 0 };
486
482
  }
@@ -492,8 +488,15 @@ async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promi
492
488
  return { ok: false, reason: `Blocked: refusing to fetch the non-public address ${entry.address}.`, ip: "", family: 0 };
493
489
  }
494
490
  }
491
+ addresses.sort((a, b) => (a.family === 4 ? 0 : 1) - (b.family === 4 ? 0 : 1));
495
492
  const first = addresses[0];
496
- return { ok: true, reason: "", ip: first.address, family: first.family };
493
+ return {
494
+ ok: true,
495
+ reason: "",
496
+ ip: first.address,
497
+ family: first.family,
498
+ alternates: addresses.slice(1).map((entry) => ({ ip: entry.address, family: entry.family })),
499
+ };
497
500
  }
498
501
 
499
502
 
@@ -603,7 +606,8 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
603
606
  if (total >= limit) {
604
607
  truncated =
605
608
  codec !== null ||
606
- !(Number.isFinite(declaredLength) && declaredLength === total);
609
+ take.length < chunk.length ||
610
+ (Number.isFinite(declaredLength) && declaredLength > total);
607
611
  decoder?.destroy();
608
612
  res.destroy();
609
613
  finish(null, Buffer.concat(chunks));
@@ -722,8 +726,9 @@ export async function fetchUrlRaw(
722
726
  let currentUrl = url;
723
727
  let pinnedIp = resolved.ip;
724
728
  let pinnedFamily = resolved.family;
729
+ let alternates: { ip: string; family: number }[] = resolved.alternates ?? [];
730
+ let alternateIndex = 0;
725
731
  const userAgent = randomUserAgent();
726
-
727
732
  for (let hop = 0; hop < MAX_REQUESTS; hop++) {
728
733
  const budgetResult = budgetExceededResult(deadline, signal, now);
729
734
  if (budgetResult !== null) return budgetResult;
@@ -752,6 +757,16 @@ export async function fetchUrlRaw(
752
757
  signal,
753
758
  });
754
759
  } catch (err) {
760
+ if (
761
+ !(err instanceof FetchCancelledError) &&
762
+ !(err instanceof FetchTimeoutError) &&
763
+ alternateIndex < alternates.length
764
+ ) {
765
+ const next = alternates[alternateIndex++];
766
+ pinnedIp = next.ip;
767
+ pinnedFamily = next.family;
768
+ continue;
769
+ }
755
770
  return { error: fetchErrorMessage(err), body: "", contentType: "" };
756
771
  }
757
772
 
@@ -782,6 +797,8 @@ export async function fetchUrlRaw(
782
797
  if (!redirected.ok) return { error: redirected.reason, body: "", contentType: "" };
783
798
  pinnedIp = redirected.ip;
784
799
  pinnedFamily = redirected.family;
800
+ alternates = redirected.alternates ?? [];
801
+ alternateIndex = 0;
785
802
  continue;
786
803
  }
787
804