mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 14:42:20 +03:00
[v3.8.50] refactor(tls): consolidate 6 TLS client providers into shared factory + wrappers (reopen) (#10910)
Validado no worktree combinado: typecheck:core (confirma que TODOS os símbolos exportados foram preservados — TlsClientHangError, TlsClientUnavailableError, looksLikeSse, isCloudflareChallenge continuam re-exportados em cada wrapper), changelog-integrity, complexity, cognitive-complexity, file-size, lint e testes focados via vitest (chatgptTlsClient, grokTlsClient) + node:test (chatgpt-web-handoff-resume, lmarena-provider, claude-web-live-alignment, chatgpt-web, claude-web-slow-first-byte, grok-web-cloudflare-classification, grok-web) todos verdes. Refactor de consolidação bem executado: -3379 linhas líquidas, zero mudança de comportamento, 6 clientes TLS quase idênticos viram uma factory + wrappers finos. CI vermelho é o base-red já rastreado em #9985. Obrigado!
This commit is contained in:
@@ -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<unknown> | 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<unknown> };
|
||||
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<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | null | undefined
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let abortListener: (() => void) | null = null;
|
||||
try {
|
||||
const racers: Promise<T>[] = [
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(
|
||||
new TlsClientHangError(
|
||||
`tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
];
|
||||
if (signal) {
|
||||
racers.push(
|
||||
new Promise<T>((_, 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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}> {
|
||||
if (!clientPromise) {
|
||||
clientPromise = (async () => {
|
||||
try {
|
||||
const mod = await import("tls-client-node");
|
||||
const TLSClient = (mod as { TLSClient: new (opts?: Record<string, unknown>) => 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<void>;
|
||||
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
|
||||
};
|
||||
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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TlsResponseLike {
|
||||
status: number;
|
||||
headers: Record<string, string[]>;
|
||||
body: string; // for non-streaming requests, the full response body
|
||||
cookies?: Record<string, string>;
|
||||
text: () => Promise<string>;
|
||||
bytes: () => Promise<Uint8Array>;
|
||||
json: <T = unknown>() => Promise<T>;
|
||||
}
|
||||
|
||||
export class TlsClientUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TlsClientUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface TlsFetchOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal | null;
|
||||
/**
|
||||
* If true, the response body is streamed to a temp file and exposed as a
|
||||
* ReadableStream<Uint8Array>. 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:<mime>;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<Uint8Array> | 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<TlsFetchResult>) | 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<TlsFetchResult> {
|
||||
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<TlsFetchResult> => tlsClientModule.tlsFetch(url, options);
|
||||
export const __tlsFetchStreamingForTesting = tlsClientModule.__tlsFetchStreamingForTesting;
|
||||
|
||||
const requestOptions: Record<string, unknown> = {
|
||||
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<string, string[]>): 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<string, unknown>) => Promise<TlsResponseLike> },
|
||||
url: string,
|
||||
requestOptions: Record<string, unknown>,
|
||||
eofSymbol = "[DONE]",
|
||||
signal: AbortSignal | null = null,
|
||||
hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS,
|
||||
firstByteTimeoutMs: number = STREAM_FIRST_BYTE_TIMEOUT_MS
|
||||
): Promise<TlsFetchResult> {
|
||||
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<void> {
|
||||
await unlink(path).catch(() => {});
|
||||
const dir = path.substring(0, path.lastIndexOf("/"));
|
||||
await rmdir(dir).catch(() => {});
|
||||
}
|
||||
|
||||
async function readTextFileIfExists(path: string): Promise<string> {
|
||||
try {
|
||||
return await readFile(path, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export async function __tlsFetchStreamingForTesting(
|
||||
client: { request: (url: string, opts: Record<string, unknown>) => Promise<unknown> },
|
||||
url: string,
|
||||
requestOptions: Record<string, unknown>,
|
||||
eofSymbol = "[DONE]",
|
||||
signal: AbortSignal | null = null,
|
||||
hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS,
|
||||
firstByteTimeoutMs: number = STREAM_FIRST_BYTE_TIMEOUT_MS
|
||||
): Promise<TlsFetchResult> {
|
||||
return tlsFetchStreaming(
|
||||
client as { request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike> },
|
||||
url,
|
||||
requestOptions,
|
||||
eofSymbol,
|
||||
signal,
|
||||
hardTimeoutMs,
|
||||
firstByteTimeoutMs
|
||||
);
|
||||
}
|
||||
|
||||
async function readFirstBytes(path: string, n: number): Promise<string> {
|
||||
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<TlsResponseLike>
|
||||
): Promise<boolean> {
|
||||
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<TlsResponseLike>,
|
||||
signal: AbortSignal | null = null
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
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<void> {
|
||||
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";
|
||||
|
||||
@@ -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<unknown> | 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<unknown> };
|
||||
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<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | null | undefined
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let abortListener: (() => void) | null = null;
|
||||
try {
|
||||
const racers: Promise<T>[] = [
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(
|
||||
new TlsClientHangError(
|
||||
`tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
];
|
||||
if (signal) {
|
||||
racers.push(
|
||||
new Promise<T>((_, 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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}> {
|
||||
if (!clientPromise) {
|
||||
clientPromise = (async () => {
|
||||
try {
|
||||
const mod = await import("tls-client-node");
|
||||
const TLSClient = (mod as { TLSClient: new (opts?: Record<string, unknown>) => 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<void>;
|
||||
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
|
||||
};
|
||||
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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TlsResponseLike {
|
||||
status: number;
|
||||
headers: Record<string, string[]>;
|
||||
body: string; // for non-streaming requests, the full response body
|
||||
cookies?: Record<string, string>;
|
||||
text: () => Promise<string>;
|
||||
bytes: () => Promise<Uint8Array>;
|
||||
json: <T = unknown>() => Promise<T>;
|
||||
}
|
||||
|
||||
export class TlsClientUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TlsClientUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface TlsFetchOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal | null;
|
||||
/**
|
||||
* If true, the response body is streamed to a temp file and exposed as a
|
||||
* ReadableStream<Uint8Array>. 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:<mime>;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<Uint8Array> | 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<TlsFetchResult>) | 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<TlsFetchResult> {
|
||||
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<TlsFetchResult> => tlsClientModule.tlsFetch(url, options);
|
||||
export const tlsFetchStreaming = tlsClientModule.__tlsFetchStreamingForTesting;
|
||||
|
||||
const requestOptions: Record<string, unknown> = {
|
||||
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<string, string[]>): 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<string, unknown>) => Promise<TlsResponseLike> },
|
||||
url: string,
|
||||
requestOptions: Record<string, unknown>,
|
||||
eofSymbol = "[DONE]",
|
||||
signal: AbortSignal | null = null,
|
||||
hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS
|
||||
): Promise<TlsFetchResult> {
|
||||
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<void> {
|
||||
await unlink(path).catch(() => {});
|
||||
const dir = path.substring(0, path.lastIndexOf("/"));
|
||||
await rmdir(dir).catch(() => {});
|
||||
}
|
||||
|
||||
async function readFirstBytes(path: string, n: number): Promise<string> {
|
||||
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<TlsResponseLike>
|
||||
): Promise<boolean> {
|
||||
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<TlsResponseLike>,
|
||||
signal: AbortSignal | null = null
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
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<void> {
|
||||
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";
|
||||
|
||||
@@ -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<unknown> | 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<unknown> };
|
||||
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<TlsFetchResult> =>
|
||||
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<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | null | undefined
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let abortListener: (() => void) | null = null;
|
||||
try {
|
||||
const racers: Promise<T>[] = [
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(
|
||||
new TlsClientHangError(
|
||||
`tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
];
|
||||
if (signal) {
|
||||
racers.push(
|
||||
new Promise<T>((_, 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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}> {
|
||||
if (!clientPromise) {
|
||||
clientPromise = (async () => {
|
||||
try {
|
||||
const mod = await import("tls-client-node");
|
||||
const TLSClient = (mod as { TLSClient: new (opts?: Record<string, unknown>) => 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<void>;
|
||||
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
|
||||
};
|
||||
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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TlsResponseLike {
|
||||
status: number;
|
||||
headers: Record<string, string[]>;
|
||||
body: string; // for non-streaming requests, the full response body
|
||||
cookies?: Record<string, string>;
|
||||
text: () => Promise<string>;
|
||||
bytes: () => Promise<Uint8Array>;
|
||||
json: <T = unknown>() => Promise<T>;
|
||||
}
|
||||
|
||||
export class TlsClientUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TlsClientUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface TlsFetchOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal | null;
|
||||
/**
|
||||
* If true, the response body is streamed to a temp file and exposed as a
|
||||
* ReadableStream<Uint8Array>. 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<Uint8Array> | 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<TlsFetchResult>) | 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<TlsFetchResult> {
|
||||
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<string, unknown> = {
|
||||
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<string, string[]>): 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<string, unknown>) => Promise<TlsResponseLike> },
|
||||
url: string,
|
||||
requestOptions: Record<string, unknown>,
|
||||
eofSymbol = "[DONE]",
|
||||
signal: AbortSignal | null = null,
|
||||
hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS
|
||||
): Promise<TlsFetchResult> {
|
||||
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<void> {
|
||||
await unlink(path).catch(() => {});
|
||||
await rmdir(dirname(path)).catch(() => {});
|
||||
}
|
||||
|
||||
async function readFirstBytes(path: string, n: number): Promise<string> {
|
||||
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<TlsResponseLike>
|
||||
): Promise<boolean> {
|
||||
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<TlsResponseLike>,
|
||||
signal: AbortSignal | null = null
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
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<void> {
|
||||
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";
|
||||
|
||||
@@ -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<unknown> | 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<unknown> };
|
||||
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<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | null | undefined
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let abortListener: (() => void) | null = null;
|
||||
try {
|
||||
const racers: Promise<T>[] = [
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(
|
||||
new TlsClientHangError(
|
||||
`tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
];
|
||||
if (signal) {
|
||||
racers.push(
|
||||
new Promise<T>((_, 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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}> {
|
||||
if (!clientPromise) {
|
||||
clientPromise = (async () => {
|
||||
try {
|
||||
const mod = await import("tls-client-node");
|
||||
const TLSClient = (mod as { TLSClient: new (opts?: Record<string, unknown>) => 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<void>;
|
||||
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
|
||||
};
|
||||
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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TlsResponseLike {
|
||||
status: number;
|
||||
headers: Record<string, string[]>;
|
||||
body: string; // for non-streaming requests, the full response body
|
||||
cookies?: Record<string, string>;
|
||||
text: () => Promise<string>;
|
||||
bytes: () => Promise<Uint8Array>;
|
||||
json: <T = unknown>() => Promise<T>;
|
||||
}
|
||||
|
||||
export class TlsClientUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TlsClientUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface TlsFetchOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal | null;
|
||||
/**
|
||||
* If true, the response body is streamed to a temp file and exposed as a
|
||||
* ReadableStream<Uint8Array>. 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<Uint8Array> | 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<TlsFetchResult>) | 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<string, unknown> {
|
||||
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<string, unknown>) => Promise<TlsResponseLike> },
|
||||
url: string,
|
||||
requestOptions: Record<string, unknown>,
|
||||
options: TlsFetchOptions
|
||||
): Promise<TlsFetchResult> {
|
||||
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<TlsFetchResult> {
|
||||
if (testOverride) return testOverride(url, options);
|
||||
throwIfAborted(options.signal);
|
||||
const client = await getClient();
|
||||
throwIfAborted(options.signal);
|
||||
): Promise<TlsFetchResult> => 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<string, string[]>): 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<string, unknown>) => Promise<TlsResponseLike> },
|
||||
url: string,
|
||||
requestOptions: Record<string, unknown>,
|
||||
eofSymbol = "[DONE]",
|
||||
signal: AbortSignal | null = null,
|
||||
hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS
|
||||
): Promise<TlsFetchResult> {
|
||||
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<void> {
|
||||
await unlink(path).catch(() => {});
|
||||
await rmdir(dirname(path)).catch(() => {});
|
||||
}
|
||||
|
||||
async function readFirstBytes(path: string, n: number): Promise<string> {
|
||||
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<TlsResponseLike>
|
||||
): Promise<boolean> {
|
||||
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<Uint8Array>,
|
||||
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<ReturnType<typeof open>>;
|
||||
|
||||
async function drainRemaining(
|
||||
fd: FileHandle,
|
||||
buf: Buffer,
|
||||
offsetRef: { offset: number },
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
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<TlsResponseLike>,
|
||||
signal: AbortSignal | null = null
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
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<void> {
|
||||
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";
|
||||
|
||||
@@ -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<unknown> | 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<unknown> };
|
||||
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<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | null | undefined
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let abortListener: (() => void) | null = null;
|
||||
try {
|
||||
const racers: Promise<T>[] = [
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(
|
||||
new TlsClientHangError(
|
||||
`tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
];
|
||||
if (signal) {
|
||||
racers.push(
|
||||
new Promise<T>((_, 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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}> {
|
||||
if (!clientPromise) {
|
||||
clientPromise = (async () => {
|
||||
try {
|
||||
const mod = await import("tls-client-node");
|
||||
const TLSClient = (mod as { TLSClient: new (opts?: Record<string, unknown>) => 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<void>;
|
||||
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
|
||||
};
|
||||
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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TlsResponseLike {
|
||||
status: number;
|
||||
headers: Record<string, string[]>;
|
||||
body: string; // for non-streaming requests, the full response body
|
||||
cookies?: Record<string, string>;
|
||||
text: () => Promise<string>;
|
||||
bytes: () => Promise<Uint8Array>;
|
||||
json: <T = unknown>() => Promise<T>;
|
||||
}
|
||||
|
||||
export class TlsClientUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TlsClientUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface TlsFetchOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal | null;
|
||||
/**
|
||||
* If true, the response body is streamed to a temp file and exposed as a
|
||||
* ReadableStream<Uint8Array>. 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<Uint8Array> | 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<TlsFetchResult>) | 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<TlsFetchResult> {
|
||||
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<TlsFetchResult> => tlsClientModule.tlsFetch(url, options);
|
||||
|
||||
const requestOptions: Record<string, unknown> = {
|
||||
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<string, string[]>): 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<string, unknown>) => Promise<TlsResponseLike> },
|
||||
url: string,
|
||||
requestOptions: Record<string, unknown>,
|
||||
eofSymbol = "[DONE]",
|
||||
signal: AbortSignal | null = null,
|
||||
hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS
|
||||
): Promise<TlsFetchResult> {
|
||||
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<void> {
|
||||
await unlink(path).catch(() => {});
|
||||
const dir = path.substring(0, path.lastIndexOf("/"));
|
||||
await rmdir(dir).catch(() => {});
|
||||
}
|
||||
|
||||
async function readFirstBytes(path: string, n: number): Promise<string> {
|
||||
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<TlsResponseLike>
|
||||
): Promise<boolean> {
|
||||
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<TlsResponseLike>,
|
||||
signal: AbortSignal | null = null
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
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<void> {
|
||||
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";
|
||||
|
||||
@@ -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<unknown> | 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<unknown> };
|
||||
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<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | null | undefined
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let abortListener: (() => void) | null = null;
|
||||
try {
|
||||
const racers: Promise<T>[] = [
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(
|
||||
new TlsClientHangError(
|
||||
`tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
];
|
||||
if (signal) {
|
||||
racers.push(
|
||||
new Promise<T>((_, 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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}> {
|
||||
if (!clientPromise) {
|
||||
clientPromise = (async () => {
|
||||
try {
|
||||
const mod = await import("tls-client-node");
|
||||
const TLSClient = (mod as { TLSClient: new (opts?: Record<string, unknown>) => 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<void>;
|
||||
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
|
||||
};
|
||||
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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TlsResponseLike {
|
||||
status: number;
|
||||
headers: Record<string, string[]>;
|
||||
body: string; // for non-streaming requests, the full response body
|
||||
cookies?: Record<string, string>;
|
||||
text: () => Promise<string>;
|
||||
bytes: () => Promise<Uint8Array>;
|
||||
json: <T = unknown>() => Promise<T>;
|
||||
}
|
||||
|
||||
export class TlsClientUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TlsClientUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface TlsFetchOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal | null;
|
||||
/**
|
||||
* If true, the response body is streamed to a temp file and exposed as a
|
||||
* ReadableStream<Uint8Array>. 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<Uint8Array> | 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<TlsFetchResult>) | 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<TlsFetchResult> {
|
||||
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<TlsFetchResult> => tlsClientModule.tlsFetch(url, options);
|
||||
|
||||
const requestOptions: Record<string, unknown> = {
|
||||
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<string, string[]>): 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<string, unknown>) => Promise<TlsResponseLike> },
|
||||
url: string,
|
||||
requestOptions: Record<string, unknown>,
|
||||
eofSymbol = "[DONE]",
|
||||
signal: AbortSignal | null = null,
|
||||
hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS
|
||||
): Promise<TlsFetchResult> {
|
||||
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<void> {
|
||||
await unlink(path).catch(() => {});
|
||||
const dir = path.substring(0, path.lastIndexOf("/"));
|
||||
await rmdir(dir).catch(() => {});
|
||||
}
|
||||
|
||||
async function readFirstBytes(path: string, n: number): Promise<string> {
|
||||
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<TlsResponseLike>
|
||||
): Promise<boolean> {
|
||||
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<TlsResponseLike>,
|
||||
signal: AbortSignal | null = null
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
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<void> {
|
||||
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";
|
||||
|
||||
958
open-sse/services/tlsClientBase.ts
Normal file
958
open-sse/services/tlsClientBase.ts
Normal file
@@ -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<string, string[]>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface TlsFetchResult {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
text: string | null;
|
||||
body: ReadableStream<Uint8Array> | null;
|
||||
}
|
||||
|
||||
export interface TlsFetchOptions {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
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<void> {
|
||||
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<string, string[]> | 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<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | null | undefined
|
||||
): Promise<T> {
|
||||
// If no signal, just race with a simple timeout.
|
||||
if (!signal) {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new TlsClientHangError()), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
// With signal, race against both timeout and abort.
|
||||
return await new Promise<T>((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<string> {
|
||||
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<TlsResponseLike>
|
||||
): Promise<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await unlink(path).catch(() => {});
|
||||
await rmdir(dirname(path)).catch(() => {});
|
||||
}
|
||||
|
||||
async function readTextFileIfExists(path: string): Promise<string> {
|
||||
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<TlsResponseLike>,
|
||||
signal: AbortSignal | null = null,
|
||||
cleanupPath: string
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
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<TlsResponseLike>,
|
||||
signal: AbortSignal | null = null,
|
||||
cleanupPath: string
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
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<ReturnType<typeof open>>;
|
||||
|
||||
function enqueueChunkMaybeEof(
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
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<Uint8Array>,
|
||||
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<TlsResponseLike>,
|
||||
signal: AbortSignal | null = null,
|
||||
cleanupPath: string
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}> {
|
||||
let clientPromise: Promise<{
|
||||
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}> | null = null;
|
||||
let exitHookInstalled = false;
|
||||
|
||||
const installExitHook = (client: { stop: () => Promise<void> }): void => {
|
||||
if (!exitHookInstalled) {
|
||||
exitHookInstalled = true;
|
||||
process.on("exit", () => {
|
||||
void client.stop();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return async function getClient(): Promise<{
|
||||
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}> {
|
||||
if (!clientPromise) {
|
||||
clientPromise = (async () => {
|
||||
let TLSClientCtor: {
|
||||
new (config: Record<string, unknown>): {
|
||||
start: () => Promise<void>;
|
||||
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
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<string, unknown> = {
|
||||
...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<TlsFetchResult>;
|
||||
__setTlsFetchOverrideForTesting: (
|
||||
fn: ((url: string, options: TlsFetchOptions) => Promise<TlsFetchResult>) | null
|
||||
) => void;
|
||||
isCloudflareChallenge?: (text: string | null | undefined) => boolean;
|
||||
__tlsFetchStreamingForTesting?: (
|
||||
client: { request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike> },
|
||||
url: string,
|
||||
requestOptions: Record<string, unknown>,
|
||||
eofSymbol?: string,
|
||||
signal?: AbortSignal | null,
|
||||
hardTimeoutMs?: number,
|
||||
firstByteTimeoutMs?: number
|
||||
) => Promise<TlsFetchResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TlsFetchResult>) | null =
|
||||
null;
|
||||
|
||||
const tailFileFn = TAIL_FILE_VARIANTS[tailFileVariant];
|
||||
|
||||
const cleanupFn = tailFileVariant === "A" ? cleanupTempPathSubstring : cleanupTempPathDirname;
|
||||
|
||||
async function tlsFetchStreaming(
|
||||
client: { request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike> },
|
||||
url: string,
|
||||
requestOptions: Record<string, unknown>,
|
||||
eofSymbol: string,
|
||||
signal: AbortSignal | null,
|
||||
hardTimeoutMs: number,
|
||||
firstByteMs: number = firstByteTimeoutMs
|
||||
): Promise<TlsFetchResult> {
|
||||
const dir = await mkdtemp(join(tmpdir(), tempDirPrefix));
|
||||
const path = join(dir, `${randomUUID()}.sse`);
|
||||
|
||||
const streamOpts: Record<string, unknown> = {
|
||||
...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<TlsFetchResult> {
|
||||
// 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<string, unknown> = {
|
||||
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<TlsFetchResult> => {
|
||||
return tlsFetchStreaming(
|
||||
client,
|
||||
url,
|
||||
requestOptions,
|
||||
eofSymbol,
|
||||
signal,
|
||||
hardTimeoutMs,
|
||||
firstByteMs
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return module;
|
||||
}
|
||||
Reference in New Issue
Block a user