/** * Browser-TLS-impersonating HTTP client for www.perplexity.ai. * * Why this exists: Perplexity sits behind the same Cloudflare Enterprise * configuration as ChatGPT — it pins access to the client's TLS fingerprint * (JA3/JA4) + HTTP/2 SETTINGS frame ordering. Node's Undici fetch presents an * obvious "not a browser" handshake and gets challenged with a 403 "Just a * moment..." page from VPS/datacenter IPs — even with a valid session cookie. * This module wraps `tls-client-node` (native shared library built from * bogdanfinn/tls-client) to send a Firefox handshake instead. (issue #2459) * * Mirrors `chatgptTlsClient.ts`; kept as an independent module so changes here * cannot regress the production chatgpt-web path. The first call lazily starts * the managed sidecar; subsequent calls reuse a singleton TLSClient. Process * exit hooks stop the sidecar cleanly. */ import { tmpdir } from "node:os"; import { join } from "node:path"; import { mkdtemp, open, unlink, rmdir, stat } from "node:fs/promises"; import { randomUUID } from "node:crypto"; let clientPromise: Promise | null = null; let exitHookInstalled = false; const PPLX_PROFILE = "firefox_148"; // matches the Firefox 148 UA we send const DEFAULT_TIMEOUT_MS = Number.parseInt(process.env.OMNIROUTE_PPLX_TLS_TIMEOUT_MS || "", 10) || 30_000; // Grace period added to the binding's wire-level timeout before our JS-level // hard timeout fires. Under healthy operation `tls-client-node` honors // `timeoutMilliseconds` and rejects on its own; the JS-level race only wins // when the koffi-loaded native library is wedged (which the binding's own // timer can't escape). Keep the grace small so users don't wait noticeably // longer than the configured timeout when the binding is dead. const HARD_TIMEOUT_GRACE_MS = Number.parseInt(process.env.OMNIROUTE_PPLX_TLS_GRACE_MS || "", 10) || 10_000; function installExitHook(): void { if (exitHookInstalled) return; exitHookInstalled = true; const stop = async () => { if (!clientPromise) return; try { const c = (await clientPromise) as { stop?: () => Promise }; await c.stop?.(); } catch { // ignore } }; process.once("beforeExit", stop); process.once("SIGINT", () => { void stop(); }); process.once("SIGTERM", () => { void stop(); }); } /** * Drop the cached client so the next `getClient()` call respawns it. Called * when a request observes the native binding has wedged — releasing the * reference lets a fresh TLSClient (and a fresh koffi load) take over without * a process restart. */ function resetClientCache(): void { clientPromise = null; } export class TlsClientHangError extends Error { constructor(message: string) { super(message); this.name = "TlsClientHangError"; } } /** * Race a `client.request()` promise against (a) a JS-level hard timeout and * (b) the caller's abort signal. The native binding's `timeoutMilliseconds` * already covers the wire path; this guards the case where the koffi binding * itself deadlocks (observed after sustained load), where neither the * binding's own timer nor a post-call `signal.aborted` re-check can recover. */ async function raceWithTimeout( promise: Promise, timeoutMs: number, signal: AbortSignal | null | undefined ): Promise { let timer: ReturnType | null = null; let abortListener: (() => void) | null = null; try { const racers: Promise[] = [ promise, new Promise((_, reject) => { timer = setTimeout(() => { reject( new TlsClientHangError( `tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked` ) ); }, timeoutMs); }), ]; if (signal) { racers.push( new Promise((_, reject) => { if (signal.aborted) { reject(makeAbortError(signal)); return; } abortListener = () => reject(makeAbortError(signal)); signal.addEventListener("abort", abortListener, { once: true }); }) ); } return await Promise.race(racers); } finally { if (timer) clearTimeout(timer); if (signal && abortListener) signal.removeEventListener("abort", abortListener); } } async function getClient(): Promise<{ request: (url: string, opts: Record) => Promise; }> { if (!clientPromise) { clientPromise = (async () => { try { const mod = await import("tls-client-node"); const TLSClient = (mod as { TLSClient: new (opts?: Record) => unknown }) .TLSClient; // Native mode loads the shared library directly via koffi, avoiding the // managed sidecar's localhost HTTP calls that OmniRoute's global fetch // proxy patch interferes with. const client = new TLSClient({ runtimeMode: "native" }) as { start: () => Promise; request: (url: string, opts: Record) => Promise; }; await client.start(); installExitHook(); return client; } catch (err) { clientPromise = null; const msg = err instanceof Error ? err.message : String(err); throw new TlsClientUnavailableError( `TLS impersonation client failed to start: ${msg}. ` + `Verify tls-client-node is installed and its native binary downloaded.` ); } })(); } return clientPromise as Promise<{ request: (url: string, opts: Record) => Promise; }>; } interface TlsResponseLike { status: number; headers: Record; body: string; // for non-streaming requests, the full response body cookies?: Record; text: () => Promise; bytes: () => Promise; json: () => Promise; } export class TlsClientUnavailableError extends Error { constructor(message: string) { super(message); this.name = "TlsClientUnavailableError"; } } export interface TlsFetchOptions { method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; headers?: Record; body?: string; timeoutMs?: number; signal?: AbortSignal | null; /** * If true, the response body is streamed to a temp file and exposed as a * ReadableStream. Use for SSE responses (the perplexity_ask * endpoint). Otherwise, the full body is read into memory. */ stream?: boolean; /** EOF marker the upstream sends to signal end of stream (default: "[DONE]"). */ streamEofSymbol?: string; /** * Optional upstream proxy URL (`http://user:pass@host:port` or * `socks5://...`). When set, the request is tunneled through this proxy * before reaching perplexity.ai. * * Resolution order: * 1. `options.proxyUrl` (per-call override from caller) * 2. `process.env.OMNIROUTE_TLS_PROXY_URL` (single-flag opt-in) * 3. `process.env.HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` (POSIX-standard fallback) * * The native `tls-client-node` binding does **not** consult Go's * `http.ProxyFromEnvironment`, so the env vars need to be plumbed in here at * the JS layer. */ proxyUrl?: string; } import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; /** * Resolve the proxy URL for a tls-client request. Per-call value wins; * otherwise we use the standard proxy fetch resolution which reads from * the dashboard AsyncLocalStorage context or falls back to env vars. * * Fail-closed: if resolution throws (e.g. a configured socks5 proxy with * ENABLE_SOCKS5_PROXY=false), this rethrows rather than returning undefined — * undefined would let the native binding connect directly and leak the real IP. */ function resolveProxyUrl(perCall: string | undefined): string | undefined { return resolveTlsClientProxyUrl("https://www.perplexity.ai", perCall, resolveProxyForRequest); } export interface TlsFetchResult { status: number; headers: Headers; /** Full response body as text — only populated for non-streaming requests. */ text: string | null; /** Streaming body — only populated when options.stream === true. */ body: ReadableStream | null; } // Test-only injection point. Tests call __setTlsFetchOverrideForTesting() // to replace the real TLS client with a mock; production never touches this. let testOverride: ((url: string, options: TlsFetchOptions) => Promise) | null = null; export function __setTlsFetchOverrideForTesting(fn: typeof testOverride): void { testOverride = fn; } /** * Make a single HTTP request to perplexity.ai with a Firefox-like TLS fingerprint. * * Throws TlsClientUnavailableError if the native binary failed to load. */ export async function tlsFetchPerplexity( url: string, options: TlsFetchOptions = {} ): Promise { if (testOverride) return testOverride(url, options); // Honor abort signals up-front. tls-client-node's koffi binding doesn't // accept an AbortSignal mid-flight (the binary call is opaque), so the best // we can do is bail before issuing the call. We also re-check after — if // the caller aborted while the upstream was running, throw rather than // returning a stale response so the caller doesn't try to use it. if (options.signal?.aborted) { throw makeAbortError(options.signal); } const client = await getClient(); if (options.signal?.aborted) { throw makeAbortError(options.signal); } const requestOptions: Record = { method: options.method || "GET", headers: options.headers || {}, body: options.body, tlsClientIdentifier: PPLX_PROFILE, timeoutMilliseconds: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, followRedirects: true, withRandomTLSExtensionOrder: true, // Plumb the configured proxy through to the native binding. tls-client-node // consults `proxyUrl` in the per-call options (it does NOT auto-pick up // HTTP_PROXY / HTTPS_PROXY env), so callers / env have to be threaded in // explicitly. See `resolveProxyUrl()` for the lookup order. proxyUrl: resolveProxyUrl(options.proxyUrl), }; if (options.stream) { return await tlsFetchStreaming( client, url, requestOptions, options.streamEofSymbol, options.signal ?? null, (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS ); } let tlsResponse: TlsResponseLike; try { tlsResponse = await raceWithTimeout( client.request(url, requestOptions), (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS, options.signal ?? null ); } catch (err) { if (err instanceof TlsClientHangError) { // The native binding is wedged — drop the singleton so the next // request respawns a fresh client (and a fresh koffi load). resetClientCache(); } throw err; } if (options.signal?.aborted) { throw makeAbortError(options.signal); } return { status: tlsResponse.status, headers: toHeaders(tlsResponse.headers), text: tlsResponse.body, body: null, }; } function makeAbortError(signal: AbortSignal): Error { const reason = signal.reason; if (reason instanceof Error) return reason; const err = new Error(typeof reason === "string" ? reason : "The operation was aborted"); err.name = "AbortError"; return err; } function toHeaders(raw: Record): Headers { const h = new Headers(); for (const [k, vs] of Object.entries(raw || {})) { for (const v of vs) h.append(k, v); } return h; } /** * Returns true if the response body is a Cloudflare challenge/interstitial page * rather than a real Perplexity response. From VPS/datacenter IPs a valid cookie * still gets a 403 "Just a moment..." HTML page; distinguishing it from a genuine * auth failure lets the caller surface an actionable error (issue #2459). * * Exported so the executor and the connection validator share one detector. */ export function isCloudflareChallenge(text: string | null | undefined): boolean { if (!text) return false; return /just a moment|window\._cf_chl_opt|challenges\.cloudflare\.com|attention required|cf-chl/i.test( text ); } // ─── Streaming via temp file ──────────────────────────────────────────────── // tls-client-node's streaming primitive writes the response body chunk-by-chunk // to a file path, terminating when the upstream sends `streamOutputEOFSymbol`. // We tail the file from a worker and surface the bytes as a ReadableStream. async function tlsFetchStreaming( client: { request: (url: string, opts: Record) => Promise }, url: string, requestOptions: Record, eofSymbol = "[DONE]", signal: AbortSignal | null = null, hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS ): Promise { const dir = await mkdtemp(join(tmpdir(), "pplx-stream-")); const path = join(dir, `${randomUUID()}.sse`); const streamOpts = { ...requestOptions, streamOutputPath: path, streamOutputBlockSize: 1024, streamOutputEOFSymbol: eofSymbol, }; // Kick off the request without awaiting — tls-client writes the body to // `path` chunk-by-chunk while the call runs. The Promise resolves when the // request fully completes (full body written). Wrapping in raceWithTimeout // guarantees this promise eventually settles even if the koffi binding // wedges; on hang we reset the singleton so the next request respawns. let resetOnHang = true; const requestPromise = raceWithTimeout( client.request(url, streamOpts), hardTimeoutMs, signal ).catch((err: unknown) => { if (resetOnHang && err instanceof TlsClientHangError) { resetClientCache(); resetOnHang = false; } // Re-throw so downstream consumers (waitForContent, tailFile) observe // the rejection and surface it instead of treating the stream as having // ended cleanly. throw err; }); // Wait for the file to exist AND have at least one byte. tls-client-node // creates the output file when the request starts, but the file can be // empty for a brief window before the first body chunk lands — peeking // during that window would return "" and misclassify the response as // non-SSE, dropping us into the buffered-wait branch and silently turning // a streaming request into a buffered one. Waiting for content avoids // that race; if the request actually fails before producing any bytes, // the timeout falls through to the requestPromise drain below (returning // the real upstream status). const ready = await waitForContent(path, 5_000, requestPromise); if (!ready) { const r = await requestPromise.catch( (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike ); await cleanupTempPath(path); return { status: r.status, headers: toHeaders(r.headers), text: r.body, body: null, }; } // Peek the first bytes to decide whether this looks like SSE. Anything // that doesn't positively look like SSE (JSON `{...}`, HTML `<...>`, plain // text rate-limit messages, Cloudflare challenge pages, etc.) gets surfaced // as a non-streaming response so the executor sees the real upstream status // and body — otherwise non-2xx error pages get silently treated as 200 OK // and the SSE parser produces an empty completion. const peek = await readFirstBytes(path, 256); if (!looksLikeSse(peek)) { const r = await requestPromise.catch( (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike ); await cleanupTempPath(path); return { status: r.status, headers: toHeaders(r.headers), text: r.body, body: null, }; } // Looks like SSE — start tailing. SSE bodies in practice are always 2xx; // tls-client-node doesn't expose response status separately from full-body // completion, so we report 200 and let the SSE parser consume the stream. const stream = tailFile(path, eofSymbol, requestPromise, signal); const headers = new Headers({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", }); return { status: 200, headers, text: null, body: stream }; } /** * Returns true if the peeked response body looks like an SSE stream — i.e., * begins (after any leading whitespace) with one of the SSE field markers * (`data:`, `event:`, `id:`, `retry:`) or a comment line (`:`). * * Exported for tests. */ export function looksLikeSse(text: string): boolean { const trimmed = text.replace(/^[\s\r\n]+/, ""); if (!trimmed) return false; if (trimmed.startsWith(":")) return true; return /^(data|event|id|retry):/i.test(trimmed); } async function cleanupTempPath(path: string): Promise { await unlink(path).catch(() => {}); const dir = path.substring(0, path.lastIndexOf("/")); await rmdir(dir).catch(() => {}); } async function readFirstBytes(path: string, n: number): Promise { const fd = await open(path, "r"); try { const buf = Buffer.alloc(n); const { bytesRead } = await fd.read(buf, 0, n, 0); return buf.subarray(0, bytesRead).toString("utf8"); } finally { await fd.close().catch(() => {}); } } /** * Wait for the streaming output file to exist AND contain at least one byte. * Returns false if the request settles before any bytes arrive (so the caller * can drain `requestPromise` and surface the real upstream status). Returns * true as soon as the file has data — even one byte is enough for the SSE * heuristic to give a useful answer. */ async function waitForContent( path: string, timeoutMs: number, requestPromise: Promise ): Promise { let requestSettled = false; requestPromise.then( () => { requestSettled = true; }, () => { requestSettled = true; } ); const start = Date.now(); while (Date.now() - start < timeoutMs) { try { const s = await stat(path); if (s.size > 0) return true; } catch { // file doesn't exist yet } // If the request finished without producing any bytes, no point waiting // out the rest of the timeout — let the caller drain it. if (requestSettled) return false; await sleep(25); } return false; } function tailFile( path: string, eofSymbol: string, done: Promise, signal: AbortSignal | null = null ): ReadableStream { return new ReadableStream({ async start(controller) { const fd = await open(path, "r"); const buf = Buffer.alloc(64 * 1024); let offset = 0; let finished = false; let aborted = false; let upstreamError: Error | null = null; // Track request settlement, capturing both fulfillment and rejection. // Without the rejection branch, a mid-stream tls-client-node error // becomes an unhandledRejection — the stream cleans up silently and // the consumer sees what looks like a successful truncated response. done.then( () => { finished = true; }, (err) => { upstreamError = err instanceof Error ? err : new Error(String(err)); finished = true; } ); // If the caller aborts, stop tailing immediately. const onAbort = () => { aborted = true; }; if (signal) { if (signal.aborted) aborted = true; else signal.addEventListener("abort", onAbort, { once: true }); } let errored = false; try { while (!aborted) { const { bytesRead } = await fd.read(buf, 0, buf.length, offset); if (bytesRead > 0) { const chunk = buf.subarray(0, bytesRead); offset += bytesRead; const text = chunk.toString("utf8"); if (text.includes(eofSymbol)) { const cutAt = text.indexOf(eofSymbol) + eofSymbol.length; controller.enqueue(new Uint8Array(chunk.subarray(0, cutAt))); break; } controller.enqueue(new Uint8Array(chunk)); } else if (finished) { // No more data and request completed. If the request rejected, // surface the error so the consumer doesn't think the stream // ended cleanly. if (upstreamError) { controller.error(upstreamError); errored = true; } break; } else { await sleep(25); } } } catch (err) { controller.error(err); errored = true; } finally { if (signal) signal.removeEventListener("abort", onAbort); await fd.close().catch(() => {}); await unlink(path).catch(() => {}); const dir = path.substring(0, path.lastIndexOf("/")); await rmdir(dir).catch(() => {}); if (!errored) controller.close(); } }, }); } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); }