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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.7.6",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension: web_search and web_fetch tools that began as a port of the Unsloth Studio codebase and now diverge from it (multi-engine search, opt-in SSRF guard, HTML-to-Markdown extraction)",
6
6
  "main": "index.ts",
@@ -23,11 +23,10 @@
23
23
  "license": "AGPL-3.0",
24
24
  "files": [
25
25
  "index.ts",
26
- "config.ts",
27
- "config-ui.ts",
28
26
  "web-search.ts",
29
27
  "web-fetch.ts",
30
- "web-render.ts",
28
+ "tls-fetch.ts",
29
+ "lightpanda.ts",
31
30
  "web-access.ts",
32
31
  "html-to-md.ts",
33
32
  "engines.ts",
@@ -39,7 +38,8 @@
39
38
  "settings.ts",
40
39
  "agent-dir.ts",
41
40
  "README.md",
42
- "LICENSE"
41
+ "LICENSE",
42
+ "scripts/install-lightpanda.sh"
43
43
  ],
44
44
  "pi": {
45
45
  "extensions": [
@@ -58,6 +58,11 @@
58
58
  "test": "vitest run",
59
59
  "test:unit": "vitest run --exclude **/smoke.test.ts",
60
60
  "test:smoke": "vitest run test/smoke.test.ts",
61
+ "test:lightpanda": "vitest run test/lightpanda-live.test.ts",
62
+ "compare:fetch": "node scripts/fetch-compare.ts",
63
+ "compare:browsers": "node scripts/compare-browsers.ts",
64
+ "camoufox:warmup": "node scripts/camoufox-warmup.ts",
65
+ "stealth:matrix": "node scripts/stealth-matrix.ts",
61
66
  "typecheck": "tsc --noEmit",
62
67
  "check:package": "node -e \"const fs=require('fs');const pkg=require('./package.json');const listed=new Set(pkg.files);const bad=[];for(const f of pkg.files){if(!fs.existsSync(f))bad.push('missing file: '+f)}for(const f of fs.readdirSync('.').filter(f=>f.endsWith('.ts'))){if(!listed.has(f))bad.push('unlisted source: '+f)}if(bad.length){console.error(bad.join('\\n'));process.exit(1)}\"",
63
68
  "prepublishOnly": "npm run typecheck && npm run lint && npm run test:unit && npm run check:package && npm run check:publint",
@@ -65,6 +70,9 @@
65
70
  "check:publint": "publint",
66
71
  "check:attw": "attw --pack"
67
72
  },
73
+ "dependencies": {
74
+ "wreq-js": "^3.2.0"
75
+ },
68
76
  "devDependencies": {
69
77
  "@earendil-works/pi-coding-agent": "^0.87.0",
70
78
  "@earendil-works/pi-tui": "^0.87.0",
package/proxy.ts CHANGED
@@ -3,6 +3,7 @@ import https from "node:https";
3
3
  import net from "node:net";
4
4
  import tls from "node:tls";
5
5
  import type { Duplex } from "node:stream";
6
+ import { stripIpv6Brackets } from "./web-access.ts";
6
7
 
7
8
  export interface SocksProxy {
8
9
  host: string;
@@ -124,7 +125,7 @@ export function socksProxyForUrl(url: URL): SocksProxy | null {
124
125
  const proxy = parseSocksProxy(firstEnv(url.protocol === "https:" ? HTTPS_PROXY_VARS : HTTP_PROXY_VARS));
125
126
  if (!proxy) return null;
126
127
  const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80;
127
- if (bypassesProxy(url.hostname, port)) return null;
128
+ if (bypassesProxy(stripIpv6Brackets(url.hostname), port)) return null;
128
129
  return proxy;
129
130
  }
130
131
 
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ REPO="lightpanda-io/browser"
5
+ DEFAULT_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/pi-unsloth-webtools/lightpanda"
6
+ PINNED_FALLBACK_VERSION="1.0.0"
7
+
8
+ DIR="$DEFAULT_DIR"
9
+ VERSION=""
10
+ FORCE=0
11
+ ALLOW_SHIM=1
12
+ PRINT_PATH=0
13
+
14
+ usage() {
15
+ cat <<'EOF'
16
+ Install the Lightpanda browser used by this package's local rendering tier.
17
+
18
+ Usage: bash scripts/install-lightpanda.sh [options]
19
+
20
+ --version=X Release tag to install (default: the newest stable release, e.g. 1.0.0;
21
+ pass --version=nightly for the nightly build)
22
+ --dir=PATH Install directory (default: $XDG_DATA_HOME/pi-unsloth-webtools/lightpanda)
23
+ --force Reinstall even when a working binary is already present
24
+ --no-shim Fail instead of bundling a newer libc when the system glibc is too old
25
+ --print-path Print only the launcher path (for scripts and CI)
26
+ -h, --help Show this message
27
+
28
+ On Linux, if the release needs a newer glibc than the system provides, the script
29
+ downloads Debian's libc6 for the current stable suite, extracts it into the install
30
+ directory, and writes a launcher shim that runs the binary with that libc. Point
31
+ `webRender.lightpandaPath` (settings.json) or `PI_LIGHTPANDA_BIN` at the printed path.
32
+ EOF
33
+ }
34
+
35
+ for arg in "$@"; do
36
+ case "$arg" in
37
+ --version=*) VERSION="${arg#*=}" ;;
38
+ --dir=*) DIR="${arg#*=}" ;;
39
+ --force) FORCE=1 ;;
40
+ --no-shim) ALLOW_SHIM=0 ;;
41
+ --print-path) PRINT_PATH=1 ;;
42
+ -h|--help) usage; exit 0 ;;
43
+ *) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;;
44
+ esac
45
+ done
46
+
47
+ need() {
48
+ if ! command -v "$1" >/dev/null 2>&1; then
49
+ echo "missing required command: $1" >&2
50
+ exit 1
51
+ fi
52
+ }
53
+
54
+ need curl
55
+ need awk
56
+ need sed
57
+
58
+ os="$(uname -s)"
59
+ arch="$(uname -m)"
60
+ deb_arch=""
61
+ case "$os/$arch" in
62
+ Linux/x86_64) asset="lightpanda-x86_64-linux"; deb_arch="amd64" ;;
63
+ Linux/aarch64|Linux/arm64) asset="lightpanda-aarch64-linux"; deb_arch="arm64" ;;
64
+ Darwin/x86_64) asset="lightpanda-x86_64-macos" ;;
65
+ Darwin/arm64) asset="lightpanda-aarch64-macos" ;;
66
+ MINGW*|MSYS*|CYGWIN*)
67
+ cat >&2 <<'EOF'
68
+ Lightpanda ships no native Windows build. Run it inside WSL2 instead:
69
+
70
+ 1. in WSL: bash scripts/install-lightpanda.sh
71
+ (it prints a path such as /home/<you>/.local/share/pi-unsloth-webtools/lightpanda/lightpanda-run)
72
+
73
+ 2. in Windows settings.json, drive that binary through wsl.exe:
74
+
75
+ {
76
+ "webRender": {
77
+ "lightpandaCommand": ["wsl.exe", "-e", "/home/<you>/.local/share/pi-unsloth-webtools/lightpanda/lightpanda-run"]
78
+ }
79
+ }
80
+
81
+ Everything else in this package (search, the browser-fingerprint transport) runs natively on Windows.
82
+ EOF
83
+ exit 1 ;;
84
+ *) echo "unsupported platform: $os/$arch" >&2; exit 1 ;;
85
+ esac
86
+
87
+ if [ -z "$VERSION" ]; then
88
+ VERSION="$(curl -fsSL "https://api.github.com/repos/$REPO/releases?per_page=30" 2>/dev/null \
89
+ | grep -o '"tag_name":[[:space:]]*"[^"]*"' \
90
+ | sed -n 's/.*"\([^"]*\)"$/\1/p' \
91
+ | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \
92
+ | head -1 || true)"
93
+ if [ -z "$VERSION" ]; then
94
+ echo "could not resolve the newest release; falling back to $PINNED_FALLBACK_VERSION" >&2
95
+ VERSION="$PINNED_FALLBACK_VERSION"
96
+ fi
97
+ fi
98
+
99
+ mkdir -p "$DIR"
100
+ binary="$DIR/lightpanda"
101
+ shim="$DIR/lightpanda-run"
102
+
103
+ report_path() {
104
+ if [ "$PRINT_PATH" = 1 ]; then
105
+ printf '%s\n' "$1"
106
+ else
107
+ echo "already installed and working: $1" >&2
108
+ fi
109
+ exit 0
110
+ }
111
+
112
+ if [ "$FORCE" = 0 ] && [ -x "$shim" ] && "$shim" version >/dev/null 2>&1; then
113
+ report_path "$shim"
114
+ fi
115
+ if [ "$FORCE" = 0 ] && [ -x "$binary" ] && "$binary" version >/dev/null 2>&1; then
116
+ report_path "$binary"
117
+ fi
118
+
119
+ url="https://github.com/$REPO/releases/download/$VERSION/$asset"
120
+ if [ "$PRINT_PATH" = 1 ]; then
121
+ echo "downloading $VERSION ($asset)" >&2
122
+ curl -fL --silent --show-error "$url" -o "$binary.part"
123
+ else
124
+ echo "downloading $VERSION ($asset)"
125
+ curl -fL --progress-bar "$url" -o "$binary.part"
126
+ fi
127
+ chmod +x "$binary.part"
128
+ mv -f "$binary.part" "$binary"
129
+ rm -f "$shim"
130
+
131
+ run_path="$binary"
132
+ if ! "$binary" version >/dev/null 2>&1; then
133
+ run_path=""
134
+ if [ "$ALLOW_SHIM" = 0 ] || [ "$os" != "Linux" ]; then
135
+ echo "the $VERSION binary cannot run on this system:" >&2
136
+ "$binary" version 2>&1 | head -3 >&2
137
+ exit 1
138
+ fi
139
+ required="$({ "$binary" version 2>&1 || true; } | sed -n "s/.*GLIBC_\([0-9][0-9.]*\).*/\1/p" | sort -V | tail -1)"
140
+ if [ -z "$required" ]; then
141
+ echo "the $VERSION binary failed to start and the cause is not a glibc version:" >&2
142
+ "$binary" version 2>&1 | head -3 >&2
143
+ exit 1
144
+ fi
145
+ system_glibc="$(ldd --version 2>/dev/null | head -1 | sed -n 's/.* \([0-9][0-9.]*\)$/\1/p')"
146
+ echo "binary needs glibc $required, system has ${system_glibc:-unknown}: bundling a newer libc"
147
+ need dpkg-deb
148
+ glibc_dir="$DIR/glibc"
149
+ rm -rf "$glibc_dir"
150
+ for suite in trixie stable sid; do
151
+ index="https://deb.debian.org/debian/dists/$suite/main/binary-$deb_arch/Packages.gz"
152
+ deb_path="$(curl -fsSL "$index" 2>/dev/null | gzip -dc 2>/dev/null \
153
+ | awk '/^Package: libc6$/{found=1} found && /^Filename:/{print $2; exit}' || true)"
154
+ [ -n "$deb_path" ] || continue
155
+ echo "fetching libc6 from debian/$suite"
156
+ curl -fsSL "https://deb.debian.org/debian/$deb_path" -o "$DIR/libc6.deb" || continue
157
+ mkdir -p "$glibc_dir"
158
+ dpkg-deb -x "$DIR/libc6.deb" "$glibc_dir"
159
+ rm -f "$DIR/libc6.deb"
160
+ loader="$(find "$glibc_dir" -path "*-linux-gnu/ld-linux-*.so.1" | head -1)"
161
+ [ -n "$loader" ] || continue
162
+ bundled_glibc="$("$loader" --version 2>/dev/null | head -1 | sed -n 's/.* \([0-9][0-9.]*\)$/\1/p')"
163
+ if [ -z "$bundled_glibc" ]; then
164
+ rm -rf "$glibc_dir"
165
+ continue
166
+ fi
167
+ newest="$(printf '%s\n%s\n' "$required" "$bundled_glibc" | sort -V | tail -1)"
168
+ if [ "$newest" != "$bundled_glibc" ]; then
169
+ echo "bundled glibc $bundled_glibc is older than required $required, trying the next suite" >&2
170
+ rm -rf "$glibc_dir"
171
+ continue
172
+ fi
173
+ libdir="$(dirname "$loader")"
174
+ {
175
+ echo "#!/bin/sh"
176
+ echo "exec \"$loader\" --library-path \"$libdir\" \"$binary\" \"\$@\""
177
+ } > "$shim"
178
+ chmod +x "$shim"
179
+ if "$shim" version >/dev/null 2>&1; then
180
+ run_path="$shim"
181
+ echo "bundled glibc $bundled_glibc at $glibc_dir"
182
+ break
183
+ fi
184
+ rm -rf "$glibc_dir" "$shim"
185
+ done
186
+ if [ -z "$run_path" ]; then
187
+ echo "could not make $VERSION runnable on this system." >&2
188
+ echo "Options: upgrade the distribution, run Lightpanda in a container, or install an older" >&2
189
+ echo "release that targets this glibc (for example 0.2.9)." >&2
190
+ exit 1
191
+ fi
192
+ fi
193
+
194
+ installed_version="$("$run_path" version 2>/dev/null | head -1)"
195
+ if [ "$PRINT_PATH" = 1 ]; then
196
+ printf '%s\n' "$run_path"
197
+ exit 0
198
+ fi
199
+
200
+ echo
201
+ echo "Lightpanda $installed_version installed"
202
+ echo " run this: $run_path"
203
+ echo " configure one of:"
204
+ echo " export PI_LIGHTPANDA_BIN=$run_path"
205
+ echo " settings.json: { \"webRender\": { \"lightpandaPath\": \"$run_path\" } }"
206
+ echo " verify: $run_path fetch --dump markdown --wait-until networkidle https://example.com/"
package/settings.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { agentDir } from "./agent-dir.ts";
4
+ import type { FetchTransport } from "./web-fetch.ts";
4
5
 
