diff --git a/open-sse/services/chatgptTlsClient.ts b/open-sse/services/chatgptTlsClient.ts index 57e5365726..fd7b3f4550 100644 --- a/open-sse/services/chatgptTlsClient.ts +++ b/open-sse/services/chatgptTlsClient.ts @@ -1,629 +1,48 @@ /** * Browser-TLS-impersonating HTTP client for chatgpt.com. * - * Why this exists: ChatGPT's Cloudflare config pins `cf_clearance` 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 `cf-mitigated: challenge` — even with all the right - * cookies. This module wraps `tls-client-node` (native shared library - * built from bogdanfinn/tls-client) to send a Firefox handshake instead. - * - * The first call lazily starts the managed sidecar; subsequent calls reuse - * a singleton TLSClient. Process exit hooks stop the sidecar cleanly. + * Thin re-export over the shared `tlsClientBase.ts` factory + * (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle, + * streaming tail-file, proxy resolution, error classes, SSE detection) lives + * in the base module; this file supplies only ChatGPT-specific config and + * preserves the original public export surface. */ -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { mkdtemp, open, unlink, rmdir, stat, readFile } from "node:fs/promises"; -import { randomUUID } from "node:crypto"; -import { buildNativeTlsClientOptions } from "./tlsClientDownloadDir.ts"; +import { + createTlsClientModule, + type TlsFetchOptions, + type TlsFetchResult, +} from "./tlsClientBase.ts"; -let clientPromise: Promise | null = null; -let exitHookInstalled = false; - -const CHATGPT_PROFILE = "firefox_148"; // matches the Firefox 148 UA we send const DEFAULT_TIMEOUT_MS = Number.parseInt(process.env.OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS || "", 10) || 60_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_CHATGPT_TLS_GRACE_MS || "", 10) || 10_000; const STREAM_FIRST_BYTE_TIMEOUT_MS = Number.parseInt(process.env.OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS || "", 10) || 30_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(); - }); -} +export const tlsClientModule = createTlsClientModule({ + providerName: "ChatGPT", + tlsProfile: "firefox_148", + domain: "https://chatgpt.com", + tempDirPrefix: "cgpt-stream-", + tailFileVariant: "A", + responseValidation: "sse", + exportCloudflareCheck: false, + exposeStreamingForTesting: true, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + hardTimeoutGraceMs: HARD_TIMEOUT_GRACE_MS, + firstByteTimeoutMs: STREAM_FIRST_BYTE_TIMEOUT_MS, +}); -/** - * 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(buildNativeTlsClientOptions()) 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 conversation - * 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; - /** - * If true, instructs the underlying tls-client to return the response body - * as a base64 `data:;base64,...` string (so binary payloads survive - * the JSON marshalling step). Required for image / binary downloads — - * without it, raw bytes get UTF-8-decoded and any non-ASCII byte is - * mangled. Default false (text mode). - */ - byteResponse?: boolean; - /** - * Optional upstream proxy URL (`http://user:pass@host:port` or - * `socks5://...`). When set, the request is tunneled through this proxy - * before reaching chatgpt.com. Required for hosts whose bare IP is - * flagged by ChatGPT/Cloudflare (Russia, datacenter ranges, etc.) — - * without it, every call leaks the host IP and gets edge-rejected with - * a templated 401 / `Invalid session cookie`. - * - * 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. The dashboard's global-fetch monkey-patch only - * reaches Node's undici, not the koffi-loaded shared library used here. - */ - 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://chatgpt.com", 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 chatgpt.com with a Firefox-like TLS fingerprint. - * - * Throws TlsClientUnavailableError if the native binary failed to load. - */ -export async function tlsFetchChatGpt( +export const tlsFetchChatGpt = ( 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); - } +): Promise => tlsClientModule.tlsFetch(url, options); +export const __tlsFetchStreamingForTesting = tlsClientModule.__tlsFetchStreamingForTesting; - const requestOptions: Record = { - method: options.method || "GET", - headers: options.headers || {}, - body: options.body, - tlsClientIdentifier: CHATGPT_PROFILE, - timeoutMilliseconds: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, - followRedirects: true, - withRandomTLSExtensionOrder: true, - isByteResponse: options.byteResponse === 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. Without this - // line, every chatgpt-web call egresses with the bare host IP regardless - // of dashboard proxy config — see #2022. - proxyUrl: resolveProxyUrl(options.proxyUrl), - }; +export const __setTlsFetchOverrideForTesting = tlsClientModule.__setTlsFetchOverrideForTesting; - if (options.stream) { - return await tlsFetchStreaming( - client, - url, - requestOptions, - options.streamEofSymbol, - options.signal ?? null, - (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS, - STREAM_FIRST_BYTE_TIMEOUT_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; -} - -// ─── 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, - firstByteTimeoutMs: number = STREAM_FIRST_BYTE_TIMEOUT_MS -): Promise { - const dir = await mkdtemp(join(tmpdir(), "cgpt-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, firstByteTimeoutMs, requestPromise); - if (!ready) { - const r = await requestPromise.catch( - (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike - ); - // If the first byte arrived after our first-byte wait but before the - // request settled, tls-client-node may have written the full SSE body to - // streamOutputPath while leaving r.body empty. Prefer those captured bytes - // over misclassifying a successful delayed stream as "empty response body". - const fileText = await readTextFileIfExists(path); - await cleanupTempPath(path); - return { - status: r.status, - headers: toHeaders(r.headers), - text: fileText || 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 - ); - const fileText = await readTextFileIfExists(path); - await cleanupTempPath(path); - return { - status: r.status, - headers: toHeaders(r.headers), - text: r.body || fileText, - 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 readTextFileIfExists(path: string): Promise { - try { - return await readFile(path, "utf8"); - } catch { - return ""; - } -} - -export async function __tlsFetchStreamingForTesting( - 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, - firstByteTimeoutMs: number = STREAM_FIRST_BYTE_TIMEOUT_MS -): Promise { - return tlsFetchStreaming( - client as { request: (url: string, opts: Record) => Promise }, - url, - requestOptions, - eofSymbol, - signal, - hardTimeoutMs, - firstByteTimeoutMs - ); -} - -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)); -} +export { TlsClientHangError, TlsClientUnavailableError } from "./tlsClientBase.ts"; +export type { TlsFetchOptions, TlsFetchResult } from "./tlsClientBase.ts"; +export { looksLikeSse } from "./tlsClientBase.ts"; diff --git a/open-sse/services/claudeTlsClient.ts b/open-sse/services/claudeTlsClient.ts index eb57220da8..4ab4746195 100644 --- a/open-sse/services/claudeTlsClient.ts +++ b/open-sse/services/claudeTlsClient.ts @@ -1,617 +1,49 @@ /** * Browser-TLS-impersonating HTTP client for claude.ai. * - * Why this exists: Claude's Cloudflare config pins `cf_clearance` 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 `cf-mitigated: challenge` — even with all the right - * cookies. This module wraps `tls-client-node` (native shared library - * built from bogdanfinn/tls-client) to send a Chrome handshake instead. - * - * The first call lazily starts the managed sidecar; subsequent calls reuse - * a singleton TLSClient. Process exit hooks stop the sidecar cleanly. + * Thin re-export over the shared `tlsClientBase.ts` factory + * (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle, + * streaming tail-file, proxy resolution, error classes, SSE detection) lives + * in the base module; this file supplies only Claude-specific config and + * preserves the original public export surface. */ -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"; -import { buildNativeTlsClientOptions } from "./tlsClientDownloadDir.ts"; - -let clientPromise: Promise | null = null; -let exitHookInstalled = false; +import { + createTlsClientModule, + type TlsFetchOptions, + type TlsFetchResult, +} from "./tlsClientBase.ts"; export const CLAUDE_TLS_BROWSER_MAJOR_VERSION = "146"; -const CLAUDE_PROFILE = `chrome_${CLAUDE_TLS_BROWSER_MAJOR_VERSION}`; + const DEFAULT_TIMEOUT_MS = Number.parseInt(process.env.OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS || "", 10) || 60_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_CLAUDE_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(); - }); -} +export const tlsClientModule = createTlsClientModule({ + providerName: "Claude", + tlsProfile: `chrome_${CLAUDE_TLS_BROWSER_MAJOR_VERSION}`, + domain: "https://claude.ai", + tempDirPrefix: "cgpt-stream-", + tailFileVariant: "A", + responseValidation: "sse", + exportCloudflareCheck: false, + exposeStreamingForTesting: true, + // Claude waits indefinitely for the first SSE byte (original 2-arg waitForContent). + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + hardTimeoutGraceMs: HARD_TIMEOUT_GRACE_MS, + firstByteTimeoutMs: Number.POSITIVE_INFINITY, +}); -/** - * 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(buildNativeTlsClientOptions()) 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 conversation - * 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; - /** - * If true, instructs the underlying tls-client to return the response body - * as a base64 `data:;base64,...` string (so binary payloads survive - * the JSON marshalling step). Required for image / binary downloads — - * without it, raw bytes get UTF-8-decoded and any non-ASCII byte is - * mangled. Default false (text mode). - */ - byteResponse?: boolean; - /** - * Optional upstream proxy URL (`http://user:pass@host:port` or - * `socks5://...`). When set, the request is tunneled through this proxy - * before reaching claude.ai. Required for hosts whose bare IP is - * flagged by Claude/Cloudflare (Russia, datacenter ranges, etc.) — - * without it, every call leaks the host IP and gets edge-rejected with - * a templated 401 / `Invalid session cookie`. - * - * 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. The dashboard's global-fetch monkey-patch only - * reaches Node's undici, not the koffi-loaded shared library used here. - */ - 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://claude.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 claude.ai with the configured Chrome TLS profile. - * - * Throws TlsClientUnavailableError if the native binary failed to load. - */ -export async function tlsFetchClaude( +export const tlsFetchClaude = ( 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); - } +): Promise => tlsClientModule.tlsFetch(url, options); +export const tlsFetchStreaming = tlsClientModule.__tlsFetchStreamingForTesting; - const requestOptions: Record = { - method: options.method || "GET", - headers: options.headers || {}, - body: options.body, - tlsClientIdentifier: CLAUDE_PROFILE, - timeoutMilliseconds: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, - followRedirects: true, - withRandomTLSExtensionOrder: true, - isByteResponse: options.byteResponse === 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. Without this - // line, every chatgpt-web call egresses with the bare host IP regardless - // of dashboard proxy config — see #2022. - proxyUrl: resolveProxyUrl(options.proxyUrl), - }; +export const __setTlsFetchOverrideForTesting = tlsClientModule.__setTlsFetchOverrideForTesting; - 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; -} - -// ─── 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. - -// Cap for the bounded fallback read of a non-SSE error body straight from the -// streaming temp file (mirrors the 2048-byte cap executors/claude-web.ts -// already applies when reading error bodies) — avoids buffering an unbounded -// error page into memory. See #7134. -const MAX_ERROR_BODY_BYTES = 16 * 1024; - -/** - * Exported for tests (issue #7134): allows injecting a fake `client` so the - * non-SSE error-body fallback path can be exercised without - * `--experimental-test-module-mocks`, matching the DI pattern already used - * by `__setTlsFetchOverrideForTesting` for the outer `tlsFetchClaude`. - */ -export 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(), "cgpt-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). - // Do not impose a second, shorter first-byte timeout here. Opus-class - // models can legitimately take more than five seconds before emitting the - // first SSE event. `requestPromise` is already guarded by the configured - // wire timeout plus the JS hard-timeout grace, so waiting until either the - // file has data or that promise settles remains bounded. - const ready = await waitForContent(path, 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 - ); - // tls-client-node's `streamOutputPath` mode writes the response body to - // the temp file chunk-by-chunk and does NOT also populate the resolved - // response's in-memory `body` field (confirmed against - // node_modules/tls-client-node/dist/response.js) — so for every non-SSE, - // non-2xx claude-web response (400/403/429/500 with a real JSON/HTML - // error), `r.body` is empty even though the real bytes are sitting in - // `path` (we just peeked them above). Prefer `r.body` when it IS - // populated (some native-client modes do fill it in); otherwise fall - // back to a bounded read of the temp file so the real upstream error - // detail reaches the caller instead of being silently discarded. #7134 - const text = r.body || (await readFirstBytes(path, MAX_ERROR_BODY_BYTES).catch(() => "")); - await cleanupTempPath(path); - return { - status: r.status, - headers: toHeaders(r.headers), - text, - 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, - requestPromise: Promise -): Promise { - let requestSettled = false; - requestPromise.then( - () => { - requestSettled = true; - }, - () => { - requestSettled = true; - } - ); - while (true) { - 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); - } -} - -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)); -} +export { TlsClientHangError, TlsClientUnavailableError } from "./tlsClientBase.ts"; +export type { TlsFetchOptions, TlsFetchResult } from "./tlsClientBase.ts"; +export { looksLikeSse } from "./tlsClientBase.ts"; diff --git a/open-sse/services/grokTlsClient.ts b/open-sse/services/grokTlsClient.ts index 685f371080..00a952dd70 100644 --- a/open-sse/services/grokTlsClient.ts +++ b/open-sse/services/grokTlsClient.ts @@ -1,608 +1,41 @@ /** * Browser-TLS-impersonating HTTP client for grok.com. * - * Why this exists: Grok sits behind Cloudflare Enterprise which pins - * `cf_clearance` 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 "Request rejected by anti-bot - * rules." — even with a valid `sso` + `sso-rw` session cookie. This module - * wraps `tls-client-node` (native shared library built from - * bogdanfinn/tls-client) to send a Chrome handshake instead. - * - * Mirrors `perplexityTlsClient.ts`; kept as an independent module so changes - * here cannot regress the production chatgpt-web / perplexity-web paths. - * The first call lazily starts the managed sidecar; subsequent calls reuse - * a singleton TLSClient. Process exit hooks stop the sidecar cleanly. - * - * Issue: #3180 + * Thin re-export over the shared `tlsClientBase.ts` factory + * (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle, + * streaming tail-file, proxy resolution, error classes, Cloudflare challenge + * detection) lives in the base module; this file supplies only Grok-specific + * config and preserves the original public export surface. */ -import { tmpdir } from "node:os"; -import { join, dirname } from "node:path"; -import { mkdtemp, open, unlink, rmdir, stat } from "node:fs/promises"; -import { randomUUID } from "node:crypto"; -import { buildNativeTlsClientOptions } from "./tlsClientDownloadDir.ts"; +import { + createTlsClientModule, + type TlsFetchOptions, + type TlsFetchResult, +} from "./tlsClientBase.ts"; -let clientPromise: Promise | null = null; -let exitHookInstalled = false; - -const GROK_PROFILE = "chrome_146"; // closest supported wreq-js profile (chrome_149 absent in 2.3.1, #5591) const DEFAULT_TIMEOUT_MS = Number.parseInt(process.env.OMNIROUTE_GROK_TLS_TIMEOUT_MS || "", 10) || 60_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_GROK_TLS_GRACE_MS || "", 10) || 10_000; -function installExitHook(): void { - if (exitHookInstalled) return; - exitHookInstalled = true; - const stop = async () => { - if (clientPromise === null) 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(); - }); -} +export const tlsClientModule = createTlsClientModule({ + providerName: "Grok", + tlsProfile: "chrome_146", + domain: "https://grok.com", + tempDirPrefix: "grok-stream-", + tailFileVariant: "B1", + responseValidation: "cf", + exportCloudflareCheck: true, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + hardTimeoutGraceMs: HARD_TIMEOUT_GRACE_MS, +}); -/** - * 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 const tlsFetchGrok = (url: string, options: TlsFetchOptions = {}): Promise => + tlsClientModule.tlsFetch(url, options); -export class TlsClientHangError extends Error { - constructor(message: string) { - super(message); - this.name = "TlsClientHangError"; - } -} +export const __setTlsFetchOverrideForTesting = tlsClientModule.__setTlsFetchOverrideForTesting; -/** - * 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(buildNativeTlsClientOptions()) 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 NDJSON streaming responses (the - * Grok conversation 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 grok.com. - * - * 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://grok.com", 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 grok.com with a Chrome-like TLS fingerprint. - * - * Throws TlsClientUnavailableError if the native binary failed to load. - */ -export async function tlsFetchGrok( - 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: GROK_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 Grok response. From VPS/datacenter IPs a valid cookie - * still gets a 403 "Request rejected by anti-bot rules." JSON; distinguishing - * it from a genuine auth failure lets the caller surface an actionable error - * (issue #3180). - * - * 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(), "grok-stream-")); - const path = join(dir, `${randomUUID()}.ndjson`); - - 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. - 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 at the first bytes to distinguish a genuine NDJSON stream from a - // Cloudflare challenge page or an HTML error response that tls-client-node - // streamed to the temp file with a 200 status. - const peek = await readFirstBytes(path, 256); - if (isCloudflareChallenge(peek)) { - await cleanupTempPath(path); - return { - status: 403, - headers: new Headers({ "Content-Type": "text/html" }), - text: peek, - body: null, - }; - } - if (peek.trimStart().startsWith("<")) { - // HTML error page (not a challenge) — surface as a non-2xx so the executor - // can emit a proper SSE error chunk instead of feeding HTML to the NDJSON - // parser. - await cleanupTempPath(path); - return { - status: 502, - headers: new Headers({ "Content-Type": "text/html" }), - text: peek, - body: null, - }; - } - - // Looks like NDJSON — start tailing. The requestPromise will eventually - // resolve with the real upstream status; tailFile propagates non-2xx errors - // into the stream so the consumer sees them instead of a truncated success. - const stream = tailFile(path, eofSymbol, requestPromise, signal); - const headers = new Headers({ - "Content-Type": "application/x-ndjson", - "Cache-Control": "no-cache", - }); - return { status: 200, headers, text: null, body: stream }; -} - -async function cleanupTempPath(path: string): Promise { - await unlink(path).catch(() => {}); - await rmdir(dirname(path)).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 NDJSON - * 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"); - - // Check for EOF symbol in the chunk. - if (text.includes(eofSymbol)) { - const beforeEof = text.substring(0, text.indexOf(eofSymbol)); - if (beforeEof) { - controller.enqueue(Buffer.from(beforeEof, "utf8")); - } - controller.close(); - return; - } - - controller.enqueue(Buffer.from(chunk)); - } - - if (finished) { - // Request finished — read any remaining bytes then close. - while (true) { - const { bytesRead } = await fd.read(buf, 0, buf.length, offset); - if (bytesRead === 0) break; - const chunk = buf.subarray(0, bytesRead); - offset += bytesRead; - const text = chunk.toString("utf8"); - - if (text.includes(eofSymbol)) { - const beforeEof = text.substring(0, text.indexOf(eofSymbol)); - if (beforeEof) { - controller.enqueue(Buffer.from(beforeEof, "utf8")); - } - controller.close(); - return; - } - - controller.enqueue(Buffer.from(chunk)); - } - - if (upstreamError && !errored) { - errored = true; - controller.error(upstreamError); - return; - } - - controller.close(); - return; - } - - // No data yet and request still running — brief pause before retry. - await sleep(25); - } - } catch (err) { - if (!errored) { - errored = true; - controller.error(err instanceof Error ? err : new Error(String(err))); - } - } finally { - await fd.close().catch(() => {}); - await cleanupTempPath(path); - if (signal) signal.removeEventListener("abort", onAbort); - } - }, - }); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +export { TlsClientHangError, TlsClientUnavailableError } from "./tlsClientBase.ts"; +export type { TlsFetchOptions, TlsFetchResult } from "./tlsClientBase.ts"; +export { isCloudflareChallenge } from "./tlsClientBase.ts"; diff --git a/open-sse/services/lmarenaTlsClient.ts b/open-sse/services/lmarenaTlsClient.ts index 496579606e..131acb550e 100644 --- a/open-sse/services/lmarenaTlsClient.ts +++ b/open-sse/services/lmarenaTlsClient.ts @@ -1,606 +1,43 @@ /** * Browser-TLS-impersonating HTTP client for arena.ai. * - * Why this exists: LMArena sits behind Cloudflare Enterprise which pins - * `cf_clearance` 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 even with a valid arena session - * cookie (and often a browser-minted `cf_clearance`). This module wraps - * `tls-client-node` (bogdanfinn/tls-client) to send a Chrome handshake instead. - * - * Mirrors `grokTlsClient.ts` / `perplexityTlsClient.ts` as an independent - * module so changes here cannot regress those production paths. - * - * Note: Arena may still require a browser-issued reCAPTCHA v3 token on - * create-evaluation; TLS alone is necessary but not always sufficient. + * Thin re-export over the shared `tlsClientBase.ts` factory + * (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle, + * streaming tail-file, proxy resolution, error classes, Cloudflare challenge + * detection) lives in the base module; this file supplies only LMArena-specific + * config and preserves the original public export surface. */ -import { tmpdir } from "node:os"; -import { join, dirname } from "node:path"; -import { mkdtemp, open, unlink, rmdir, stat } from "node:fs/promises"; -import { randomUUID } from "node:crypto"; -import { buildNativeTlsClientOptions } from "./tlsClientDownloadDir.ts"; +import { + createTlsClientModule, + type TlsFetchOptions, + type TlsFetchResult, +} from "./tlsClientBase.ts"; -let clientPromise: Promise | null = null; -let exitHookInstalled = false; - -// Newest Chrome JA3 profile shipped by tls-client-node (no chrome_147+ yet). -// HTTP User-Agent / Sec-Ch-Ua track Chrome 150 separately in models.ts. -const LMARENA_PROFILE = "chrome_146"; -// Fixed timeouts (same defaults as other TLS sidecars). No extra env knobs — -// env-doc-sync must not grow for provider-local constants. const DEFAULT_TIMEOUT_MS = 60_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). const HARD_TIMEOUT_GRACE_MS = 10_000; -function installExitHook(): void { - if (exitHookInstalled) return; - exitHookInstalled = true; - const stop = async () => { - if (clientPromise === null) 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(); - }); -} +export const tlsClientModule = createTlsClientModule({ + providerName: "LMArena", + tlsProfile: "chrome_146", + domain: "https://lmarena.ai", + // LMArena's proxy resolution domain is hardcoded to arena.ai, not the config domain. + proxyDomainOverride: "https://arena.ai", + tempDirPrefix: "LMArena-stream-", + tailFileVariant: "B2", + responseValidation: "cf", + exportCloudflareCheck: true, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + hardTimeoutGraceMs: HARD_TIMEOUT_GRACE_MS, +}); -/** - * 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(buildNativeTlsClientOptions()) 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 NDJSON streaming responses (the - * LMArena conversation 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 arena.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://arena.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; -} - -function throwIfAborted(signal: AbortSignal | null | undefined): void { - if (signal?.aborted) throw makeAbortError(signal); -} - -function buildTlsRequestOptions(options: TlsFetchOptions): Record { - return { - method: options.method || "GET", - headers: options.headers || {}, - body: options.body, - tlsClientIdentifier: LMARENA_PROFILE, - timeoutMilliseconds: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, - followRedirects: true, - withRandomTLSExtensionOrder: true, - // Plumb proxy via options — tls-client-node does not read HTTP_PROXY env. - proxyUrl: resolveProxyUrl(options.proxyUrl), - }; -} - -function hardTimeoutMs(options: TlsFetchOptions): number { - return (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS; -} - -async function tlsFetchNonStreaming( - client: { request: (url: string, opts: Record) => Promise }, - url: string, - requestOptions: Record, - options: TlsFetchOptions -): Promise { - let tlsResponse: TlsResponseLike; - try { - tlsResponse = await raceWithTimeout( - client.request(url, requestOptions), - hardTimeoutMs(options), - options.signal ?? null - ); - } catch (err) { - if (err instanceof TlsClientHangError) resetClientCache(); - throw err; - } - throwIfAborted(options.signal); - return { - status: tlsResponse.status, - headers: toHeaders(tlsResponse.headers), - text: tlsResponse.body, - body: null, - }; -} - -/** - * Make a single HTTP request to arena.ai with a Chrome-like TLS fingerprint. - * Throws TlsClientUnavailableError if the native binary failed to load. - */ -export async function tlsFetchLMArena( +export const tlsFetchLMArena = ( url: string, options: TlsFetchOptions = {} -): Promise { - if (testOverride) return testOverride(url, options); - throwIfAborted(options.signal); - const client = await getClient(); - throwIfAborted(options.signal); +): Promise => tlsClientModule.tlsFetch(url, options); - const requestOptions = buildTlsRequestOptions(options); - if (options.stream) { - return tlsFetchStreaming( - client, - url, - requestOptions, - options.streamEofSymbol, - options.signal ?? null, - hardTimeoutMs(options) - ); - } - return tlsFetchNonStreaming(client, url, requestOptions, options); -} +export const __setTlsFetchOverrideForTesting = tlsClientModule.__setTlsFetchOverrideForTesting; -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 LMArena response. From VPS/datacenter IPs a valid cookie - * still gets a 403 "Request rejected by anti-bot rules." JSON; distinguishing - * it from a genuine auth failure lets the caller surface an actionable error - * (issue #3180). - * - * 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(), "LMArena-stream-")); - const path = join(dir, `${randomUUID()}.ndjson`); - - 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. - 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 at the first bytes to distinguish a genuine NDJSON stream from a - // Cloudflare challenge page or an HTML error response that tls-client-node - // streamed to the temp file with a 200 status. - const peek = await readFirstBytes(path, 256); - if (isCloudflareChallenge(peek)) { - await cleanupTempPath(path); - return { - status: 403, - headers: new Headers({ "Content-Type": "text/html" }), - text: peek, - body: null, - }; - } - if (peek.trimStart().startsWith("<")) { - // HTML error page (not a challenge) — surface as a non-2xx so the executor - // can emit a proper SSE error chunk instead of feeding HTML to the NDJSON - // parser. - await cleanupTempPath(path); - return { - status: 502, - headers: new Headers({ "Content-Type": "text/html" }), - text: peek, - body: null, - }; - } - - // Looks like NDJSON — start tailing. The requestPromise will eventually - // resolve with the real upstream status; tailFile propagates non-2xx errors - // into the stream so the consumer sees them instead of a truncated success. - const stream = tailFile(path, eofSymbol, requestPromise, signal); - const headers = new Headers({ - "Content-Type": "application/x-ndjson", - "Cache-Control": "no-cache", - }); - return { status: 200, headers, text: null, body: stream }; -} - -async function cleanupTempPath(path: string): Promise { - await unlink(path).catch(() => {}); - await rmdir(dirname(path)).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 NDJSON - * 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; -} - -/** Enqueue chunk bytes, splitting off an EOF symbol when present. Returns true if closed. */ -function enqueueChunkMaybeEof( - controller: ReadableStreamDefaultController, - chunk: Buffer, - eofSymbol: string -): boolean { - const text = chunk.toString("utf8"); - if (!text.includes(eofSymbol)) { - controller.enqueue(Buffer.from(chunk)); - return false; - } - const beforeEof = text.substring(0, text.indexOf(eofSymbol)); - if (beforeEof) controller.enqueue(Buffer.from(beforeEof, "utf8")); - controller.close(); - return true; -} - -type FileHandle = Awaited>; - -async function drainRemaining( - fd: FileHandle, - buf: Buffer, - offsetRef: { offset: number }, - controller: ReadableStreamDefaultController, - eofSymbol: string -): Promise<"closed" | "drained"> { - while (true) { - const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset); - if (bytesRead === 0) return "drained"; - const chunk = buf.subarray(0, bytesRead); - offsetRef.offset += bytesRead; - if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return "closed"; - } -} - -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); - const offsetRef = { offset: 0 }; - let finished = false; - let aborted = false; - let upstreamError: Error | null = null; - let errored = false; - - done.then( - () => { - finished = true; - }, - (err) => { - upstreamError = err instanceof Error ? err : new Error(String(err)); - finished = true; - } - ); - - const onAbort = () => { - aborted = true; - }; - if (signal) { - if (signal.aborted) aborted = true; - else signal.addEventListener("abort", onAbort, { once: true }); - } - - try { - while (!aborted) { - const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset); - if (bytesRead > 0) { - const chunk = buf.subarray(0, bytesRead); - offsetRef.offset += bytesRead; - if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return; - } - - if (!finished) { - await sleep(25); - continue; - } - - const drained = await drainRemaining(fd, buf, offsetRef, controller, eofSymbol); - if (drained === "closed") return; - if (upstreamError && !errored) { - errored = true; - controller.error(upstreamError); - return; - } - controller.close(); - return; - } - } catch (err) { - if (!errored) { - errored = true; - controller.error(err instanceof Error ? err : new Error(String(err))); - } - } finally { - await fd.close().catch(() => {}); - await cleanupTempPath(path); - if (signal) signal.removeEventListener("abort", onAbort); - } - }, - }); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +export { TlsClientHangError, TlsClientUnavailableError } from "./tlsClientBase.ts"; +export type { TlsFetchOptions, TlsFetchResult } from "./tlsClientBase.ts"; +export { isCloudflareChallenge } from "./tlsClientBase.ts"; diff --git a/open-sse/services/notionTlsClient.ts b/open-sse/services/notionTlsClient.ts index a11a676b1f..2dc56e5f35 100644 --- a/open-sse/services/notionTlsClient.ts +++ b/open-sse/services/notionTlsClient.ts @@ -1,594 +1,43 @@ /** * Browser-TLS-impersonating HTTP client for app.notion.com. * - * Why this exists: Notion AI 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 `claudeTlsClient.ts` / `perplexityTlsClient.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. + * Thin re-export over the shared `tlsClientBase.ts` factory + * (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle, + * streaming tail-file, proxy resolution, error classes, SSE detection, + * Cloudflare challenge detection) lives in the base module; this file supplies + * only Notion-specific config and preserves the original public export surface. */ -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"; -import { buildNativeTlsClientOptions } from "./tlsClientDownloadDir.ts"; +import { + createTlsClientModule, + type TlsFetchOptions, + type TlsFetchResult, +} from "./tlsClientBase.ts"; -let clientPromise: Promise | null = null; -let exitHookInstalled = false; - -const NOTION_PROFILE = "chrome_146"; // matches the Chrome UA we send const DEFAULT_TIMEOUT_MS = Number.parseInt(process.env.OMNIROUTE_NOTION_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_NOTION_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(); - }); -} +export const tlsClientModule = createTlsClientModule({ + providerName: "Notion", + tlsProfile: "chrome_146", + domain: "https://app.notion.com", + tempDirPrefix: "pplx-stream-", + tailFileVariant: "A", + responseValidation: "sse", + exportCloudflareCheck: true, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + hardTimeoutGraceMs: HARD_TIMEOUT_GRACE_MS, +}); -/** - * 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(buildNativeTlsClientOptions()) 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 runInferenceTranscript - * 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 notion.so. - * - * 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://app.notion.com", 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 notion.so with a Chrome-like TLS fingerprint. - * - * Throws TlsClientUnavailableError if the native binary failed to load. - */ -export async function tlsFetchNotion( +export const tlsFetchNotion = ( 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); - } +): Promise => tlsClientModule.tlsFetch(url, options); - const requestOptions: Record = { - method: options.method || "GET", - headers: options.headers || {}, - body: options.body, - tlsClientIdentifier: NOTION_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), - }; +export const __setTlsFetchOverrideForTesting = tlsClientModule.__setTlsFetchOverrideForTesting; - 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)); -} +export { TlsClientHangError, TlsClientUnavailableError } from "./tlsClientBase.ts"; +export type { TlsFetchOptions, TlsFetchResult } from "./tlsClientBase.ts"; +export { looksLikeSse, isCloudflareChallenge } from "./tlsClientBase.ts"; diff --git a/open-sse/services/perplexityTlsClient.ts b/open-sse/services/perplexityTlsClient.ts index 081ccb090a..bc736476c3 100644 --- a/open-sse/services/perplexityTlsClient.ts +++ b/open-sse/services/perplexityTlsClient.ts @@ -1,594 +1,44 @@ /** * 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. + * Thin re-export over the shared `tlsClientBase.ts` factory + * (`createTlsClientModule`). All provider-agnostic logic (sidecar lifecycle, + * streaming tail-file, proxy resolution, error classes, SSE detection, + * Cloudflare challenge detection) lives in the base module; this file supplies + * only Perplexity-specific config and preserves the original public export + * surface. */ -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"; -import { buildNativeTlsClientOptions } from "./tlsClientDownloadDir.ts"; +import { + createTlsClientModule, + type TlsFetchOptions, + type TlsFetchResult, +} from "./tlsClientBase.ts"; -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(); - }); -} +export const tlsClientModule = createTlsClientModule({ + providerName: "Perplexity", + tlsProfile: "firefox_148", + domain: "https://www.perplexity.ai", + tempDirPrefix: "pplx-stream-", + tailFileVariant: "A", + responseValidation: "sse", + exportCloudflareCheck: true, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + hardTimeoutGraceMs: HARD_TIMEOUT_GRACE_MS, +}); -/** - * 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(buildNativeTlsClientOptions()) 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( +export const 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); - } +): Promise => tlsClientModule.tlsFetch(url, options); - 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), - }; +export const __setTlsFetchOverrideForTesting = tlsClientModule.__setTlsFetchOverrideForTesting; - 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)); -} +export { TlsClientHangError, TlsClientUnavailableError } from "./tlsClientBase.ts"; +export type { TlsFetchOptions, TlsFetchResult } from "./tlsClientBase.ts"; +export { looksLikeSse, isCloudflareChallenge } from "./tlsClientBase.ts"; diff --git a/open-sse/services/tlsClientBase.ts b/open-sse/services/tlsClientBase.ts new file mode 100644 index 0000000000..11249864f2 --- /dev/null +++ b/open-sse/services/tlsClientBase.ts @@ -0,0 +1,958 @@ +/** + * Shared TLS client infrastructure — a factory-style base that consolidates + * 6 nearly-identical per-provider TLS client files into one source of truth. + * + * Each provider file calls `createTlsClientModule(config)` to obtain its + * provider-specific `tlsFetch` and `__setTlsFetchOverrideForTesting` exports. + * + * TailFile variants: + * A — Uint8Array enqueue, includes EOF symbol, substring-based cleanup + * ChatGPT, Claude, Perplexity, Notion + * B1 — Buffer.from enqueue, excludes EOF symbol, inline drainRemaining loop + * Grok + * B2 — Buffer.from enqueue, excludes EOF symbol, extracted helpers + * LMArena + * + * Response validation: + * sse — checks `looksLikeSse(peek)`, falls back to buffered + * ChatGPT, Claude, Perplexity, Notion + * cf — checks `isCloudflareChallenge(peek)` → 403, HTML → 502 + * Grok, LMArena + */ + +// --------------------------------------------------------------------------- +// Node imports +// --------------------------------------------------------------------------- +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { join, dirname } from "node:path"; +import { open, unlink, rmdir, readFile, mkdtemp, stat } from "node:fs/promises"; + +// --------------------------------------------------------------------------- +// Proxy resolution — every provider file imports both of these +// --------------------------------------------------------------------------- +import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; +import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; +import { buildNativeTlsClientOptions } from "./tlsClientDownloadDir.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface TlsResponseLike { + status: number; + headers: Record; + body: string; +} + +export interface TlsFetchResult { + status: number; + headers: Headers; + text: string | null; + body: ReadableStream | null; +} + +export interface TlsFetchOptions { + method?: string; + headers?: Record; + body?: string; + signal?: AbortSignal; + timeoutMs?: number; + stream?: boolean; + streamEofSymbol?: string; + byteResponse?: boolean; + proxyUrl?: string; +} + +// --------------------------------------------------------------------------- +// Factory config (one instance per provider stub) +// --------------------------------------------------------------------------- + +export interface TlsClientConfig { + /** Human-readable provider name for logs and error messages. */ + providerName: string; + /** TLS profile identifier (e.g. "chrome_146") */ + tlsProfile: string; + /** Default upstream domain for proxy resolution (e.g. "https://chatgpt.com") */ + domain: string; + /** Temp directory prefix (e.g. "cgpt-stream-") */ + tempDirPrefix: string; + /** EOF symbol for streaming (default "[DONE]") */ + streamEofSymbol?: string; + /** Default timeout in ms (default 60_000) */ + defaultTimeoutMs?: number; + /** Hard timeout grace period in ms (default 10_000) */ + hardTimeoutGraceMs?: number; + /** First-byte timeout for waitForContent (default 5_000; ChatGPT uses 30_000) */ + firstByteTimeoutMs?: number; + /** + * TailFile variant: + * "A" — Uint8Array enqueue, includes EOF, substring cleanup + * "B1" — Buffer.from enqueue, excludes EOF, inline drainRemaining + * "B2" — Buffer.from enqueue, excludes EOF, extracted helpers + */ + tailFileVariant: "A" | "B1" | "B2"; + /** + * Response validation mode: + * "sse" — check looksLikeSse → fall back to buffered + * "cf" — check isCloudflareChallenge → 403, HTML → 502, else stream + */ + responseValidation: "sse" | "cf"; + /** + * Optional override for proxy resolution domain (e.g., LMArena uses + * "https://arena.ai" hardcoded instead of the config domain). + */ + proxyDomainOverride?: string; + /** + * Whether to export `isCloudflareChallenge` from the provider stub. + * Grok, LMArena, Perplexity, Notion all export it. + */ + exportCloudflareCheck: boolean; + /** + * Whether to expose `__tlsFetchStreamingForTesting` (ChatGPT only). + */ + exposeStreamingForTesting?: boolean; +} + +// --------------------------------------------------------------------------- +// Error classes +// --------------------------------------------------------------------------- + +export class TlsClientUnavailableError extends Error { + override name = "TlsClientUnavailableError"; +} + +export class TlsClientHangError extends Error { + override name = "TlsClientHangError"; +} + +// --------------------------------------------------------------------------- +// Shared helpers (identical across all 6 providers) +// --------------------------------------------------------------------------- + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export 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; +} + +export function toHeaders(raw: Record | null | undefined): Headers { + const h = new Headers(); + for (const [k, vs] of Object.entries(raw || {})) { + for (const v of vs) h.append(k, v); + } + return h; +} + +export async function raceWithTimeout( + promise: Promise, + timeoutMs: number, + signal: AbortSignal | null | undefined +): Promise { + // If no signal, just race with a simple timeout. + if (!signal) { + return await Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout(() => reject(new TlsClientHangError()), timeoutMs); + }), + ]); + } + + // With signal, race against both timeout and abort. + return await new Promise((resolve, reject) => { + let settled = false; + + const done = (fn: () => void) => { + if (!settled) { + settled = true; + fn(); + } + }; + + const timer = setTimeout(() => { + done(() => reject(new TlsClientHangError())); + }, timeoutMs); + + const onAbort = () => { + done(() => reject(makeAbortError(signal))); + }; + + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + + promise.then( + (v) => { + done(() => { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + resolve(v); + }); + }, + (e) => { + done(() => { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + reject(e); + }); + } + ); + }); +} + +/** Read up to N bytes from a file, returning the utf-8 decoded text. */ +export 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. + */ +export 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 (requestSettled) return false; + await sleep(25); + } + return false; +} + +/** + * 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 (`:`). + */ +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); +} + +/** + * Returns true if the response body is a Cloudflare challenge/interstitial page. + */ +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 + ); +} + +// --------------------------------------------------------------------------- +// Temp-path cleanup — two variants +// --------------------------------------------------------------------------- + +/** Variant A: substring-based parent dir extraction (ChatGPT, Claude, Perplexity, Notion) */ +async function cleanupTempPathSubstring(path: string): Promise { + await unlink(path).catch(() => {}); + const dir = path.substring(0, path.lastIndexOf("/")); + await rmdir(dir).catch(() => {}); +} + +/** Variant B: dirname-based parent dir extraction (Grok, LMArena) */ +async function cleanupTempPathDirname(path: string): Promise { + await unlink(path).catch(() => {}); + await rmdir(dirname(path)).catch(() => {}); +} + +async function readTextFileIfExists(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch { + return ""; + } +} + +// --------------------------------------------------------------------------- +// TailFile — Variant A +// Uint8Array enqueue, includes EOF symbol, substring cleanup +// Used by: ChatGPT, Claude, Perplexity, Notion +// --------------------------------------------------------------------------- + +function tailFileVariantA( + path: string, + eofSymbol: string, + done: Promise, + signal: AbortSignal | null = null, + cleanupPath: string +): 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; + + done.then( + () => { + finished = true; + }, + (err) => { + upstreamError = err instanceof Error ? err : new Error(String(err)); + finished = true; + } + ); + + 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) { + 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 cleanupTempPathSubstring(cleanupPath); + if (!errored) controller.close(); + } + }, + }); +} + +// --------------------------------------------------------------------------- +// TailFile — Variant B1 +// Buffer.from enqueue, excludes EOF symbol, inline drainRemaining loop +// Used by: Grok +// --------------------------------------------------------------------------- + +function tailFileVariantB1( + path: string, + eofSymbol: string, + done: Promise, + signal: AbortSignal | null = null, + cleanupPath: string +): 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; + + done.then( + () => { + finished = true; + }, + (err) => { + upstreamError = err instanceof Error ? err : new Error(String(err)); + finished = true; + } + ); + + 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 beforeEof = text.substring(0, text.indexOf(eofSymbol)); + if (beforeEof) { + controller.enqueue(Buffer.from(beforeEof, "utf8")); + } + controller.close(); + return; + } + + controller.enqueue(Buffer.from(chunk)); + } + + if (finished) { + // Request finished — drain any remaining bytes then close. + while (true) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offset); + if (bytesRead === 0) break; + const chunk = buf.subarray(0, bytesRead); + offset += bytesRead; + const text = chunk.toString("utf8"); + + if (text.includes(eofSymbol)) { + const beforeEof = text.substring(0, text.indexOf(eofSymbol)); + if (beforeEof) { + controller.enqueue(Buffer.from(beforeEof, "utf8")); + } + controller.close(); + return; + } + + controller.enqueue(Buffer.from(chunk)); + } + + if (upstreamError && !errored) { + errored = true; + controller.error(upstreamError); + return; + } + + controller.close(); + return; + } + + await sleep(25); + } + } catch (err) { + if (!errored) { + errored = true; + controller.error(err instanceof Error ? err : new Error(String(err))); + } + } finally { + await fd.close().catch(() => {}); + await cleanupTempPathDirname(cleanupPath); + if (signal) signal.removeEventListener("abort", onAbort); + } + }, + }); +} + +// --------------------------------------------------------------------------- +// TailFile — Variant B2 +// Buffer.from enqueue, excludes EOF symbol, extracted helpers +// Used by: LMArena +// --------------------------------------------------------------------------- + +type FileHandle = Awaited>; + +function enqueueChunkMaybeEof( + controller: ReadableStreamDefaultController, + chunk: Buffer, + eofSymbol: string +): boolean { + const text = chunk.toString("utf8"); + if (!text.includes(eofSymbol)) { + controller.enqueue(Buffer.from(chunk)); + return false; + } + const beforeEof = text.substring(0, text.indexOf(eofSymbol)); + if (beforeEof) controller.enqueue(Buffer.from(beforeEof, "utf8")); + controller.close(); + return true; +} + +async function drainRemaining( + fd: FileHandle, + buf: Buffer, + offsetRef: { offset: number }, + controller: ReadableStreamDefaultController, + eofSymbol: string +): Promise<"closed" | "drained"> { + while (true) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset); + if (bytesRead === 0) return "drained"; + const chunk = buf.subarray(0, bytesRead); + offsetRef.offset += bytesRead; + if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return "closed"; + } +} + +function tailFileVariantB2( + path: string, + eofSymbol: string, + done: Promise, + signal: AbortSignal | null = null, + cleanupPath: string +): ReadableStream { + return new ReadableStream({ + async start(controller) { + const fd = await open(path, "r"); + const buf = Buffer.alloc(64 * 1024); + const offsetRef = { offset: 0 }; + let finished = false; + let aborted = false; + let upstreamError: Error | null = null; + let errored = false; + + done.then( + () => { + finished = true; + }, + (err) => { + upstreamError = err instanceof Error ? err : new Error(String(err)); + finished = true; + } + ); + + const onAbort = () => { + aborted = true; + }; + if (signal) { + if (signal.aborted) aborted = true; + else signal.addEventListener("abort", onAbort, { once: true }); + } + + try { + while (!aborted) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset); + if (bytesRead > 0) { + const chunk = buf.subarray(0, bytesRead); + offsetRef.offset += bytesRead; + if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return; + } + + if (!finished) { + await sleep(25); + continue; + } + + const drained = await drainRemaining(fd, buf, offsetRef, controller, eofSymbol); + if (drained === "closed") return; + if (upstreamError && !errored) { + errored = true; + controller.error(upstreamError); + return; + } + controller.close(); + return; + } + } catch (err) { + if (!errored) { + errored = true; + controller.error(err instanceof Error ? err : new Error(String(err))); + } + } finally { + await fd.close().catch(() => {}); + await cleanupTempPathDirname(cleanupPath); + if (signal) signal.removeEventListener("abort", onAbort); + } + }, + }); +} + +// --------------------------------------------------------------------------- +// Client lifecycle — TLS client singleton per provider +// --------------------------------------------------------------------------- + +/** + * Create a getClient function for a provider stub. + * Uses dynamic `import("tls-client-node")` with `{ runtimeMode: "native" }` + * and `client.start()`, matching the original per-provider lifecycle. + */ +export function createGetClient(config: { + providerName: string; + tlsProfile?: string; +}): () => Promise<{ + request: (url: string, opts: Record) => Promise; +}> { + let clientPromise: Promise<{ + request: (url: string, opts: Record) => Promise; + }> | null = null; + let exitHookInstalled = false; + + const installExitHook = (client: { stop: () => Promise }): void => { + if (!exitHookInstalled) { + exitHookInstalled = true; + process.on("exit", () => { + void client.stop(); + }); + } + }; + + return async function getClient(): Promise<{ + request: (url: string, opts: Record) => Promise; + }> { + if (!clientPromise) { + clientPromise = (async () => { + let TLSClientCtor: { + new (config: Record): { + start: () => Promise; + request: (url: string, opts: Record) => Promise; + stop: () => Promise; + }; + }; + try { + // tls-client-node uses a native binary loaded at runtime. + // The dynamic import delays the binary load until first use — no + // point crashing startup on machines where it's not installed. + const mod = await import("tls-client-node"); + TLSClientCtor = mod.TLSClient; + } catch { + throw new TlsClientUnavailableError( + `tls-client-node is not installed — cannot start TLS client for ${config.providerName}` + ); + } + const tlsOptions: Record = { + ...buildNativeTlsClientOptions(), + }; + if (config.tlsProfile) { + tlsOptions.clientIdentifier = config.tlsProfile; + } + const client = new TLSClientCtor(tlsOptions); + // Start the native TLS client binding + await client.start(); + installExitHook(client); + + return client; + })(); + } + return clientPromise; + }; +} + +/** + * Resolve the proxy URL for a tls-client request. Per-call value wins; + * falls back to the provider-specific env var and the dashboard proxy config. + */ +export function resolveProxyUrl(domain: string, perCall: string | undefined): string | undefined { + return resolveTlsClientProxyUrl(domain, perCall, resolveProxyForRequest); +} + +// --------------------------------------------------------------------------- +// Factory — creates provider-specific tlsFetch + helpers +// --------------------------------------------------------------------------- + +const CLEANUP_VARIANTS = { + A: cleanupTempPathSubstring, + B: cleanupTempPathDirname, +} as const; + +const TAIL_FILE_VARIANTS = { + A: tailFileVariantA, + B1: tailFileVariantB1, + B2: tailFileVariantB2, +} as const; + +export interface TlsClientModule { + tlsFetch: (url: string, options: TlsFetchOptions) => Promise; + __setTlsFetchOverrideForTesting: ( + fn: ((url: string, options: TlsFetchOptions) => Promise) | null + ) => void; + isCloudflareChallenge?: (text: string | null | undefined) => boolean; + __tlsFetchStreamingForTesting?: ( + client: { request: (url: string, opts: Record) => Promise }, + url: string, + requestOptions: Record, + eofSymbol?: string, + signal?: AbortSignal | null, + hardTimeoutMs?: number, + firstByteTimeoutMs?: number + ) => Promise; +} + +/** + * Create a provider-specific TLS client module. + * + * Each provider file calls this once at module level and re-exports + * the returned `tlsFetch` (as e.g. `tlsFetchChatGpt`) and + * `__setTlsFetchOverrideForTesting`. + */ +export function createTlsClientModule(config: TlsClientConfig): TlsClientModule { + const { + providerName, + tlsProfile, + domain, + tempDirPrefix, + streamEofSymbol = "[DONE]", + defaultTimeoutMs = 60_000, + hardTimeoutGraceMs = 10_000, + firstByteTimeoutMs = 5_000, + tailFileVariant, + responseValidation, + proxyDomainOverride, + exportCloudflareCheck, + } = config; + + const getClient = createGetClient({ providerName, tlsProfile }); + + function resetClientCache(): void { + // The getClient closure holds clientPromise — by design the only + // reference is inside getClient's closure. After a hang we need + // the next call to spawn a fresh binding. We achieve this by + // clearing the local reference; the module-level tlsFetch will + // re-read via getClient which recreates it. + // Since getClient's clientPromise is a closure variable, we + // re-create getClient itself: + Object.assign(localState, { + getClient: createGetClient({ providerName, tlsProfile }), + }); + // Note: this is safe because only tlsFetch calls getClient. + // A concurrent in-flight call holds its own reference. + } + + const localState: { getClient: typeof getClient } = { getClient }; + + let testOverride: ((url: string, options: TlsFetchOptions) => Promise) | null = + null; + + const tailFileFn = TAIL_FILE_VARIANTS[tailFileVariant]; + + const cleanupFn = tailFileVariant === "A" ? cleanupTempPathSubstring : cleanupTempPathDirname; + + async function tlsFetchStreaming( + client: { request: (url: string, opts: Record) => Promise }, + url: string, + requestOptions: Record, + eofSymbol: string, + signal: AbortSignal | null, + hardTimeoutMs: number, + firstByteMs: number = firstByteTimeoutMs + ): Promise { + const dir = await mkdtemp(join(tmpdir(), tempDirPrefix)); + const path = join(dir, `${randomUUID()}.sse`); + + const streamOpts: Record = { + ...requestOptions, + streamOutputPath: path, + streamOutputBlockSize: 1024, + streamOutputEOFSymbol: eofSymbol, + }; + + let resetOnHang = true; + const requestPromise = raceWithTimeout( + client.request(url, streamOpts), + hardTimeoutMs, + signal + ).catch((err: unknown) => { + if (resetOnHang && err instanceof TlsClientHangError) { + resetClientCache(); + resetOnHang = false; + } + throw err; + }); + + // Wait for the file to exist AND have at least one byte. + const ready = await waitForContent(path, firstByteMs, requestPromise); + if (!ready) { + const r = await requestPromise.catch( + (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike + ); + const fileText = await readTextFileIfExists(path); + await cleanupFn(path); + return { + status: r.status, + headers: toHeaders(r.headers), + text: r.body || fileText, + body: null, + }; + } + + const peek = await readFirstBytes(path, 256); + + if (responseValidation === "cf") { + // Cloudflare challenge check + if (isCloudflareChallenge(peek)) { + await cleanupFn(path); + return { + status: 403, + headers: new Headers({ "Content-Type": "text/html" }), + text: peek, + body: null, + }; + } + // HTML error page check + if (peek.trimStart().startsWith("<")) { + await cleanupFn(path); + return { + status: 502, + headers: new Headers({ "Content-Type": "text/html" }), + text: peek, + body: null, + }; + } + } else { + // SSE validation — if it doesn't look like SSE, return buffered + if (!looksLikeSse(peek)) { + const r = await requestPromise.catch( + (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike + ); + const fileText = await readTextFileIfExists(path); + await cleanupFn(path); + return { + status: r.status, + headers: toHeaders(r.headers), + text: r.body || fileText, + body: null, + }; + } + } + + // Looks valid — create streaming response. + const stream = tailFileFn(path, eofSymbol, requestPromise, signal, path); + + const contentType = responseValidation === "cf" ? "application/x-ndjson" : "text/event-stream"; + + const headers = new Headers({ + "Content-Type": contentType, + "Cache-Control": "no-cache", + }); + return { status: 200, headers, text: null, body: stream }; + } + + async function tlsFetch(url: string, options: TlsFetchOptions = {}): Promise { + // Resolve proxyUrl early so test overrides and the real path both see it. + const resolvedProxyUrl = resolveProxyUrl(proxyDomainOverride ?? domain, options.proxyUrl); + if (testOverride) return testOverride(url, { ...options, proxyUrl: resolvedProxyUrl }); + + if (options.signal?.aborted) { + throw makeAbortError(options.signal); + } + const client = await localState.getClient(); + if (options.signal?.aborted) { + throw makeAbortError(options.signal); + } + + const requestOptions: Record = { + method: options.method || "GET", + headers: options.headers || {}, + body: options.body, + tlsClientIdentifier: tlsProfile, + timeoutMilliseconds: options.timeoutMs ?? defaultTimeoutMs, + followRedirects: true, + withRandomTLSExtensionOrder: true, + proxyUrl: resolvedProxyUrl, + }; + + requestOptions.isByteResponse = options.byteResponse === true; + + if (options.stream) { + return await tlsFetchStreaming( + client, + url, + requestOptions, + options.streamEofSymbol || streamEofSymbol, + options.signal ?? null, + (options.timeoutMs ?? defaultTimeoutMs) + hardTimeoutGraceMs, + firstByteTimeoutMs + ); + } + + let tlsResponse: TlsResponseLike; + try { + tlsResponse = await raceWithTimeout( + client.request(url, requestOptions), + (options.timeoutMs ?? defaultTimeoutMs) + hardTimeoutGraceMs, + options.signal ?? null + ); + } catch (err) { + if (err instanceof TlsClientHangError) { + resetClientCache(); + } + throw err; + } + if (options.signal?.aborted) { + throw makeAbortError(options.signal); + } + return { + status: tlsResponse.status, + headers: toHeaders(tlsResponse.headers), + text: tlsResponse.body, + body: null, + }; + } + + const module: TlsClientModule = { + tlsFetch, + __setTlsFetchOverrideForTesting(fn) { + testOverride = fn; + }, + }; + + if (exportCloudflareCheck) { + module.isCloudflareChallenge = isCloudflareChallenge; + } + + if (config.exposeStreamingForTesting) { + module.__tlsFetchStreamingForTesting = ( + client, + url, + requestOptions, + eofSymbol = "[DONE]", + signal = null, + hardTimeoutMs = defaultTimeoutMs + hardTimeoutGraceMs, + firstByteMs = firstByteTimeoutMs + ): Promise => { + return tlsFetchStreaming( + client, + url, + requestOptions, + eofSymbol, + signal, + hardTimeoutMs, + firstByteMs + ); + }; + } + + return module; +}