pi-unsloth-webtools 0.7.6 → 0.9.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/index.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  import { defineTool, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import { truncateToWidth } from "@earendil-works/pi-tui";
4
- import { collapseWhitespace } from "./html-to-md.ts";
4
+ import { collapseWhitespace, visibleChars } from "./html-to-md.ts";
5
5
  import { SEARCH_TIMEOUT_MS, webSearch as defaultWebSearch } from "./web-search.ts";
6
- import { DEFAULT_FETCH_TIMEOUT_MS, fetchPageText as defaultFetchPageText } from "./web-fetch.ts";
7
- import { renderPageText as defaultRenderPageText } from "./web-render.ts";
8
- import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs, loadJinaApiKey } from "./settings.ts";
9
- import { readConfig, readConfigWithStatus, toggleWebRender } from "./config.ts";
10
- import { WebToolsConfigOverlay } from "./config-ui.ts";
6
+ import {
7
+ DEFAULT_FETCH_TIMEOUT_MS,
8
+ fetchPageOutcome as defaultFetchPageOutcome,
9
+ fetchPageText as defaultFetchPageText,
10
+ type FetchPageOutcome,
11
+ } from "./web-fetch.ts";
12
+ import { renderPageWithLightpanda as defaultRenderLocalPageText } from "./lightpanda.ts";
13
+ import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs, loadLightpandaSettings } from "./settings.ts";
11
14
 
