import { randomBytes } from "crypto" import { existsSync, readFileSync, statSync } from "fs" import { dirname, extname, isAbsolute, resolve, sep } from "path" import type { EditableDeck } from "../edit/resolve-deck" import type { InspectionElementSnapshot } from "../inspection-context/match" import { buildInspectionPrompt } from "./prompt" import { projectWorkspaceElement } from "./request" import { createInspectRequest, failInspectRequest, getInspectRequest } from "./requests" const TOKEN_BYTES = 24 const SESSION_TTL_MS = 2 * 60 * 60 * 1000 const IDLE_STOP_MS = 30 * 60 * 1000 interface InspectAsset { id: string absoluteFile: string } interface InspectSession { token: string client: any sessionID: string deck: string file: string absoluteFile: string workspaceRoot: string assets: Map assetKeys: Map nextAssetId: number lastActiveAt: number } export interface InspectServerHandle { baseUrl: string getOrCreateSession(input: { client: any; sessionID: string; workspaceRoot: string; deck: EditableDeck }): InspectServerSessionResult } export interface InspectServerSessionResult { token: string reused: boolean } let server: ReturnType | undefined let baseUrl = "" let idleTimer: Timer | undefined const sessions = new Map() export function startInspectServer(): InspectServerHandle { if (!server) { server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handleRequest }) baseUrl = `http://127.0.0.1:${server.port}` scheduleIdleStop() } return { baseUrl, getOrCreateSession(input) { cleanupExpiredSessions() const existing = findSessionForDeck(input.deck.absoluteFile) if (existing) { existing.session.client = input.client existing.session.sessionID = input.sessionID existing.session.deck = input.deck.slug existing.session.file = input.deck.file existing.session.workspaceRoot = resolve(input.workspaceRoot) return { token: existing.token, reused: true } } const token = randomBytes(TOKEN_BYTES).toString("base64url") sessions.set(token, { token, client: input.client, sessionID: input.sessionID, deck: input.deck.slug, file: input.deck.file, absoluteFile: input.deck.absoluteFile, workspaceRoot: resolve(input.workspaceRoot), assets: new Map(), assetKeys: new Map(), nextAssetId: 1, lastActiveAt: Date.now(), }) return { token, reused: false } }, } } export function stopInspectServer(): void { if (idleTimer) clearTimeout(idleTimer) idleTimer = undefined sessions.clear() server?.stop() server = undefined baseUrl = "" } async function handleRequest(req: Request): Promise { cleanupExpiredSessions() const url = new URL(req.url) if (url.pathname === "/health") return textResponse("ok") if (url.pathname === "/inspect" && req.method === "GET") { const session = validateSession(url.searchParams.get("token")) if (!session.ok) return session.response return htmlResponse(renderInspectorShell(session.value.token)) } if (url.pathname === "/deck" && req.method === "GET") { const session = validateSession(url.searchParams.get("token")) if (!session.ok) return session.response return handleDeck(session.value) } if (url.pathname === "/__revela_asset" && (req.method === "GET" || req.method === "HEAD")) { const session = validateSession(url.searchParams.get("token")) if (!session.ok) return session.response return handleAsset(session.value, url.searchParams.get("id"), req.method) } if (url.pathname === "/api/inspect" && req.method === "POST") { const session = validateSession(url.searchParams.get("token")) if (!session.ok) return session.response return handleInspect(req, session.value) } if (url.pathname === "/api/inspect-result" && req.method === "GET") { const session = validateSession(url.searchParams.get("token")) if (!session.ok) return session.response return handleInspectResult(url.searchParams.get("requestId"), session.value) } if (url.pathname === "/api/deck-version" && req.method === "GET") { const session = validateSession(url.searchParams.get("token")) if (!session.ok) return session.response return jsonResponse({ ok: true, ...readDeckVersion(session.value) }) } return textResponse("Not found", 404) } function handleDeck(session: InspectSession): Response { session.assets.clear() session.assetKeys.clear() session.nextAssetId = 1 const html = readFileSync(session.absoluteFile, "utf-8") return htmlResponse(rewriteLocalAssetRefs(html, session, session.absoluteFile, "html")) } function handleAsset(session: InspectSession, id: string | null, method: string): Response { if (!id) return textResponse("Missing asset id", 400) const asset = session.assets.get(id) if (!asset) return textResponse("Asset not found", 404) if (!existsSync(asset.absoluteFile)) return textResponse("Asset file not found", 404) if (!statSync(asset.absoluteFile).isFile()) return textResponse("Asset is not a file", 404) const mime = mimeTypeForPath(asset.absoluteFile) const headers = { "content-type": mime, "cache-control": "no-store, max-age=0" } if (method === "HEAD") return new Response(null, { status: 200, headers }) if (mime === "text/css") { const css = readFileSync(asset.absoluteFile, "utf-8") return new Response(rewriteLocalAssetRefs(css, session, asset.absoluteFile, "css"), { status: 200, headers }) } return new Response(new Uint8Array(readFileSync(asset.absoluteFile)), { status: 200, headers }) } async function handleInspect(req: Request, session: InspectSession): Promise { let body: any try { body = await req.json() } catch { return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400) } const snapshot = normalizeSnapshot(body?.snapshot ?? body) const requestId = typeof body?.requestId === "string" && body.requestId.trim() ? body.requestId.trim() : randomBytes(10).toString("base64url") const version = readDeckVersion(session).version const staleReason = typeof body?.deckVersion === "string" && body.deckVersion !== version ? "Deck changed after the browser captured this selection. Re-select the element for the freshest inspection." : undefined try { const { projection, preprocess } = projectWorkspaceElement(session.workspaceRoot, snapshot, { requestId }) createInspectRequest({ requestId, projection, deckVersion: version }) session.lastActiveAt = Date.now() scheduleIdleStop() void session.client.session.prompt({ path: { id: session.sessionID }, body: { parts: [{ type: "text", text: buildInspectionPrompt({ requestId, file: session.file, projection: staleReason ? { ...projection, stale: { stale: true, reason: staleReason } } as any : projection, }), }], }, }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error) failInspectRequest(requestId, message) }) return jsonResponse({ ok: true, requestId, deckVersion: version, status: "pending", preprocess }) } catch (error) { const message = error instanceof Error ? error.message : String(error) failInspectRequest(requestId, message) return jsonResponse({ ok: false, requestId, deckVersion: version, error: message }, 400) } } function handleInspectResult(requestId: string | null, session: InspectSession): Response { if (!requestId) return jsonResponse({ ok: false, error: "Missing requestId" }, 400) const request = getInspectRequest(requestId) if (!request) return jsonResponse({ ok: false, requestId, error: "Inspection request not found" }, 404) session.lastActiveAt = Date.now() scheduleIdleStop() if (request.status === "completed") { return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion, result: request.result }) } if (request.status === "failed" || request.status === "expired") { return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion, error: request.error || "Inspection failed" }) } return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion }) } function normalizeSnapshot(input: any): InspectionElementSnapshot { return { scope: input?.scope === "selection" || input?.scope === "slide" || input?.scope === "element" ? input.scope : undefined, slideIndex: typeof input?.slideIndex === "number" ? input.slideIndex : undefined, text: typeof input?.text === "string" ? input.text : undefined, selectedText: typeof input?.selectedText === "string" ? input.selectedText : undefined, tagName: typeof input?.tagName === "string" ? input.tagName : undefined, slideTitle: typeof input?.slideTitle === "string" ? input.slideTitle : undefined, selector: typeof input?.selector === "string" ? input.selector : undefined, domPath: typeof input?.domPath === "string" ? input.domPath : undefined, id: typeof input?.id === "string" ? input.id : undefined, classList: Array.isArray(input?.classList) ? input.classList.filter((item: unknown) => typeof item === "string") : [], role: typeof input?.role === "string" ? input.role : undefined, outerHTMLExcerpt: typeof input?.outerHTMLExcerpt === "string" ? input.outerHTMLExcerpt : undefined, nearbyText: typeof input?.nearbyText === "string" ? input.nearbyText : undefined, elements: Array.isArray(input?.elements) ? input.elements.map((item: any) => ({ text: typeof item?.text === "string" ? item.text : undefined, tagName: typeof item?.tagName === "string" ? item.tagName : undefined, slideIndex: typeof item?.slideIndex === "number" ? item.slideIndex : undefined, slideTitle: typeof item?.slideTitle === "string" ? item.slideTitle : undefined, selector: typeof item?.selector === "string" ? item.selector : undefined, domPath: typeof item?.domPath === "string" ? item.domPath : undefined, id: typeof item?.id === "string" ? item.id : undefined, classList: Array.isArray(item?.classList) ? item.classList.filter((className: unknown) => typeof className === "string") : [], role: typeof item?.role === "string" ? item.role : undefined, outerHTMLExcerpt: typeof item?.outerHTMLExcerpt === "string" ? item.outerHTMLExcerpt : undefined, nearbyText: typeof item?.nearbyText === "string" ? item.nearbyText : undefined, boundingBox: item?.boundingBox && typeof item.boundingBox === "object" ? item.boundingBox : undefined, viewport: item?.viewport && typeof item.viewport === "object" ? item.viewport : undefined, })) : undefined, boundingBox: input?.boundingBox && typeof input.boundingBox === "object" ? input.boundingBox : undefined, viewport: input?.viewport && typeof input.viewport === "object" ? input.viewport : undefined, } } function readDeckVersion(session: InspectSession): { mtimeMs: number; size: number; version: string } { const stat = statSync(session.absoluteFile) return { mtimeMs: stat.mtimeMs, size: stat.size, version: `${stat.mtimeMs}:${stat.size}` } } function findSessionForDeck(absoluteFile: string): { token: string; session: InspectSession } | undefined { for (const [token, session] of sessions) { if (session.absoluteFile === absoluteFile) return { token, session } } return undefined } function validateSession(token: string | null): { ok: true; value: InspectSession } | { ok: false; response: Response } { if (!token) return { ok: false, response: textResponse("Missing token", 401) } const session = sessions.get(token) if (!session) return { ok: false, response: textResponse("Invalid or expired token", 401) } if (Date.now() - session.lastActiveAt > SESSION_TTL_MS) { sessions.delete(token) return { ok: false, response: textResponse("Expired token", 401) } } session.lastActiveAt = Date.now() return { ok: true, value: session } } function cleanupExpiredSessions(): void { const now = Date.now() for (const [token, session] of sessions) { if (now - session.lastActiveAt > SESSION_TTL_MS) sessions.delete(token) } } function scheduleIdleStop(): void { if (idleTimer) clearTimeout(idleTimer) idleTimer = setTimeout(() => { const now = Date.now() const active = [...sessions.values()].some((session) => now - session.lastActiveAt < IDLE_STOP_MS) if (active) { scheduleIdleStop() return } sessions.clear() server?.stop() server = undefined baseUrl = "" idleTimer = undefined }, IDLE_STOP_MS) } function htmlResponse(body: string, status = 200): Response { return new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store, max-age=0" } }) } function textResponse(body: string, status = 200): Response { return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } }) } function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8" } }) } function rewriteLocalAssetRefs(content: string, session: InspectSession, sourceFile: string, contentType: "html" | "css"): string { const baseDir = dirname(sourceFile) let rewritten = rewriteCssUrls(content, session, baseDir) if (contentType === "css") return rewritten rewritten = rewriteHtmlAssetAttributes(rewritten, session, baseDir) return rewriteSrcsetAttributes(rewritten, session, baseDir) } function rewriteHtmlAssetAttributes(html: string, session: InspectSession, baseDir: string): string { const attrPattern = /\b(src|href|poster)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi return html.replace(attrPattern, (match, name: string, raw: string, doubleQuoted?: string, singleQuoted?: string, unquoted?: string) => { const value = doubleQuoted ?? singleQuoted ?? unquoted ?? "" const assetUrl = assetUrlForRef(value, session, baseDir) if (!assetUrl) return match const quote = doubleQuoted !== undefined ? '"' : singleQuoted !== undefined ? "'" : "" const escaped = quote ? assetUrl.replace(/&/g, "&") : assetUrl return `${name}=${quote}${escaped}${quote}` }) } function rewriteSrcsetAttributes(html: string, session: InspectSession, baseDir: string): string { const srcsetPattern = /\bsrcset\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi return html.replace(srcsetPattern, (match, raw: string, doubleQuoted?: string, singleQuoted?: string, unquoted?: string) => { const value = doubleQuoted ?? singleQuoted ?? unquoted ?? "" const rewritten = value.split(",").map((part) => { const trimmed = part.trim() if (!trimmed) return part const pieces = trimmed.split(/\s+/) const assetUrl = assetUrlForRef(pieces[0], session, baseDir) return assetUrl ? [assetUrl, ...pieces.slice(1)].join(" ") : part }).join(", ") if (rewritten === value) return match const quote = doubleQuoted !== undefined ? '"' : singleQuoted !== undefined ? "'" : "" const escaped = quote ? rewritten.replace(/&/g, "&") : rewritten return `srcset=${quote}${escaped}${quote}` }) } function rewriteCssUrls(content: string, session: InspectSession, baseDir: string): string { const cssUrlPattern = /url\(\s*("([^"]*)"|'([^']*)'|([^\s)]+))\s*\)/gi return content.replace(cssUrlPattern, (match, raw: string, doubleQuoted?: string, singleQuoted?: string, unquoted?: string) => { const value = doubleQuoted ?? singleQuoted ?? unquoted ?? "" const assetUrl = assetUrlForRef(value, session, baseDir) if (!assetUrl) return match const quote = doubleQuoted !== undefined ? '"' : singleQuoted !== undefined ? "'" : "" return `url(${quote}${assetUrl}${quote})` }) } function assetUrlForRef(value: string, session: InspectSession, baseDir: string): string | undefined { const trimmed = value.trim() if (!trimmed || trimmed.startsWith("#") || /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(trimmed)) return undefined const [pathPart, suffix = ""] = splitAssetSuffix(trimmed) const absoluteFile = resolveAssetPath(pathPart, session.workspaceRoot, baseDir) if (!absoluteFile) return undefined const root = resolve(session.workspaceRoot) if (absoluteFile !== root && !absoluteFile.startsWith(root.endsWith(sep) ? root : root + sep)) return undefined if (!existsSync(absoluteFile) || !statSync(absoluteFile).isFile()) return undefined const key = absoluteFile let id = session.assetKeys.get(key) if (!id) { id = String(session.nextAssetId++) session.assetKeys.set(key, id) session.assets.set(id, { id, absoluteFile }) } return `/__revela_asset?token=${encodeURIComponent(session.token)}&id=${encodeURIComponent(id)}${suffix}` } function splitAssetSuffix(value: string): [string, string] { const hashIndex = value.indexOf("#") const queryIndex = value.indexOf("?") const indexes = [hashIndex, queryIndex].filter((index) => index >= 0) if (indexes.length === 0) return [value, ""] const splitAt = Math.min(...indexes) return [value.slice(0, splitAt), value.slice(splitAt)] } function resolveAssetPath(value: string, workspaceRoot: string, baseDir: string): string | undefined { if (!value) return undefined if (value.startsWith("/")) return resolve(workspaceRoot, "." + value) return isAbsolute(value) ? resolve(value) : resolve(baseDir, value) } function mimeTypeForPath(filePath: string): string { switch (extname(filePath).toLowerCase()) { case ".html": return "text/html" case ".css": return "text/css" case ".js": return "text/javascript" case ".json": return "application/json" case ".jpg": case ".jpeg": return "image/jpeg" case ".png": return "image/png" case ".gif": return "image/gif" case ".webp": return "image/webp" case ".svg": return "image/svg+xml" case ".woff": return "font/woff" case ".woff2": return "font/woff2" case ".ttf": return "font/ttf" case ".otf": return "font/otf" default: return "application/octet-stream" } } export function renderInspectorShell(token: string): string { const encodedToken = JSON.stringify(token) return ` Evidence Inspector
` }