pi-unsloth-webtools 0.2.3 → 0.2.4

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.
Files changed (3) hide show
  1. package/engines.ts +35 -5
  2. package/package.json +1 -1
  3. package/web-fetch.ts +44 -22
package/engines.ts CHANGED
@@ -472,6 +472,28 @@ async function httpPost(
472
472
  return httpFetch(url, { ...options, method: "POST", body: new URLSearchParams(data).toString() });
473
473
  }
474
474
 
475
+ const MAX_ENGINE_RESPONSE_BYTES = 5 * 1024 * 1024;
476
+
477
+ async function readBodyCapped(response: Response): Promise<string | null> {
478
+ const declared = Number(response.headers.get("content-length") ?? "0");
479
+ if (declared > MAX_ENGINE_RESPONSE_BYTES) return null;
480
+ if (!response.body) return "";
481
+ const reader = response.body.getReader();
482
+ const chunks: Uint8Array[] = [];
483
+ let total = 0;
484
+ while (true) {
485
+ const { done, value } = await reader.read();
486
+ if (done) break;
487
+ total += value.length;
488
+ if (total > MAX_ENGINE_RESPONSE_BYTES) {
489
+ await reader.cancel();
490
+ return null;
491
+ }
492
+ chunks.push(value);
493
+ }
494
+ return new TextDecoder("utf-8").decode(Buffer.concat(chunks));
495
+ }
496
+
475
497
  async function httpFetch(
476
498
  url: string,
477
499
  options: {
@@ -505,13 +527,18 @@ async function httpFetch(
505
527
  signal: AbortSignal.any(signals),
506
528
  });
507
529
  } catch (err) {
508
- if (err instanceof DOMException && err.name === "TimeoutError") {
509
- throw new Error("timed out");
510
- }
530
+ if (err instanceof DOMException && err.name === "TimeoutError") throw new SearchTimeoutError();
531
+ if (err instanceof DOMException && err.name === "AbortError") throw new SearchCancelled();
511
532
  throw err;
512
533
  }
513
534
  if (response.status !== 200) return null;
514
- return response.text();
535
+ try {
536
+ return await readBodyCapped(response);
537
+ } catch (err) {
538
+ if (err instanceof DOMException && err.name === "TimeoutError") throw new SearchTimeoutError();
539
+ if (err instanceof DOMException && err.name === "AbortError") throw new SearchCancelled();
540
+ throw err;
541
+ }
515
542
  }
516
543
 
517
544
  const DUCKDUCKGO: Engine = {
@@ -792,6 +819,7 @@ export async function autoTextSearch(
792
819
  const aggregator = new ResultsAggregator();
793
820
  const ctx: EngineContext = { region: "us-en", safesearch: "moderate" };
794
821
  let timedOut = false;
822
+ let cancelled = false;
795
823
  const uniqueProviders = new Set(engines.map((e) => e.provider)).size;
796
824
  const maxWorkers = Math.min(uniqueProviders, Math.ceil(maxResults / 10) + 1);
797
825
  let i = 0;
@@ -804,7 +832,8 @@ export async function autoTextSearch(
804
832
  seenProviders.add(engine.provider);
805
833
  }
806
834
  } catch (e) {
807
- if (e instanceof Error && e.message.includes("timed out")) timedOut = true;
835
+ if (e instanceof SearchCancelled) cancelled = true;
836
+ if (e instanceof SearchTimeoutError) timedOut = true;
808
837
  }
809
838
  };
810
839
  while (i < engines.length) {
@@ -818,6 +847,7 @@ export async function autoTextSearch(
818
847
  }
819
848
  }
820
849
  await Promise.allSettled(pending);
850
+ if (cancelled) throw new SearchCancelled();
821
851
  const results = rankResults(aggregator.extractDicts(), query);
822
852
  if (results.length) return results.slice(0, maxResults);
823
853
  if (timedOut) throw new SearchTimeoutError();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
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/web-fetch.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  type WebsitePolicy,
11
11
  } from "./web-access.ts";
12
12
  import { htmlToMarkdown } from "./html-to-md.ts";
13
+ import { INVALID_CHARREFS } from "./entities.ts";
13
14
  import { extractPdfText, PdfParseError } from "./pdf.ts";
14
15
  import { randomUserAgent } from "./user-agents.ts";
15
16
 
@@ -97,14 +98,17 @@ const ASCII_TEXT_BYTES = new Set<number>([
97
98
  0x1b,
98
99
  ]);
99
100
 
100
- const CP1252_HIGH: Record<number, number> = {
101
- 0x80: 0x20ac, 0x82: 0x201a, 0x83: 0x0192, 0x84: 0x201e, 0x85: 0x2026,
102
- 0x86: 0x2020, 0x87: 0x2021, 0x88: 0x02c6, 0x89: 0x2030, 0x8a: 0x0160,
103
- 0x8b: 0x2039, 0x8c: 0x0152, 0x8e: 0x017d, 0x91: 0x2018, 0x92: 0x2019,
104
- 0x93: 0x201c, 0x94: 0x201d, 0x95: 0x2022, 0x96: 0x2013, 0x97: 0x2014,
105
- 0x98: 0x02dc, 0x99: 0x2122, 0x9a: 0x0161, 0x9b: 0x203a, 0x9c: 0x0153,
106
- 0x9e: 0x017e, 0x9f: 0x0178,
107
- };
101
+ export class FetchCancelledError extends Error {
102
+ constructor() {
103
+ super("cancelled");
104
+ }
105
+ }
106
+
107
+ export class FetchTimeoutError extends Error {
108
+ constructor() {
109
+ super("timed out");
110
+ }
111
+ }
108
112
 