5
6
  async function readJson(file: string): Promise<Record<string, unknown> | undefined> {
6
7
  try {
@@ -49,6 +50,23 @@ function pickBoolean(data: Record<string, unknown>, paths: string[][]): boolean
49
50
  return undefined;
50
51
  }
51
52
 
53
+ function pickStringArray(data: Record<string, unknown>, paths: string[][]): string[] | undefined {
54
+ for (const path of paths) {
55
+ let cur: unknown = data;
56
+ for (const key of path) {
57
+ if (cur && typeof cur === "object" && !Array.isArray(cur)) cur = (cur as Record<string, unknown>)[key];
58
+ else {
59
+ cur = undefined;
60
+ break;
61
+ }
62
+ }
63
+ if (!Array.isArray(cur)) continue;
64
+ const values = cur.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
65
+ if (values.length) return values;
66
+ }
67
+ return undefined;
68
+ }
69
+
52
70
  function pickString(data: Record<string, unknown>, paths: string[][]): string | undefined {
53
71
  for (const path of paths) {
54
72
  let cur: unknown = data;
@@ -94,11 +112,27 @@ const ALLOW_LOCAL_FILES_PATHS: string[][] = [
94
112
  ["webFetch", "allowLocalFiles"],
95
113
  ];
96
114
 
97
- const JINA_API_KEY_PATHS: string[][] = [
98
- ["unslothWebTools", "jinaApiKey"],
99
- ["webRender", "jinaApiKey"],
115
+ const TRANSPORT_PATHS: string[][] = [
116
+ ["unslothWebTools", "transport"],
117
+ ["webFetch", "transport"],
118
+ ];
119
+
120
+ const TRANSPORT_VALUES: ReadonlySet<string> = new Set(["tls-first", "direct-first", "off"]);
121
+
122
+ const LIGHTPANDA_ENABLED_PATHS: string[][] = [
123
+ ["unslothWebTools", "lightpandaEnabled"],
124
+ ["webRender", "lightpandaEnabled"],
125
+ ];
126
+
127
+ const LIGHTPANDA_PATH_PATHS: string[][] = [
128
+ ["unslothWebTools", "lightpandaPath"],
129
+ ["webRender", "lightpandaPath"],
100
130
  ];
101
131
 
132
+ const LIGHTPANDA_COMMAND_PATHS: string[][] = [
133
+ ["unslothWebTools", "lightpandaCommand"],
134
+ ["webRender", "lightpandaCommand"],
135
+ ];
102
136
  function clampMaxResults(value: number): number {
103
137
  return Math.min(20, Math.max(1, value));
104
138
  }
@@ -151,11 +185,13 @@ export async function loadDefaultFetchSettings(cwd?: string): Promise<{
151
185
  timeoutMs?: number;
152
186
  allowPrivateAddresses: boolean;
153
187
  allowLocalFiles: boolean;
188
+ transport: FetchTransport;
154
189
  }> {
155
190
  let maxChars: number | undefined;
156
191
  let timeoutMs: number | undefined;
157
192
  let allowPrivateAddresses = true;
158
193
  let allowLocalFiles = true;
194
+ let transport: FetchTransport = "tls-first";
159
195
  for (const data of await settingsEntries(cwd)) {
160
196
  const c = pickNumber(data, FETCH_MAX_CHARS_PATHS);
161
197
  if (c !== undefined && c > 0) maxChars = c;
@@ -165,17 +201,27 @@ export async function loadDefaultFetchSettings(cwd?: string): Promise<{
165
201
  if (p !== undefined) allowPrivateAddresses = p;
166
202
  const l = pickBoolean(data, ALLOW_LOCAL_FILES_PATHS);
167
203
  if (l !== undefined) allowLocalFiles = l;
204
+ const mode = pickString(data, TRANSPORT_PATHS);
205
+ if (mode !== undefined && TRANSPORT_VALUES.has(mode)) transport = mode as FetchTransport;
168
206
  }
169
- return { maxChars, timeoutMs, allowPrivateAddresses, allowLocalFiles };
207
+ return { maxChars, timeoutMs, allowPrivateAddresses, allowLocalFiles, transport };
170
208
  }
171
209
 
172
- export async function loadJinaApiKey(cwd?: string): Promise<string | undefined> {
173
- let result: string | undefined;
210
+ export async function loadLightpandaSettings(cwd?: string): Promise<{
211
+ enabled: boolean;
212
+ binaryPath: string | null;
213
+ command: string[] | null;
214
+ }> {
215
+ let enabled = true;
216
+ let binaryPath: string | null = null;
217
+ let command: string[] | null = null;
174
218
  for (const data of await settingsEntries(cwd)) {
175
- const candidate = pickString(data, JINA_API_KEY_PATHS);
176
- if (candidate !== undefined) result = candidate;
219
+ const e = pickBoolean(data, LIGHTPANDA_ENABLED_PATHS);
220
+ if (e !== undefined) enabled = e;
221
+ const p = pickString(data, LIGHTPANDA_PATH_PATHS);
222
+ if (p !== undefined) binaryPath = p;
223
+ const c = pickStringArray(data, LIGHTPANDA_COMMAND_PATHS);
224
+ if (c !== undefined) command = c;
177
225
  }
178
- if (result !== undefined) return result;
179
- const env = process.env.JINA_API_KEY?.trim();
180
- return env ? env : undefined;
226
+ return { enabled, binaryPath, command };
181
227
  }
package/tls-fetch.ts ADDED
@@ -0,0 +1,205 @@
1
+ import { MAX_SIGNAL_TIMEOUT_MS } from "./web-access.ts";
2
+
3
+ const MODULE_NAME = "wreq-js";
4
+ const DEFAULT_PROFILE = "chrome_145";
5
+ const DEFAULT_OS = "windows";
6
+ const TRANSPORT_CACHE_LIMIT = 8;
7
+
8
+ const HOP_HEADER_EXCLUSIONS = new Set(["host", "user-agent", "accept-encoding", "content-length"]);
9
+ const PDF_MAGIC = Buffer.from("%PDF-");
10
+ const MAGIC_HEAD_BYTES = 1024;
11
+ const SKIPPED_MAGIC_PREFIX_BYTES = new Set([0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20]);
12
+ const BOM_PREFIXES = [
13
+ Buffer.from([0xff, 0xfe, 0x00, 0x00]),
14
+ Buffer.from([0x00, 0x00, 0xfe, 0xff]),
15
+ Buffer.from([0xff, 0xfe]),
16
+ Buffer.from([0xfe, 0xff]),
17
+ Buffer.from([0xef, 0xbb, 0xbf]),
18
+ ];
19
+
20
+ function looksLikePdf(head: Buffer): boolean {
21
+ let start = 0;
22
+ while (start < head.length && SKIPPED_MAGIC_PREFIX_BYTES.has(head[start])) start++;
23
+ for (const bom of BOM_PREFIXES) {
24
+ if (head.subarray(start).length >= bom.length && head.subarray(start, start + bom.length).equals(bom)) {
25
+ start += bom.length;
26
+ while (start < head.length && SKIPPED_MAGIC_PREFIX_BYTES.has(head[start])) start++;
27
+ break;
28
+ }
29
+ }
30
+ const magic = head.subarray(start);
31
+ return magic.length >= PDF_MAGIC.length && magic.subarray(0, PDF_MAGIC.length).equals(PDF_MAGIC);
32
+ }
33
+
34
+ interface ImpersonationResponse {
35
+ status: number;
36
+ url: string;
37
+ headers: { get(name: string): string | null };
38
+ body: AsyncIterable<Uint8Array> | null;
39
+ }
40
+
41
+ interface ImpersonationTransport {
42
+ close?(): Promise<void>;
43
+ }
44
+
45
+ interface ImpersonationModule {
46
+ createTransport(options: Record<string, unknown>): Promise<ImpersonationTransport>;
47
+ fetch(input: string, init: Record<string, unknown>): Promise<ImpersonationResponse>;
48
+ }
49
+
50
+ export type ImpersonationLoader = () => Promise<ImpersonationModule | null>;
51
+
52
+ export interface TlsHopOptions {
53
+ url: URL;
54
+ pinnedIp: string;
55
+ family: number;
56
+ timeoutMs: number;
57
+ signal?: AbortSignal;
58
+ maxBytes: number;
59
+ maxPdfBytes?: number;
60
+ profile?: string;
61
+ extraHeaders?: Record<string, string>;
62
+ loader?: ImpersonationLoader;
63
+ }
64
+
65
+ export interface TlsHopResponse {
66
+ status: number;
67
+ headers: Record<string, string>;
68
+ body: Buffer;
69
+ truncated: boolean;
70
+ }
71
+
72
+ let cachedModule: Promise<ImpersonationModule | null> | null = null;
73
+
74
+ async function loadImpersonationModule(): Promise<ImpersonationModule | null> {
75
+ cachedModule ??= (async () => {
76
+ try {
77
+ return (await import(MODULE_NAME)) as unknown as ImpersonationModule;
78
+ } catch {
79
+ return null;
80
+ }
81
+ })();
82
+ return cachedModule;
83
+ }
84
+
85
+ const transports = new Map<string, Promise<ImpersonationTransport>>();
86
+
87
+ function transportKey(options: TlsHopOptions): string {
88
+ return `${options.profile ?? DEFAULT_PROFILE}|${options.url.hostname}|${options.pinnedIp}|${options.family}`;
89
+ }
90
+
91
+ function evictTransports(): void {
92
+ while (transports.size > TRANSPORT_CACHE_LIMIT) {
93
+ const oldestKey = transports.keys().next().value as string | undefined;
94
+ if (oldestKey === undefined) return;
95
+ const oldest = transports.get(oldestKey);
96
+ transports.delete(oldestKey);
97
+ void oldest
98
+ ?.then((transport) => transport.close?.())
99
+ .catch(() => {});
100
+ }
101
+ }
102
+
103
+ async function transportFor(
104
+ module: ImpersonationModule,
105
+ options: TlsHopOptions,
106
+ ): Promise<ImpersonationTransport> {
107
+ const key = transportKey(options);
108
+ const existing = transports.get(key);
109
+ if (existing) return existing;
110
+ const created = module
111
+ .createTransport({
112
+ resolve: { [options.url.hostname]: options.pinnedIp },
113
+ browser: options.profile ?? DEFAULT_PROFILE,
114
+ os: DEFAULT_OS,
115
+ })
116
+ .catch((error: unknown) => {
117
+ transports.delete(key);
118
+ throw error;
119
+ });
120
+ transports.set(key, created);
121
+ evictTransports();
122
+ return created;
123
+ }
124
+
125
+ async function readCapped(
126
+ body: AsyncIterable<Uint8Array> | null,
127
+ maxBytes: number,
128
+ maxPdfBytes: number,
129
+ ): Promise<{ buffer: Buffer; truncated: boolean }> {
130
+ if (!body) return { buffer: Buffer.alloc(0), truncated: false };
131
+ const chunks: Buffer[] = [];
132
+ let total = 0;
133
+ let truncated = false;
134
+ let head = Buffer.alloc(0);
135
+ let limit = maxBytes;
136
+ for await (const chunk of body) {
137
+ const buffer = Buffer.from(chunk);
138
+ if (head.length < MAGIC_HEAD_BYTES) {
139
+ head = Buffer.concat([head, buffer.subarray(0, MAGIC_HEAD_BYTES - head.length)]);
140
+ if (limit === maxBytes && looksLikePdf(head)) limit = maxPdfBytes;
141
+ }
142
+ if (total + buffer.length > limit) {
143
+ const take = buffer.subarray(0, limit - total);
144
+ chunks.push(take);
145
+ total += take.length;
146
+ truncated = true;
147
+ break;
148
+ }
149
+ chunks.push(buffer);
150
+ total += buffer.length;
151
+ }
152
+ return { buffer: Buffer.concat(chunks), truncated };
153
+ }
154
+
155
+ function failureMessage(error: unknown, caller: AbortSignal | undefined, attempt: AbortSignal): string {
156
+ if (caller?.aborted) return "cancelled";
157
+ if (attempt.aborted) return "timed out";
158
+ if (error instanceof Error && error.name === "TimeoutError") return "timed out";
159
+ if (error instanceof Error && error.name === "AbortError") return "cancelled";
160
+ return error instanceof Error ? error.message : String(error);
161
+ }
162
+
163
+ function hopHeaders(extraHeaders: Record<string, string> | undefined): Record<string, string> {
164
+ const headers: Record<string, string> = {};
165
+ for (const [key, value] of Object.entries(extraHeaders ?? {})) {
166
+ if (HOP_HEADER_EXCLUSIONS.has(key.toLowerCase())) continue;
167
+ headers[key] = value;
168
+ }
169
+ return headers;
170
+ }
171
+
172
+ export async function impersonatedRequest(options: TlsHopOptions): Promise<TlsHopResponse | null> {
173
+ const load = options.loader ?? loadImpersonationModule;
174
+ const module = await load();
175
+ if (!module) return null;
176
+ const transport = await transportFor(module, options);
177
+ const timeoutMs = Math.min(MAX_SIGNAL_TIMEOUT_MS, Math.max(1, Math.floor(options.timeoutMs)));
178
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
179
+ const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
180
+ let response: ImpersonationResponse;
181
+ try {
182
+ response = await module.fetch(options.url.toString(), {
183
+ transport,
184
+ redirect: "manual",
185
+ signal,
186
+ headers: hopHeaders(options.extraHeaders),
187
+ });
188
+ } catch (error) {
189
+ throw new Error(failureMessage(error, options.signal, signal));
190
+ }
191
+ if (response.status < 200) return null;
192
+ const { buffer, truncated } = await readCapped(
193
+ response.body,
194
+ options.maxBytes,
195
+ options.maxPdfBytes ?? options.maxBytes,
196
+ );
197
+ const headers: Record<string, string> = {};
198
+ const contentType = response.headers.get("content-type");
199
+ if (contentType) headers["content-type"] = contentType;
200
+ if (response.status >= 300 && response.status < 400) {
201
+ const location = response.headers.get("location");
202
+ if (location) headers.location = location;
203
+ }
204
+ return { status: response.status, headers, body: buffer, truncated };
205
+ }
package/web-access.ts CHANGED
@@ -70,6 +70,10 @@ export function normalizeDomain(value: unknown): string {
70
70
  return asciiDomain;
71
71
  }
72
72
 
73
+ export function stripIpv6Brackets(hostname: string): string {
74
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
75
+ }
76
+
73
77
  function compressIpv6(ip: string): string {
74
78
  const segments = ip.toLowerCase().split("::");
75
79
  if (segments.length > 2) throw new Error("invalid ipv6");