import { type IncomingHttpHeaders, type IncomingMessage, type OutgoingHttpHeaders, type ServerResponse } from 'node:http'; import type { Duplex } from 'node:stream'; import { type SessionPreviewTarget } from '../core/session-preview.js'; import type { SessionPreviewResolution } from './preview-contract.js'; /** Dev servers commonly cold-compile their first response for 3–30 seconds. * This bounds only the pre-header phase and is cleared once headers arrive. */ export declare const PREVIEW_UPSTREAM_RESPONSE_TIMEOUT_MS = 45000; /** * P1-2:一个 upgrade 请求收到非 101 回应时,那是一次**拒绝**,不是长连接。 * * 代理把这段 body 攒完才写回浏览器(只有 `end` 才落笔),所以一个永不结束的 * 「拒绝」会同时钉死上游 socket 和浏览器 socket,而浏览器一个字节都收不到。 * 因此这条路必须两道时限都有:读完整段拒绝的**总时限**,以及静默多久算死的 * **idle 时限**——只有 idle 时限时,上游每 N 秒滴一个字节就能永久续命。 */ export declare const PREVIEW_UPGRADE_REJECTION_TIMEOUT_MS = 15000; export declare const PREVIEW_UPGRADE_REJECTION_IDLE_TIMEOUT_MS = 10000; /** * Sandbox flags forced onto every proxied preview document. * * `allow-same-origin` is deliberately absent: without it the document is an * opaque origin, which is the whole point. It cannot read the dashboard DOM, * cannot attach dashboard cookies to any request (an opaque origin has a null * site-for-cookies, so even SameSite=Lax credentials stay home), and cannot * touch same-origin storage. The same list is applied twice on purpose: * * • as the iframe `sandbox` attribute in the guard shell, and * • as a `Content-Security-Policy: sandbox …` response header here, * * so agent-controlled HTML is opaque even when it is reached *without* the * shell — a lured top-level navigation to a preview URL must not hand the * agent a usable dashboard origin either. * * `allow-top-navigation` is absent so a preview can never navigate the * dashboard away, and `allow-popups-to-escape-sandbox` is absent so popups * the preview opens inherit the same opaque origin. */ export declare const PREVIEW_SANDBOX_TOKENS = "allow-scripts allow-forms allow-popups"; export type PreviewProxyResolution = SessionPreviewResolution | { ok: false; status: 503; error: 'daemon_offline'; }; export interface SessionPreviewProxyOptions { /** Management-cookie authentication. Query tokens are deliberately not * accepted: a preview URL must never contain the dashboard token. */ authenticated: (req: IncomingMessage) => boolean; /** Positive session/daemon ownership lookup followed by registered-target * resolution. The URL contributes only the session id, never a target. */ resolve: (sessionId: string) => PreviewProxyResolution; /** * Verify the path-scoped capability that authorises the sandboxed content * stream for exactly this session. Required, not optional: the content path * carries no cookie by construction, so a missing verifier would either be * an open proxy or a dead route — both worse than a compile error. */ verifyContentCapability: (capability: string, sessionId: string) => boolean; /** * P1-8:把一条已授权的长连接(SSE / 长响应 / WebSocket 桥)挂到签发它的认证 * 会话下,返回注销闭包(连接自然结束时调用)。认证结束时由外层索引统一 * `close()`。返回 null 表示这条流没有可绑定的身份(不该发生,调用方自行决定 * 是否记录),此时行为与不提供本钩子一致。 * * 授权与撤销是两件事:`authenticated` / `verifyContentCapability` 只在握手那 * 一刻说话,而预览的 SSE 与 WebSocket 一握手就能流上几个小时。没有这个钩子, * 登出只能等对端自己断开。 * * P1-4:返回 `false` 表示「这条流所属的认证会话已经结束,不许登记」。授权发生 * 在**拨号前**,而 dev server 的握手最长可以拖 45 秒;登出若落在这段窗口里,撤 * 销扫描遍历索引时这条连接还不在里面,一条都关不到,等上游握完手再补登记就等于 * 把一条活流挂到已经死掉的认证会话名下——从此再没有任何撤销能碰到它。本钩子因此 * 是「登记点」也是「最后一次判定点」:调用方在这里重查存活,代理按 false 销毁 * 上游、不登记、不回 101/200。 * * P1-1:`context.target` 是**拨号那一刻**用的目标。同一段 45 秒窗口里换代 / 切 * CLI / 端口易主换来的新目标,旧流握完手照样能拿到 200/101,还会被重新登记进索引 * (换靶时的 teardown 扫的是索引,扫不到一条还没入索引的流)。所以调用方在这里 * 除了复核身份,还要把目标重解一遍与它比对——不是同一次注册就返回 false。 */ bindStream?: (req: IncomingMessage, context: { sessionId: string; contentCapability: string | null; target: SessionPreviewTarget; }, close: () => void) => (() => void) | null | false; } type ParsedPreviewPath = { matched: false; } | { matched: true; ok: false; error: 'invalid_preview_path' | 'query_token_forbidden'; } | { matched: true; ok: true; sessionId: string; upstreamPath: string; basePath: string; /** Present only on `/preview//__botmux_preview_content//…`. */ contentCapability: string | null; }; export declare function parseSessionPreviewRequest(url: URL): ParsedPreviewPath; /** Never forward dashboard/browser credentials into agent-controlled preview * servers. Host and Origin are rewritten to the literal loopback target so * common dev servers can still perform their normal origin checks. */ export declare function previewRequestHeaders(headers: IncomingHttpHeaders, target: SessionPreviewTarget, options?: { upgrade?: boolean; }): OutgoingHttpHeaders; export declare function createSessionPreviewProxy(options: SessionPreviewProxyOptions): { handleHttp: (req: IncomingMessage, res: ServerResponse, url: URL) => Promise; handleUpgrade: (req: IncomingMessage, socket: Duplex, head: Buffer) => boolean; }; export {}; //# sourceMappingURL=preview-proxy.d.ts.map