12
15
  function toolCallLine(theme: Theme, name: string, detail: string) {
13
16
  const line = theme.fg("toolTitle", theme.bold(name)) + (detail ? ` ${theme.fg("accent", detail)}` : "");
@@ -23,6 +26,7 @@ function positiveNumber(value: unknown): number | undefined {
23
26
  const n = Math.floor(value);
24
27
  return n > 0 ? n : undefined;
25
28
  }
29
+
26
30
  async function fetchDefaults(cwd: string | undefined, params: { timeoutMs?: unknown; maxChars?: unknown }) {
27
31
  const timeoutParam = positiveNumber(params.timeoutMs);
28
32
  const maxCharsParam = positiveNumber(params.maxChars);
@@ -32,6 +36,7 @@ async function fetchDefaults(cwd: string | undefined, params: { timeoutMs?: unkn
32
36
  maxChars: maxCharsParam ?? defaults.maxChars,
33
37
  allowPrivateAddresses: defaults.allowPrivateAddresses,
34
38
  allowLocalFiles: defaults.allowLocalFiles,
39
+ transport: defaults.transport,
35
40
  };
36
41
  }
37
42
 
@@ -39,24 +44,53 @@ function fetchWasForbidden(text: string): boolean {
39
44
  return /^Failed to fetch URL: HTTP 403\b/m.test(text);
40
45
  }
41
46
 
42
- const FORBIDDEN_FALLBACK_NOTE = "Direct fetch failed with HTTP 403; rendered via the Jina Reader instead.";
47
+ function renderFailed(rendered: string): boolean {
48
+ return (
49
+ rendered.startsWith("Failed to render URL:") ||
50
+ rendered.startsWith("Blocked:") ||
51
+ rendered.startsWith("(page returned no readable text)")
52
+ );
53
+ }
54
+
55
+ function renderImprovesPage(rendered: string, fetched: string): boolean {
56
+ return visibleChars(rendered) > visibleChars(fetched) * 1.5;
57
+ }
58
+
59
+ const LOCAL_FORBIDDEN_NOTE = "Direct fetch failed with HTTP 403; rendered locally with Lightpanda instead.";
60
+ const LOCAL_THIN_NOTE = "Direct fetch returned little content; rendered locally with Lightpanda instead.";
61
+ const INCOMPLETE_NOTE = "\n\n*(JavaScript-rendered page; content may be incomplete)*";
62
+ const RENDER_HEADER_LINE_RE = /^(?:URL: |Rendered via |Title: |Author: |Date: |Site: )/;
63
+
64
+ function renderedProseChars(text: string): number {
65
+ const prose = text
66
+ .split("\n")
67
+ .filter((line) => !RENDER_HEADER_LINE_RE.test(line))
68
+ .join("\n");
69
+ return visibleChars(prose);
70
+ }
71
+
72
+ const INTERSTITIAL_MAX_PROSE_CHARS = 4000;
73
+ const INTERSTITIAL_MARKERS = [
74
+ "just a moment",
75
+ "attention required",
76
+ "you have been blocked",
77
+ "enable javascript and cookies to continue",
78
+ "checking if the site connection is secure",
79
+ "access denied",
80
+ "please enable cookies",
81
+ "cf-error-details",
82
+ ];
83
+
84
+ function looksLikeInterstitial(text: string): boolean {
85
+ if (renderedProseChars(text) > INTERSTITIAL_MAX_PROSE_CHARS) return false;
86
+ const lowered = text.toLowerCase();
87
+ return INTERSTITIAL_MARKERS.some((marker) => lowered.includes(marker));
88
+ }
43
89
 
44
90
  const WebSearchParams = Type.Object({
45
91
  query: Type.Optional(
46
92
  Type.String({ description: "The search query" }),
47
93
  ),
48
- url: Type.Optional(
49
- Type.String({
50
- description:
51
- "A URL to fetch full page content from (instead of searching). Use this to read a page found in search results.",
52
- }),
53
- ),
54
- maxChars: Type.Optional(
55
- Type.Number({
56
- description:
57
- "Truncate the fetched page to this many characters (only used with the url parameter)",
58
- }),
59
- ),
60
94
  maxResults: Type.Optional(
61
95
  Type.Number({
62
96
  minimum: 1,
@@ -87,55 +121,43 @@ const WebFetchParams = Type.Object({
87
121
  ),
88
122
  });
89
123
 
90
- const WebRenderParams = Type.Object({
91
- url: Type.String({ description: "Public URL of the page to render" }),
92
- maxChars: Type.Optional(
93
- Type.Number({
94
- description: "Truncate the returned content to this many characters (default: no limit)",
95
- }),
96
- ),
97
- timeoutMs: Type.Optional(
98
- Type.Number({
99
- minimum: 1000,
100
- description: "Overall timeout in milliseconds (default: 60000)",
101
- }),
102
- ),
103
- });
104
-
105
124
  export interface WebToolsDeps {
106
125
  fetchPageText?: typeof defaultFetchPageText;
126
+ fetchPageOutcome?: typeof defaultFetchPageOutcome;
107
127
  webSearch?: typeof defaultWebSearch;
108
- renderPageText?: typeof defaultRenderPageText;
109
- webRenderEnabled?: () => Promise<boolean>;
128
+ renderLocalPageText?: typeof defaultRenderLocalPageText;
110
129
  }
111
130
 
112
131
  export function createWebTools(deps: WebToolsDeps = {}) {
113
- const fetchPageText = deps.fetchPageText ?? defaultFetchPageText;
114
132
  const webSearch = deps.webSearch ?? defaultWebSearch;
115
- const renderPageText = deps.renderPageText ?? defaultRenderPageText;
116
- const webRenderEnabled =
117
- deps.webRenderEnabled ?? (async () => (await readConfig()).webRenderEnabled !== false);
133
+ const renderLocalPageText = deps.renderLocalPageText ?? defaultRenderLocalPageText;
134
+ const legacyFetchPageText = deps.fetchPageText;
135
+ const fetchOutcome: typeof defaultFetchPageOutcome =
136
+ deps.fetchPageOutcome ??
137
+ (legacyFetchPageText
138
+ ? async (url, options) => ({ text: await legacyFetchPageText(url, options), hint: null })
139
+ : defaultFetchPageOutcome);
118
140
 
119
- const renderOnForbidden = async (
141
+ const renderFallback = async (
120
142
  url: string,
121
- failedText: string,
143
+ outcome: FetchPageOutcome,
122
144
  options: { timeoutMs: number; maxChars?: number; signal?: AbortSignal; cwd?: string },
123
145
  ): Promise<string | null> => {
124
- if (!fetchWasForbidden(failedText)) return null;
125
- try {
126
- if (!(await webRenderEnabled())) return null;
127
- const apiKey = await loadJinaApiKey(options.cwd);
128
- const rendered = await renderPageText(url, {
129
- timeoutMs: options.timeoutMs,
130
- maxChars: options.maxChars,
131
- signal: options.signal,
132
- apiKey,
133
- });
134
- if (rendered.startsWith("Failed to render URL:") || rendered.startsWith("Blocked:")) return null;
135
- return FORBIDDEN_FALLBACK_NOTE + "\n\n" + rendered;
136
- } catch {
137
- return null;
138
- }
146
+ const forbidden = fetchWasForbidden(outcome.text);
147
+ const thin = outcome.hint !== null && !forbidden;
148
+ if (!forbidden && !thin) return null;
149
+ const incomplete = outcome.text + INCOMPLETE_NOTE;
150
+ const settings = await loadLightpandaSettings(options.cwd).catch(() => null);
151
+ const rendered = await renderLocalPageText(url, {
152
+ timeoutMs: options.timeoutMs,
153
+ maxChars: options.maxChars,
154
+ signal: options.signal,
155
+ settings,
156
+ }).catch(() => null);
157
+ if (rendered === null) return thin ? incomplete : null;
158
+ if (renderFailed(rendered) || looksLikeInterstitial(rendered)) return thin ? incomplete : null;
159
+ if (thin && !renderImprovesPage(rendered, outcome.text)) return incomplete;
160
+ return (forbidden ? LOCAL_FORBIDDEN_NOTE : LOCAL_THIN_NOTE) + "\n\n" + rendered;
139
161
  };
140
162
 
141
163
  return {
@@ -143,38 +165,17 @@ export function createWebTools(deps: WebToolsDeps = {}) {
143
165
  name: "web_search",
144
166
  label: "Web Search",
145
167
  description:
146
- "Search the web and return snippets for the top results. Pass url instead of query to read a page found in the same search; use web_fetch for known URLs.",
168
+ "Search the web and return snippets for the top results. Use web_fetch to read a page found in the results.",
147
169
  promptSnippet: "Search the web and return snippets",
148
170
  promptGuidelines: [
149
- "Web tool order: web_search to discover, web_fetch for a known URL, web_render only when web_fetch cannot read the page.",
171
+ "Web tool order: web_search to discover, then web_fetch to read a page.",
150
172
  ],
151
173
  parameters: WebSearchParams,
152
174
  renderCall(args, theme) {
153
- const url = collapsedArg(args.url);
154
175
  const query = collapsedArg(args.query);
155
- return toolCallLine(theme, "web_search", url || (query ? `"${query}"` : ""));
176
+ return toolCallLine(theme, "web_search", query ? `"${query}"` : "");
156
177
  },
157
178
  async execute(_toolCallId, params, signal, onUpdate, _ctx) {
158
- if (params.url?.trim()) {
159
- const url = params.url.trim();
160
- onUpdate?.({ content: [{ type: "text", text: `Fetching ${url}...` }], details: {} });
161
- const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
162
- const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles } = await fetchDefaults(cwd, params);
163
- const text = await fetchPageText(url, {
164
- timeoutMs,
165
- signal: signal ?? undefined,
166
- maxChars,
167
- allowPrivateAddresses,
168
- allowLocalFiles,
169
- });
170
- const rendered = await renderOnForbidden(url, text, {
171
- timeoutMs,
172
- maxChars,
173
- signal: signal ?? undefined,
174
- cwd,
175
- });
176
- return { content: [{ type: "text", text: rendered ?? text }], details: {} };
177
- }
178
179
  onUpdate?.({ content: [{ type: "text", text: "Searching the web..." }], details: {} });
179
180
  const timeoutParam = positiveNumber(params.timeoutMs);
180
181
  const searchCwd = (_ctx as ExtensionContext | undefined)?.cwd;
@@ -194,13 +195,16 @@ export function createWebTools(deps: WebToolsDeps = {}) {
194
195
  description:
195
196
  "Fetch a URL and return its readable text. HTML pages are converted to Markdown using a " +
196
197
  "main-content heuristic: article/main scoping plus hidden-element and boilerplate " +
197
- "stripping. Non-HTML text is returned as-is. GitHub repo root pages are rewritten to the " +
198
+ "stripping. HTTP 403 responses are retried over a browser-fingerprint transport, and pages " +
199
+ "that look JavaScript-rendered (or still refused) are rendered locally with Lightpanda when " +
200
+ "the binary is installed; no third-party rendering service is involved. " +
201
+ "Non-HTML text is returned as-is. GitHub repo root pages are rewritten to the " +
198
202
  "README API, so the README is returned instead of the repo page's UI chrome. " +
199
203
  "Private/loopback/link-local targets and local files (file:// URLs, absolute, ~/ or ./ paths, including " +
200
204
  "PDFs) are supported by default; opt out with webFetch.allowPrivateAddresses: false or " +
201
205
  "webFetch.allowLocalFiles: false in settings. The download size is capped.",
202
206
  promptGuidelines: [
203
- "web_fetch retries an HTTP 403 through web_render automatically; never call web_render again for the same URL after a 403.",
207
+ "web_fetch renders JavaScript-rendered pages locally with Lightpanda when its binary is installed; it never calls a third-party rendering service.",
204
208
  ],
205
209
  promptSnippet: "Fetch a web page and return readable text content",
206
210
  parameters: WebFetchParams,
@@ -210,105 +214,31 @@ export function createWebTools(deps: WebToolsDeps = {}) {
210
214
  async execute(_toolCallId, params, signal, onUpdate, _ctx) {
211
215
  onUpdate?.({ content: [{ type: "text", text: `Fetching ${params.url}...` }], details: {} });
212
216
  const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
213
- const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles } = await fetchDefaults(cwd, params);
214
- const text = await fetchPageText(params.url, {
217
+ const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles, transport } = await fetchDefaults(cwd, params);
218
+ const deadlineMs = Date.now() + timeoutMs;
219
+ const outcome = await fetchOutcome(params.url, {
215
220
  timeoutMs,
221
+ deadlineMs,
216
222
  signal: signal ?? undefined,
217
223
  maxChars,
218
224
  allowPrivateAddresses,
219
225
  allowLocalFiles,
226
+ transport,
220
227
  });
221
- const rendered = await renderOnForbidden(params.url, text, {
222
- timeoutMs,
228
+ const rendered = await renderFallback(params.url, outcome, {
229
+ timeoutMs: Math.max(1, deadlineMs - Date.now()),
223
230
  maxChars,
224
231
  signal: signal ?? undefined,
225
232
  cwd,
226
233
  });
227
- return { content: [{ type: "text", text: rendered ?? text }], details: {} };
228
- },
229
- }),
230
- webRenderTool: defineTool({
231
- name: "web_render",
232
- label: "Web Render",
233
- description:
234
- "Render a public web page to Markdown through the third-party Jina Reader (r.jina.ai). " +
235
- "Use it when web_fetch cannot read the page: JavaScript-rendered pages, or pages where web_fetch returned \"(page returned no readable text)\". " +
236
- "The target URL is sent to Jina; local files and non-public addresses are refused. " +
237
- "An optional JINA_API_KEY or unslothWebTools.jinaApiKey raises the Reader's rate limits.",
238
- promptSnippet: "Render a JavaScript-rendered page to Markdown via the Jina Reader",
239
- parameters: WebRenderParams,
240
- renderCall(args, theme) {
241
- return toolCallLine(theme, "web_render", collapsedArg(args.url));
242
- },
243
- async execute(_toolCallId, params, signal, onUpdate, _ctx) {
244
- onUpdate?.({ content: [{ type: "text", text: `Rendering ${params.url}...` }], details: {} });
245
- const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
246
- const { timeoutMs, maxChars } = await fetchDefaults(cwd, params);
247
- const apiKey = await loadJinaApiKey(cwd);
248
- const text = await renderPageText(params.url, {
249
- timeoutMs,
250
- maxChars,
251
- signal: signal ?? undefined,
252
- apiKey,
253
- });
254
- return { content: [{ type: "text", text }], details: {} };
234
+ return { content: [{ type: "text", text: rendered ?? outcome.text }], details: {} };
255
235
  },
256
236
  }),
257
237
  };
258
238
  }
259
239
 
260
240
  export default function (pi: ExtensionAPI) {
261
- const { webSearchTool, webFetchTool, webRenderTool } = createWebTools();
241
+ const { webSearchTool, webFetchTool } = createWebTools();
262
242
  pi.registerTool(webSearchTool);
263
243
  pi.registerTool(webFetchTool);
264
- pi.registerTool(webRenderTool);
265
-
266
- pi.on("session_start", async (_event, ctx) => {
267
- try {
268
- const { config, corrupted } = await readConfigWithStatus();
269
- if (corrupted && ctx.hasUI) {
270
- ctx.ui.notify("Web tools config was corrupt and was reset to defaults", "warning");
271
- }
272
- if (!config.webRenderEnabled) {
273
- pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "web_render"));
274
- }
275
- } catch (error) {
276
- console.error("Failed to load web tools config:", error);
277
- }
278
- });
279
-
280
- pi.registerCommand("webtools-config", {
281
- description: "Open the web tools settings window (web_render on/off)",
282
- handler: async (_args, ctx) => {
283
- if (!ctx.hasUI) {
284
- ctx.ui.notify("/webtools-config requires interactive mode", "error");
285
- return;
286
- }
287
- await ctx.ui.custom<void>(
288
- async (tui, theme, _keybindings, done) => {
289
- const overlay = new WebToolsConfigOverlay({
290
- tui,
291
- theme,
292
- done,
293
- onToggle: async (key) => {
294
- if (key !== "webRenderEnabled") return;
295
- const enabled = await toggleWebRender();
296
- const active = pi.getActiveTools();
297
- pi.setActiveTools(
298
- enabled
299
- ? [...new Set([...active, "web_render"])]
300
- : active.filter((name) => name !== "web_render"),
301
- );
302
- },
303
- });
304
- await overlay.load();
305
- return overlay;
306
- },
307
- {
308
- overlay: true,
309
- overlayOptions: { anchor: "center", width: "90%", minWidth: 60, maxHeight: "90%" },
310
- },
311
- );
312
- },
313
- });
314
244
  }
package/lightpanda.ts ADDED
@@ -0,0 +1,276 @@
1
+ import { spawn as spawnProcess } from "node:child_process";
2
+ import { visibleChars } from "./html-to-md.ts";
3
+ import {
4
+ checkUrlAccess,
5
+ MAX_SIGNAL_TIMEOUT_MS,
6
+ normalizeUrlScheme,
7
+ type WebsitePolicy,
8
+ } from "./web-access.ts";
9
+ import {
10
+ pagePrefixedMarkdown,
11
+ parseLocalPath,
12
+ resolveAndValidateHost,
13
+ TRUNCATED_BODY_NOTICE,
14
+ truncatePageText,
15
+ type ResolvedHost,
16
+ } from "./web-fetch.ts";
17
+
18
+ const DEFAULT_BINARY = "lightpanda";
19
+ const DEFAULT_WAIT_UNTIL = "networkidle";
20
+ const DEFAULT_TIMEOUT_MS = 60_000;
21
+ const MAX_DUMP_BYTES = 4 * 1024 * 1024;
22
+ const PROBE_TIMEOUT_MS = 5_000;
23
+ const METADATA_LINE_RE = /^(?:Title|Author|Date|Site|URL|Rendered via): /;
24
+ const PROVENANCE = "Rendered via Lightpanda (local browser; nothing sent to a third party).";
25
+ const CANCELLED_MESSAGE = "Failed to render URL: cancelled.";
26
+ const TIMED_OUT_MESSAGE = "Failed to render URL: timed out.";
27
+ const LOCAL_FILE_MESSAGE = "Blocked: the local renderer cannot fetch local files.";
28
+ const NO_TEXT_MESSAGE = "(page returned no readable text)";
29
+
30
+ export type LightpandaDump = "html" | "markdown";
31
+ export type LightpandaWaitUntil = "networkidle" | "load" | "domcontentloaded" | "done";
32
+
33
+ export interface LightpandaSettings {
34
+ enabled: boolean;
35
+ binaryPath: string | null;
36
+ command: string[] | null;
37
+ }
38
+
39
+ interface LightpandaProcess {
40
+ stdout: AsyncIterable<Buffer> | null;
41
+ stderr: AsyncIterable<Buffer> | null;
42
+ on(event: "error", listener: (error: Error) => void): unknown;
43
+ on(event: "close", listener: (code: number | null) => void): unknown;
44
+ kill(signal?: string): unknown;
45
+ }
46
+
47
+ export type LightpandaSpawn = (
48
+ binary: string,
49
+ args: string[],
50
+ options: { stdio: ["ignore", "pipe", "pipe"] },
51
+ ) => LightpandaProcess;
52
+
53
+ export interface LightpandaRenderOptions {
54
+ timeoutMs?: number;
55
+ maxChars?: number;
56
+ signal?: AbortSignal;
57
+ websitePolicy?: WebsitePolicy | null;
58
+ settings?: LightpandaSettings | null;
59
+ binaryPath?: string | null;
60
+ command?: string[] | null;
61
+ dump?: LightpandaDump;
62
+ waitUntil?: LightpandaWaitUntil;
63
+ spawn?: LightpandaSpawn;
64
+ resolve?: (hostname: string, signal?: AbortSignal, allowPrivateAddresses?: boolean) => Promise<ResolvedHost>;
65
+ }
66
+
67
+ interface RunResult {
68
+ code: number | null;
69
+ stdout: string;
70
+ stderr: string;
71
+ timedOut: boolean;
72
+ cancelled: boolean;
73
+ truncated: boolean;
74
+ missing: boolean;
75
+ }
76
+
77
+ const availability = new Map<string, Promise<boolean>>();
78
+
79
+ function binaryAvailable(launch: string[], spawn: LightpandaSpawn): Promise<boolean> {
80
+ const key = launch.join(" ");
81
+ const cached = availability.get(key);
82
+ if (cached) return cached;
83
+ const probe = new Promise<boolean>((resolve) => {
84
+ let settled = false;
85
+ const finish = (value: boolean) => {
86
+ if (settled) return;
87
+ settled = true;
88
+ clearTimeout(timer);
89
+ resolve(value);
90
+ };
91
+ const timer = setTimeout(() => {
92
+ try {
93
+ child.kill("SIGKILL");
94
+ } catch {}
95
+ finish(false);
96
+ }, PROBE_TIMEOUT_MS);
97
+ let child: LightpandaProcess;
98
+ try {
99
+ child = spawn(launch[0], [...launch.slice(1), "version"], { stdio: ["ignore", "pipe", "pipe"] });
100
+ } catch {
101
+ finish(false);
102
+ return;
103
+ }
104
+ child.on("error", () => finish(false));
105
+ child.on("close", (code) => finish(code === 0));
106
+ });
107
+ availability.set(key, probe);
108
+ return probe;
109
+ }
110
+
111
+ function runLightpanda(
112
+ launch: string[],
113
+ args: string[],
114
+ options: { timeoutMs: number; signal?: AbortSignal; spawn: LightpandaSpawn },
115
+ ): Promise<RunResult> {
116
+ return new Promise<RunResult>((resolve) => {
117
+ const state = {
118
+ code: null as number | null,
119
+ stdout: [] as Buffer[],
120
+ stderr: [] as string[],
121
+ timedOut: false,
122
+ cancelled: false,
123
+ truncated: false,
124
+ missing: false,
125
+ bytes: 0,
126
+ };
127
+ let settled = false;
128
+ const finish = (error: Error | null) => {
129
+ if (settled) return;
130
+ settled = true;
131
+ clearTimeout(timer);
132
+ options.signal?.removeEventListener("abort", onAbort);
133
+ if (error && (error as NodeJS.ErrnoException).code === "ENOENT") state.missing = true;
134
+ const stdout = Buffer.concat(state.stdout).toString("utf-8");
135
+ resolve({ ...state, stdout, stderr: state.stderr.join("") });
136
+ };
137
+ const kill = () => {
138
+ try {
139
+ child.kill("SIGKILL");
140
+ } catch {}
141
+ };
142
+ const onAbort = () => {
143
+ state.cancelled = true;
144
+ kill();
145
+ };
146
+ const timer = setTimeout(() => {
147
+ state.timedOut = true;
148
+ kill();
149
+ }, options.timeoutMs);
150
+ let child: LightpandaProcess;
151
+ try {
152
+ child = options.spawn(launch[0], [...launch.slice(1), ...args], { stdio: ["ignore", "pipe", "pipe"] });
153
+ } catch (error) {
154
+ finish(error instanceof Error ? error : new Error(String(error)));
155
+ return;
156
+ }
157
+ const collect = async (stream: AsyncIterable<Buffer> | null, push: (chunk: Buffer) => void): Promise<void> => {
158
+ if (!stream) return;
159
+ try {
160
+ for await (const chunk of stream) push(chunk);
161
+ } catch {}
162
+ };
163
+ const stdoutDone = collect(child.stdout, (chunk) => {
164
+ if (state.bytes >= MAX_DUMP_BYTES) {
165
+ state.truncated = true;
166
+ return;
167
+ }
168
+ state.bytes += chunk.length;
169
+ if (state.bytes > MAX_DUMP_BYTES) {
170
+ state.truncated = true;
171
+ state.stdout.push(chunk.subarray(0, chunk.length - (state.bytes - MAX_DUMP_BYTES)));
172
+ return;
173
+ }
174
+ state.stdout.push(chunk);
175
+ });
176
+ const stderrDone = collect(child.stderr, (chunk) => state.stderr.push(chunk.toString("utf-8")));
177
+ child.on("error", (error) => finish(error));
178
+ child.on("close", (code) => {
179
+ state.code = code;
180
+ void Promise.all([stdoutDone, stderrDone]).then(() => finish(null));
181
+ });
182
+ options.signal?.addEventListener("abort", onAbort, { once: true });
183
+ if (options.signal?.aborted) onAbort();
184
+ });
185
+ }
186
+
187
+ function bodyChars(text: string): number {
188
+ const body = text
189
+ .split("\n")
190
+ .filter((line) => !METADATA_LINE_RE.test(line))
191
+ .join("\n");
192
+ return visibleChars(body);
193
+ }
194
+
195
+ function withProvenance(prefixed: string, url: string): string {
196
+ const lines = prefixed.split("\n");
197
+ let index = 0;
198
+ while (index < lines.length && METADATA_LINE_RE.test(lines[index])) index++;
199
+ const header = [`URL: ${url}`, PROVENANCE];
200
+ if (index === 0) return `${header.join("\n")}\n\n${prefixed}`;
201
+ return [...lines.slice(0, index), ...header, ...lines.slice(index)].join("\n");
202
+ }
203
+
204
+ export function lightpandaBinary(options: LightpandaRenderOptions = {}): string {
205
+ return (
206
+ options.binaryPath ??
207
+ options.settings?.binaryPath ??
208
+ process.env.PI_LIGHTPANDA_BIN?.trim() ??
209
+ DEFAULT_BINARY
210
+ );
211
+ }
212
+
213
+ export function lightpandaLaunch(options: LightpandaRenderOptions = {}): string[] {
214
+ const command = options.command ?? options.settings?.command ?? null;
215
+ if (command && command.length) return command;
216
+ return [lightpandaBinary(options)];
217
+ }
218
+
219
+ export async function renderPageWithLightpanda(
220
+ url: string,
221
+ options: LightpandaRenderOptions = {},
222
+ ): Promise<string | null> {
223
+ if (options.settings && options.settings.enabled === false) return null;
224
+ const target = typeof url === "string" ? url.trim() : "";
225
+ const policy = options.websitePolicy ?? null;
226
+ if (!target) return checkUrlAccess("", policy)[1];
227
+ if (parseLocalPath(target) !== null) return LOCAL_FILE_MESSAGE;
228
+ const normalized = normalizeUrlScheme(target);
229
+ const [allowed, reason, hostname] = checkUrlAccess(normalized, policy);
230
+ if (!allowed) return reason;
231
+ const launch = lightpandaLaunch(options);
232
+ const spawn = options.spawn ?? (spawnProcess as unknown as LightpandaSpawn);
233
+ if (!(await binaryAvailable(launch, spawn))) return null;
234
+ const timeoutMs = Math.min(
235
+ MAX_SIGNAL_TIMEOUT_MS,
236
+ Math.max(1, Math.floor(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)),
237
+ );
238
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
239
+ const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
240
+ const resolve = options.resolve ?? resolveAndValidateHost;
241
+ const resolved = await resolve(hostname, signal, false);
242
+ if (options.signal?.aborted) return CANCELLED_MESSAGE;
243
+ if (timeoutSignal.aborted) return TIMED_OUT_MESSAGE;
244
+ if (!resolved.ok) {
245
+ return resolved.reason.startsWith("Blocked:") ? resolved.reason : `Failed to render URL: ${resolved.reason}`;
246
+ }
247
+ const dump = options.dump ?? "html";
248
+ const result = await runLightpanda(
249
+ launch,
250
+ [
251
+ "fetch",
252
+ "--dump",
253
+ dump,
254
+ "--wait-until",
255
+ options.waitUntil ?? DEFAULT_WAIT_UNTIL,
256
+ "--block-private-networks",
257
+ "--http-timeout",
258
+ String(timeoutMs),
259
+ normalized,
260
+ ],
261
+ { timeoutMs, signal: options.signal, spawn },
262
+ );
263
+ if (result.missing) return null;
264
+ if (result.cancelled) return CANCELLED_MESSAGE;
265
+ if (result.timedOut) return TIMED_OUT_MESSAGE;
266
+ if (result.code !== 0) {
267
+ return `Failed to render URL: Lightpanda exited with code ${result.code ?? "unknown"}.`;
268
+ }
269
+ const raw = result.stdout;
270
+ const rendered =
271
+ dump === "html" ? pagePrefixedMarkdown(result.truncated ? raw + TRUNCATED_BODY_NOTICE : raw) : raw.trim();
272
+ if (bodyChars(rendered) === 0) return NO_TEXT_MESSAGE;
273
+ const withHeader =
274
+ dump === "html" || !result.truncated ? withProvenance(rendered, normalized) : withProvenance(rendered + TRUNCATED_BODY_NOTICE, normalized);
275
+ return truncatePageText(withHeader, options.maxChars);
276
+ }