mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 15:22:12 +03:00
495 lines
16 KiB
TypeScript
495 lines
16 KiB
TypeScript
import { createHash } from "crypto";
|
|
|
|
import {
|
|
acquireBrowserContext,
|
|
openPage,
|
|
type BrowserPoolContextOptions,
|
|
type PooledContext,
|
|
} from "../../services/browserPool.ts";
|
|
import type { ClaudeWebRequestPayload } from "./payload.ts";
|
|
|
|
const CLAUDE_WEB_TEMPLATE_TTL_MS = 30 * 60 * 1000;
|
|
const CLAUDE_WEB_TEMPLATE_MAX = 5000;
|
|
const MAX_CLAUDE_WEB_BROWSER_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
const CLAUDE_WEB_INPUT_SELECTOR = "div[contenteditable='true']";
|
|
|
|
type Page = import("playwright").Page;
|
|
|
|
type BrowserTemplate = {
|
|
tools: ClaudeWebRequestPayload["tools"];
|
|
toolStates?: unknown[];
|
|
personalizedStyles: ClaudeWebRequestPayload["personalized_styles"];
|
|
};
|
|
|
|
type CachedBrowserTemplate = {
|
|
template: BrowserTemplate;
|
|
context: PooledContext["context"];
|
|
expiresAt: number;
|
|
};
|
|
|
|
const browserTemplateCache = new Map<string, CachedBrowserTemplate>();
|
|
let testNow: number | null = null;
|
|
|
|
export interface ClaudeWebTransportRequest {
|
|
scopeKey: string;
|
|
organizationId: string;
|
|
conversationId: string;
|
|
endpointSuffix: "completion" | "retry_completion";
|
|
pageUrl: string;
|
|
url: string;
|
|
cookieString: string;
|
|
headers: Record<string, string>;
|
|
payload: ClaudeWebRequestPayload;
|
|
locale: string;
|
|
timezone: string;
|
|
signal?: AbortSignal | null;
|
|
}
|
|
|
|
export interface ClaudeWebTransportResult {
|
|
status: number;
|
|
headers: Headers;
|
|
body: ReadableStream<Uint8Array> | null;
|
|
/** Buffered only for non-success responses; never returned directly to clients. */
|
|
bodyText?: string;
|
|
}
|
|
|
|
export interface ClaudeWebBrowserDeps {
|
|
acquireContext(key: string, options: BrowserPoolContextOptions): Promise<PooledContext>;
|
|
openPage(pooled: PooledContext): Promise<Page>;
|
|
fetchResponse(
|
|
page: Page,
|
|
input: ClaudeWebBrowserFetchInput
|
|
): Promise<{
|
|
status: number;
|
|
headers: Record<string, string>;
|
|
body: Uint8Array;
|
|
}>;
|
|
}
|
|
|
|
export interface ClaudeWebBrowserFetchInput {
|
|
url: string;
|
|
headers: Record<string, string>;
|
|
body: string;
|
|
maxBytes: number;
|
|
}
|
|
|
|
const defaultDeps: ClaudeWebBrowserDeps = {
|
|
acquireContext: acquireBrowserContext,
|
|
openPage,
|
|
fetchResponse: fetchClaudeWebPageResponse,
|
|
};
|
|
|
|
function now(): number {
|
|
return testNow ?? Date.now();
|
|
}
|
|
|
|
function cloneValue<T>(value: T): T {
|
|
return structuredClone(value);
|
|
}
|
|
|
|
function expectedRequestUrl(request: ClaudeWebTransportRequest): string {
|
|
return (
|
|
"https://claude.ai/api/organizations/" +
|
|
`${encodeURIComponent(request.organizationId)}/chat_conversations/` +
|
|
`${encodeURIComponent(request.conversationId)}/${request.endpointSuffix}`
|
|
);
|
|
}
|
|
|
|
function verifyRequestUrl(request: ClaudeWebTransportRequest): void {
|
|
if (request.url !== expectedRequestUrl(request)) {
|
|
throw new Error("Claude Web browser request endpoint does not match prepared state");
|
|
}
|
|
}
|
|
|
|
function extractBrowserTemplate(uiPayload: Record<string, unknown>): BrowserTemplate {
|
|
return {
|
|
tools: Array.isArray(uiPayload.tools)
|
|
? cloneValue(uiPayload.tools as ClaudeWebRequestPayload["tools"])
|
|
: [],
|
|
...(Array.isArray(uiPayload.tool_states)
|
|
? { toolStates: cloneValue(uiPayload.tool_states) }
|
|
: {}),
|
|
personalizedStyles: Array.isArray(uiPayload.personalized_styles)
|
|
? cloneValue(uiPayload.personalized_styles as ClaudeWebRequestPayload["personalized_styles"])
|
|
: [],
|
|
};
|
|
}
|
|
|
|
function pruneExpiredTemplates(): void {
|
|
const currentTime = now();
|
|
for (const [key, entry] of browserTemplateCache) {
|
|
if (currentTime >= entry.expiresAt) browserTemplateCache.delete(key);
|
|
}
|
|
}
|
|
|
|
function rememberBrowserTemplate(
|
|
poolKey: string,
|
|
template: BrowserTemplate,
|
|
context: PooledContext["context"]
|
|
): void {
|
|
pruneExpiredTemplates();
|
|
if (browserTemplateCache.has(poolKey)) browserTemplateCache.delete(poolKey);
|
|
while (browserTemplateCache.size >= CLAUDE_WEB_TEMPLATE_MAX) {
|
|
const oldestKey = browserTemplateCache.keys().next().value;
|
|
if (typeof oldestKey !== "string") break;
|
|
browserTemplateCache.delete(oldestKey);
|
|
}
|
|
browserTemplateCache.set(poolKey, {
|
|
template: cloneValue(template),
|
|
context,
|
|
expiresAt: now() + CLAUDE_WEB_TEMPLATE_TTL_MS,
|
|
});
|
|
}
|
|
|
|
function lookupBrowserTemplate(poolKey: string): CachedBrowserTemplate | null {
|
|
const entry = browserTemplateCache.get(poolKey);
|
|
if (!entry) return null;
|
|
if (now() >= entry.expiresAt) {
|
|
browserTemplateCache.delete(poolKey);
|
|
return null;
|
|
}
|
|
return {
|
|
template: cloneValue(entry.template),
|
|
context: entry.context,
|
|
expiresAt: entry.expiresAt,
|
|
};
|
|
}
|
|
|
|
export function buildClaudeWebBrowserPoolKey(input: {
|
|
scopeKey: string;
|
|
organizationId: string;
|
|
cookieString: string;
|
|
locale: string;
|
|
timezone: string;
|
|
}): string {
|
|
const digest = createHash("sha256")
|
|
.update(
|
|
[input.scopeKey, input.organizationId, input.cookieString, input.locale, input.timezone].join(
|
|
String.fromCharCode(31)
|
|
)
|
|
)
|
|
.digest("hex");
|
|
return `claude-web:${digest}`;
|
|
}
|
|
|
|
export function mergeClaudeWebBrowserPayload(
|
|
uiPayload: Record<string, unknown>,
|
|
preparedPayload: ClaudeWebRequestPayload
|
|
): ClaudeWebRequestPayload {
|
|
const template = extractBrowserTemplate(uiPayload);
|
|
const merged = {
|
|
...uiPayload,
|
|
...preparedPayload,
|
|
tools: template.tools,
|
|
personalized_styles: template.personalizedStyles,
|
|
...(template.toolStates ? { tool_states: template.toolStates } : {}),
|
|
} as unknown as ClaudeWebRequestPayload;
|
|
|
|
if (!("parent_message_uuid" in preparedPayload)) {
|
|
delete merged.parent_message_uuid;
|
|
}
|
|
if (!("create_conversation_params" in preparedPayload)) {
|
|
delete merged.create_conversation_params;
|
|
}
|
|
if (!template.toolStates && !("tool_states" in preparedPayload)) {
|
|
delete merged.tool_states;
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function mergeTemplateIntoPrepared(
|
|
template: BrowserTemplate,
|
|
preparedPayload: ClaudeWebRequestPayload
|
|
): ClaudeWebRequestPayload {
|
|
return {
|
|
...preparedPayload,
|
|
tools: cloneValue(template.tools),
|
|
personalized_styles: cloneValue(template.personalizedStyles),
|
|
...(!("tool_states" in preparedPayload) && template.toolStates
|
|
? { tool_states: cloneValue(template.toolStates) }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
export function applyClaudeWebBrowserTemplate(
|
|
request: ClaudeWebTransportRequest
|
|
): ClaudeWebTransportRequest {
|
|
if (request.payload.tools.length > 0) return request;
|
|
const cached = lookupBrowserTemplate(buildClaudeWebBrowserPoolKey(request));
|
|
if (!cached) return request;
|
|
return {
|
|
...request,
|
|
payload: mergeTemplateIntoPrepared(cached.template, request.payload),
|
|
};
|
|
}
|
|
|
|
function browserFetchHeaders(headers: Record<string, string>): Record<string, string> {
|
|
const forbidden = new Set([
|
|
"accept-encoding",
|
|
"connection",
|
|
"content-length",
|
|
"cookie",
|
|
"host",
|
|
"origin",
|
|
"referer",
|
|
"user-agent",
|
|
]);
|
|
const filtered: Record<string, string> = {};
|
|
for (const [name, value] of Object.entries(headers)) {
|
|
const normalized = name.toLowerCase();
|
|
if (forbidden.has(normalized) || normalized.startsWith("sec-")) continue;
|
|
filtered[name] = value;
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
function makeBrowserFetchInput(
|
|
request: ClaudeWebTransportRequest,
|
|
payload: ClaudeWebRequestPayload,
|
|
capturedHeaders: Record<string, string> = {}
|
|
): ClaudeWebBrowserFetchInput {
|
|
return {
|
|
url: request.url,
|
|
headers: browserFetchHeaders({ ...capturedHeaders, ...request.headers }),
|
|
body: JSON.stringify(payload),
|
|
maxBytes: MAX_CLAUDE_WEB_BROWSER_RESPONSE_BYTES,
|
|
};
|
|
}
|
|
|
|
export async function fetchClaudeWebPageResponse(
|
|
page: Page,
|
|
input: ClaudeWebBrowserFetchInput
|
|
): Promise<{ status: number; headers: Record<string, string>; body: Uint8Array }> {
|
|
const captured = await page.evaluate(async ({ url, headers, body, maxBytes }) => {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body,
|
|
credentials: "include",
|
|
});
|
|
const responseHeaders = Object.fromEntries(response.headers.entries());
|
|
const declaredLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
|
|
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
await response.body?.cancel().catch(() => {});
|
|
throw new Error("Claude Web browser response exceeded the size limit");
|
|
}
|
|
|
|
const reader = response.body?.getReader();
|
|
const bodyChunks: string[] = [];
|
|
let totalBytes = 0;
|
|
const encodeBase64 = (bytes: Uint8Array): string => {
|
|
let binary = "";
|
|
const sliceSize = 32 * 1024;
|
|
for (let offset = 0; offset < bytes.byteLength; offset += sliceSize) {
|
|
binary += String.fromCharCode(...bytes.subarray(offset, offset + sliceSize));
|
|
}
|
|
return btoa(binary);
|
|
};
|
|
|
|
try {
|
|
while (reader) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
if (!value) continue;
|
|
totalBytes += value.byteLength;
|
|
if (totalBytes > maxBytes) {
|
|
await reader.cancel().catch(() => {});
|
|
throw new Error("Claude Web browser response exceeded the size limit");
|
|
}
|
|
bodyChunks.push(encodeBase64(value));
|
|
}
|
|
} finally {
|
|
try {
|
|
reader?.releaseLock();
|
|
} catch {
|
|
// The reader may already be released after cancellation.
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: response.status,
|
|
headers: responseHeaders,
|
|
bodyChunks,
|
|
};
|
|
}, input);
|
|
|
|
const chunks = captured.bodyChunks.map((chunk) => Buffer.from(chunk, "base64"));
|
|
return {
|
|
status: captured.status,
|
|
headers: captured.headers,
|
|
body: Buffer.concat(chunks),
|
|
};
|
|
}
|
|
|
|
function throwIfAborted(signal?: AbortSignal | null): void {
|
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
}
|
|
|
|
async function withAbort<T>(promise: Promise<T>, signal?: AbortSignal | null): Promise<T> {
|
|
if (!signal) return promise;
|
|
throwIfAborted(signal);
|
|
let abortListener: (() => void) | undefined;
|
|
const aborted = new Promise<never>((_, reject) => {
|
|
abortListener = () => reject(new DOMException("Aborted", "AbortError"));
|
|
signal.addEventListener("abort", abortListener, { once: true });
|
|
});
|
|
try {
|
|
return await Promise.race([promise, aborted]);
|
|
} finally {
|
|
if (abortListener) signal.removeEventListener("abort", abortListener);
|
|
}
|
|
}
|
|
|
|
async function captureCompletion(
|
|
page: Page,
|
|
request: ClaudeWebTransportRequest,
|
|
poolKey: string,
|
|
context: PooledContext["context"]
|
|
): Promise<ClaudeWebBrowserFetchInput> {
|
|
let resolveInterception: ((input: ClaudeWebBrowserFetchInput) => void) | undefined;
|
|
let rejectInterception: ((error: Error) => void) | undefined;
|
|
const intercepted = new Promise<ClaudeWebBrowserFetchInput>((resolve, reject) => {
|
|
resolveInterception = resolve;
|
|
rejectInterception = reject;
|
|
});
|
|
|
|
const matchesPreparedRoute = (url: URL): boolean => {
|
|
if (!("create_conversation_params" in request.payload)) {
|
|
return url.toString() === request.url;
|
|
}
|
|
if (url.origin !== "https://claude.ai" || url.search || url.hash) return false;
|
|
const segments = url.pathname.split("/").filter(Boolean);
|
|
if (
|
|
segments.length !== 6 ||
|
|
segments[0] !== "api" ||
|
|
segments[1] !== "organizations" ||
|
|
segments[3] !== "chat_conversations" ||
|
|
!segments[4] ||
|
|
segments[5] !== "completion"
|
|
) {
|
|
return false;
|
|
}
|
|
try {
|
|
return decodeURIComponent(segments[2]) === request.organizationId;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
await page.route(matchesPreparedRoute, async (route) => {
|
|
try {
|
|
const outgoing = route.request();
|
|
if (outgoing.method() !== "POST" || !matchesPreparedRoute(new URL(outgoing.url()))) {
|
|
throw new Error("Claude Web browser interception target changed");
|
|
}
|
|
const rawPayload = outgoing.postData();
|
|
if (!rawPayload) throw new Error("Claude Web browser request body is missing");
|
|
const uiPayload = JSON.parse(rawPayload) as unknown;
|
|
if (!uiPayload || typeof uiPayload !== "object" || Array.isArray(uiPayload)) {
|
|
throw new Error("Claude Web browser request body is invalid");
|
|
}
|
|
const capturedHeaders = await outgoing.allHeaders();
|
|
const template = extractBrowserTemplate(uiPayload as Record<string, unknown>);
|
|
const merged = mergeClaudeWebBrowserPayload(
|
|
uiPayload as Record<string, unknown>,
|
|
request.payload
|
|
);
|
|
await route.abort();
|
|
rememberBrowserTemplate(poolKey, template, context);
|
|
resolveInterception?.(makeBrowserFetchInput(request, merged, capturedHeaders));
|
|
} catch {
|
|
await route.abort().catch(() => {});
|
|
rejectInterception?.(new Error("Claude Web browser request interception failed"));
|
|
}
|
|
});
|
|
|
|
const guardedInterception = withAbort(intercepted, request.signal);
|
|
void guardedInterception.catch(() => {});
|
|
const input = page.locator(CLAUDE_WEB_INPUT_SELECTOR).first();
|
|
try {
|
|
await withAbort(input.waitFor({ state: "visible", timeout: 10000 }), request.signal);
|
|
await withAbort(input.fill(request.payload.prompt), request.signal);
|
|
await withAbort(page.keyboard.press("Enter"), request.signal);
|
|
return await guardedInterception;
|
|
} catch (error) {
|
|
rejectInterception?.(new Error("Claude Web browser request capture failed"));
|
|
await guardedInterception.catch(() => {});
|
|
throw error;
|
|
} finally {
|
|
await page.unroute(matchesPreparedRoute).catch(() => {});
|
|
}
|
|
}
|
|
|
|
function captureRetry(
|
|
request: ClaudeWebTransportRequest,
|
|
template: BrowserTemplate
|
|
): ClaudeWebBrowserFetchInput {
|
|
const payload = mergeTemplateIntoPrepared(template, request.payload);
|
|
return makeBrowserFetchInput(request, payload);
|
|
}
|
|
|
|
function toTransportResult(captured: {
|
|
status: number;
|
|
headers: Record<string, string>;
|
|
body: Uint8Array;
|
|
}): ClaudeWebTransportResult {
|
|
const bytes = new Uint8Array(captured.body);
|
|
return {
|
|
status: captured.status,
|
|
headers: new Headers(captured.headers),
|
|
body: new Response(bytes).body,
|
|
};
|
|
}
|
|
|
|
export async function sendClaudeWebBrowser(
|
|
request: ClaudeWebTransportRequest,
|
|
deps: ClaudeWebBrowserDeps = defaultDeps
|
|
): Promise<ClaudeWebTransportResult> {
|
|
verifyRequestUrl(request);
|
|
throwIfAborted(request.signal);
|
|
const poolKey = buildClaudeWebBrowserPoolKey(request);
|
|
const retryEntry =
|
|
request.endpointSuffix === "retry_completion" ? lookupBrowserTemplate(poolKey) : null;
|
|
if (request.endpointSuffix === "retry_completion" && !retryEntry) {
|
|
throw new Error("Claude Web browser retry requires a scoped UI template");
|
|
}
|
|
|
|
const pooled = await deps.acquireContext(poolKey, {
|
|
cookieDomain: ".claude.ai",
|
|
cookieString: request.cookieString,
|
|
warmupUrl: request.pageUrl,
|
|
locale: request.locale,
|
|
timezone: request.timezone,
|
|
proxyProviderKey: "claude-web",
|
|
});
|
|
const page = await deps.openPage(pooled);
|
|
try {
|
|
if (retryEntry && retryEntry.context !== pooled.context) {
|
|
throw new Error("Claude Web browser context no longer matches scoped UI template");
|
|
}
|
|
await page.goto(request.pageUrl, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 60000,
|
|
});
|
|
throwIfAborted(request.signal);
|
|
const fetchInput = retryEntry
|
|
? captureRetry(request, retryEntry.template)
|
|
: await captureCompletion(page, request, poolKey, pooled.context);
|
|
const captured = await withAbort(deps.fetchResponse(page, fetchInput), request.signal);
|
|
if (captured.body.byteLength > MAX_CLAUDE_WEB_BROWSER_RESPONSE_BYTES) {
|
|
throw new Error("Claude Web browser response exceeded the size limit");
|
|
}
|
|
return toTransportResult(captured);
|
|
} finally {
|
|
await page.close().catch(() => {});
|
|
}
|
|
}
|
|
|
|
export function __resetClaudeWebBrowserTemplatesForTesting(): void {
|
|
browserTemplateCache.clear();
|
|
}
|
|
|
|
export function __setClaudeWebBrowserNowForTesting(value: number | null): void {
|
|
testNow = value;
|
|
}
|