import { randomBytes } from "crypto" import { existsSync, readFileSync, statSync } from "fs" import { fileURLToPath } from "url" import { dirname, extname, isAbsolute, resolve, sep } from "path" import { ctx } from "../ctx" import { buildPrompt } from "../prompt-builder" import type { EditableDeck } from "./resolve-deck" import { buildEditPrompt, type EditCommentPayload } from "./prompt" const TOKEN_BYTES = 24 const SESSION_TTL_MS = 2 * 60 * 60 * 1000 const IDLE_STOP_MS = 30 * 60 * 1000 export const LIVE_EDITOR_IDLE_MS = 10 * 1000 interface EditAsset { id: string absoluteFile: string } interface EditSession { token: string client: any sessionID: string deck: string file: string absoluteFile: string workspaceRoot: string assets: Map assetKeys: Map nextAssetId: number createdAt: number lastActiveAt: number } export interface EditServerHandle { baseUrl: string getOrCreateSession(input: { client: any; sessionID: string; workspaceRoot: string; deck: EditableDeck }): EditServerSessionResult } export interface EditServerSessionResult { token: string reused: boolean live: boolean } let server: ReturnType | undefined let baseUrl = "" let idleTimer: Timer | undefined const sessions = new Map() export function startEditServer(): EditServerHandle { 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, live: isSessionLive(existing.session), } } 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, createdAt: Date.now(), lastActiveAt: Date.now(), }) return { token, reused: false, live: false } }, } } export function hasLiveEditorSession(deck: EditableDeck, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean { cleanupExpiredSessions() const existing = findSessionForDeck(deck.absoluteFile) return existing ? isSessionLive(existing.session, maxIdleMs) : false } export function hasLiveEditorSessionForFile(workspaceRoot: string, filePath: string, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean { if (!filePath) return false const root = resolve(workspaceRoot) const absoluteFile = resolve(root, filePath) if (absoluteFile !== root && !absoluteFile.startsWith(root.endsWith(sep) ? root : root + sep)) return false cleanupExpiredSessions() const existing = findSessionForDeck(absoluteFile) return existing ? isSessionLive(existing.session, maxIdleMs) : false } export function stopEditServer(): void { if (idleTimer) clearTimeout(idleTimer) idleTimer = undefined sessions.clear() server?.stop() server = undefined baseUrl = "" } function findSessionForDeck(absoluteFile: string): { token: string; session: EditSession } | undefined { for (const [token, session] of sessions) { if (session.absoluteFile === absoluteFile) return { token, session } } return undefined } function isSessionLive(session: EditSession, maxIdleMs = LIVE_EDITOR_IDLE_MS): boolean { return Date.now() - session.lastActiveAt <= maxIdleMs } async function handleRequest(req: Request): Promise { cleanupExpiredSessions() const url = new URL(req.url) if (url.pathname === "/health") return textResponse("ok") if (url.pathname === "/edit" && req.method === "GET") { const session = validateSession(url.searchParams.get("token")) if (!session.ok) return session.response return htmlResponse(renderEditorShell(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/comment" && req.method === "POST") { const session = validateSession(url.searchParams.get("token")) if (!session.ok) return session.response return handleComment(req, 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 handleDeckVersion(session.value) } return textResponse("Not found", 404) } function handleDeck(session: EditSession): Response { session.assets.clear() session.assetKeys.clear() session.nextAssetId = 1 const html = readFileSync(session.absoluteFile, "utf-8") return htmlResponse(rewriteLocalAssetRefs(html, { session, sourceFile: session.absoluteFile, contentType: "html", })) } function handleAsset(session: EditSession, 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, sourceFile: asset.absoluteFile, contentType: "css", }), { status: 200, headers }) } return new Response(new Uint8Array(readFileSync(asset.absoluteFile)), { status: 200, headers }) } function rewriteLocalAssetRefs(content: string, input: { session: EditSession; sourceFile: string; contentType: "html" | "css" }): string { const baseDir = dirname(input.sourceFile) let rewritten = rewriteCssUrls(content, input.session, baseDir) if (input.contentType === "css") return rewritten rewritten = rewriteHtmlAssetAttributes(rewritten, input.session, baseDir) rewritten = rewriteSrcsetAttributes(rewritten, input.session, baseDir) return rewritten } function rewriteHtmlAssetAttributes(html: string, session: EditSession, 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: EditSession, 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 = rewriteSrcset(value, session, baseDir) if (rewritten === value) return match const quote = doubleQuoted !== undefined ? '"' : singleQuoted !== undefined ? "'" : "" const escaped = quote ? rewritten.replace(/&/g, "&") : rewritten return `srcset=${quote}${escaped}${quote}` }) } function rewriteSrcset(value: string, session: EditSession, baseDir: string): string { return value.split(",").map((part) => { const trimmed = part.trim() if (!trimmed) return part const pieces = trimmed.split(/\s+/) const assetUrl = assetUrlForRef(pieces[0], session, baseDir) if (!assetUrl) return part return [assetUrl, ...pieces.slice(1)].join(" ") }).join(", ") } function rewriteCssUrls(content: string, session: EditSession, 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 return `url("${assetUrl.replace(/"/g, "%22")}")` }) } function assetUrlForRef(ref: string, session: EditSession, baseDir: string): string | null { const absoluteFile = resolveLocalAssetRef(ref, session.workspaceRoot, baseDir) if (!absoluteFile || !existsSync(absoluteFile) || !statSync(absoluteFile).isFile()) return null const key = resolve(absoluteFile) let id = session.assetKeys.get(key) if (!id) { id = String(session.nextAssetId++) session.assetKeys.set(key, id) session.assets.set(id, { id, absoluteFile: key }) } return `/__revela_asset?token=${encodeURIComponent(session.token)}&id=${encodeURIComponent(id)}` } function resolveLocalAssetRef(ref: string, workspaceRoot: string, baseDir: string): string | null { const trimmed = ref.trim() if (!trimmed || isSkippedAssetRef(trimmed)) return null const pathPart = stripQueryAndHash(trimmed) if (!pathPart) return null if (pathPart.startsWith("file://")) { try { return resolve(fileURLToPath(pathPart)) } catch { return null } } const decodedPath = safeDecodeUri(pathPart) if (isWindowsAbsolutePath(decodedPath)) return resolve(decodedPath) if (isAbsolute(decodedPath)) { const absolute = resolve(decodedPath) if (existsSync(absolute)) return absolute return resolve(workspaceRoot, `.${decodedPath}`) } return resolve(baseDir, decodedPath) } function stripQueryAndHash(ref: string): string { const hashIndex = ref.indexOf("#") const withoutHash = hashIndex >= 0 ? ref.slice(0, hashIndex) : ref const queryIndex = withoutHash.indexOf("?") return queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash } function safeDecodeUri(value: string): string { try { return decodeURI(value) } catch { return value } } function isSkippedAssetRef(ref: string): boolean { return /^(?:https?:|data:|blob:|mailto:|tel:|javascript:|#)/i.test(ref) || ref.startsWith("//") } function isWindowsAbsolutePath(value: string): boolean { return /^[a-zA-Z]:[\\/]/.test(value) } function mimeTypeForPath(filePath: string): string { switch (extname(filePath).toLowerCase()) { case ".html": case ".htm": return "text/html; charset=utf-8" case ".css": return "text/css" case ".js": return "application/javascript" 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" case ".mp4": return "video/mp4" case ".webm": return "video/webm" case ".mp3": return "audio/mpeg" case ".wav": return "audio/wav" default: return "application/octet-stream" } } function handleDeckVersion(session: EditSession): Response { try { const version = readDeckVersion(session) session.lastActiveAt = Date.now() scheduleIdleStop() return jsonResponse({ ok: true, ...version }) } catch (error) { const message = error instanceof Error ? error.message : String(error) return jsonResponse({ ok: false, error: message }, 404) } } function readDeckVersion(session: EditSession): { mtimeMs: number; size: number; version: string } { const stat = statSync(session.absoluteFile) const version = `${stat.mtimeMs}:${stat.size}` return { mtimeMs: stat.mtimeMs, size: stat.size, version } } async function handleComment(req: Request, session: EditSession): Promise { let body: Partial try { body = await req.json() } catch { return jsonResponse({ ok: false, error: "Invalid JSON body" }, 400) } const comments = Array.isArray(body.comments) ? body.comments .map((draft: any) => ({ comment: typeof draft?.comment === "string" ? draft.comment.trim() : "", elements: Array.isArray(draft?.elements) ? draft.elements : [], })) .filter((draft) => draft.comment && draft.elements.length > 0) : [] const comment = typeof body.comment === "string" ? body.comment.trim() : "" const elements = Array.isArray(body.elements) ? body.elements : [] if (!comment && comments.length === 0) return jsonResponse({ ok: false, error: "Comment is required" }, 400) ctx.enabled = true buildPrompt({ mode: "deck-render" }) const prompt = buildEditPrompt({ ...body, deck: session.deck, file: session.file, comment, elements, comments, }) const deckVersion = readDeckVersion(session).version await session.client.session.prompt({ path: { id: session.sessionID }, body: { parts: [{ type: "text", text: prompt }], }, }) session.lastActiveAt = Date.now() scheduleIdleStop() return jsonResponse({ ok: true, deckVersion }) } function validateSession(token: string | null): { ok: true; value: EditSession } | { 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" }, }) } export function renderEditorShell(token: string): string { const encodedToken = JSON.stringify(token) return ` Revela Edit
` }