109
113
  export interface FetchPageOptions {
110
114
  timeoutMs?: number;
@@ -145,6 +149,8 @@ export interface HopOptions {
145
149
  maxBytes: number;
146
150
  maxPdfBytes: number;
147
151
  inactivityMs: number;
152
+ deadlineMs?: number;
153
+ nowMs?: () => number;
148
154
  signal?: AbortSignal;
149
155
  }
150
156
 
@@ -339,8 +345,8 @@ function decodeSingleByte(bytes: Buffer, cp1252: boolean): string {
339
345
  for (const byte of bytes) {
340
346
  if (byte < 0x80) {
341
347
  out += String.fromCharCode(byte);
342
- } else if (cp1252 && byte in CP1252_HIGH) {
343
- out += String.fromCodePoint(CP1252_HIGH[byte]);
348
+ } else if (cp1252 && byte in INVALID_CHARREFS) {
349
+ out += INVALID_CHARREFS[byte];
344
350
  } else {
345
351
  out += String.fromCharCode(byte);
346
352
  }
@@ -431,7 +437,7 @@ function fetchBudgetExceeded(
431
437
  }
432
438
 
433
439
 
434
- function requestHop(opts: HopOptions): Promise<HopResponse> {
440
+ export function requestHop(opts: HopOptions): Promise<HopResponse> {
435
441
  return new Promise((resolve, reject) => {
436
442
  const url = opts.url;
437
443
  const transport = url.protocol === "https:" ? https : http;
@@ -446,6 +452,13 @@ function requestHop(opts: HopOptions): Promise<HopResponse> {
446
452
  lookup: (_host, _opts, callback) =>
447
453
  callback(null, [{ address: opts.pinnedIp, family: opts.family }]),
448
454
  };
455
+ let settled = false;
456
+ const settle = (action: () => void) => {
457
+ if (settled) return;
458
+ settled = true;
459
+ opts.signal?.removeEventListener("abort", onAbort);
460
+ action();
461
+ };
449
462
  const request = transport.request(options, (res: IncomingMessage) => {
450
463
  const chunks: Buffer[] = [];
451
464
  let total = 0;
@@ -454,17 +467,27 @@ function requestHop(opts: HopOptions): Promise<HopResponse> {
454
467
  let extendedForPdf = false;
455
468
  const finish = (err: string | null, body: Buffer) => {
456
469
  settle(() => {
457
- if (err) reject(new Error(err));
458
- else
470
+ if (err) {
471
+ if (err === "cancelled") reject(new FetchCancelledError());
472
+ else if (err === "timed out") reject(new FetchTimeoutError());
473
+ else reject(new Error(err));
474
+ } else {
459
475
  resolve({
460
476
  status: res.statusCode ?? 0,
461
477
  headers: res.headers as Record<string, string | string[] | undefined>,
462
478
  body,
463
479
  });
480
+ }
464
481
  });
465
482
  };
466
483
  res.on("data", (chunk: Buffer) => {
467
484
  if (settled) return;
485
+ const now = opts.nowMs ?? Date.now;
486
+ if (opts.deadlineMs !== undefined && now() >= opts.deadlineMs) {
487
+ res.destroy();
488
+ finish("timed out", Buffer.concat(chunks));
489
+ return;
490
+ }
468
491
  if (!declaredPdf && !extendedForPdf && total + chunk.length > opts.maxBytes) {
469
492
  if (hasPdfMagic(Buffer.concat(chunks))) {
470
493
  limit = opts.maxPdfBytes;
@@ -488,16 +511,9 @@ function requestHop(opts: HopOptions): Promise<HopResponse> {
488
511
  res.on("end", () => finish(null, Buffer.concat(chunks)));
489
512
  res.on("error", (err) => finish(err.message, Buffer.concat(chunks)));
490
513
  });
491
- const onAbort = () => request.destroy(new Error("cancelled"));
514
+ const onAbort = () => request.destroy(new FetchCancelledError());
492
515
  opts.signal?.addEventListener("abort", onAbort, { once: true });
493
- let settled = false;
494
- const settle = (action: () => void) => {
495
- if (settled) return;
496
- settled = true;
497
- opts.signal?.removeEventListener("abort", onAbort);
498
- action();
499
- };
500
- request.on("timeout", () => request.destroy(new Error("timed out")));
516
+ request.on("timeout", () => request.destroy(new FetchTimeoutError()));
501
517
  request.on("error", (err) => settle(() => reject(err)));
502
518
  request.end();
503
519
  });
@@ -557,9 +573,15 @@ export async function fetchUrlRaw(
557
573
  maxBytes,
558
574
  maxPdfBytes,
559
575
  inactivityMs: inactivity,
576
+ deadlineMs: deadline,
577
+ nowMs: now,
560
578
  signal,
561
579
  });
562
580
  } catch (err) {
581
+ if (err instanceof FetchCancelledError)
582
+ return { error: "Failed to fetch URL: cancelled.", body: "", contentType: "" };
583
+ if (err instanceof FetchTimeoutError)
584
+ return { error: "Failed to fetch URL: timed out.", body: "", contentType: "" };
563
585
  const message = err instanceof Error ? err.message : String(err);
564
586
  if (message === "cancelled")
565
587
  return { error: "Failed to fetch URL: cancelled.", body: "", contentType: "" };