import { state } from "./state" import { rebuildAndBroadcast } from "./rebuild" import { remapPositions } from "./remap" import { ENTRY_EXTENSIONS, absolute, dirname } from "./mode" import { file, realpath } from "flux:fs" import type { ServerWebSocket } from "flux:http" import type { ClientEntry, ClientsResponse, LoadResponse, LogEntry, LogsResponse, MuteResponse, ReloadResponse, ShutdownResponse, StatsResponse, WatchResponse, } from "../types/control" // The control API under /__control__/: read-only introspection of connected // app clients, served next to the file routes. The MCP bridge (sol mcp) is the // primary consumer. Two shapes: server-held data answered directly (clients, // logs) and queries forwarded to a client over its websocket and correlated // back by id (tree, stats). // Ring buffer of forwarded client logs. Capped so a chatty app cannot grow the // server without bound; readers page through it with the `since` cursor. const LOG_CAP = 2000 // How long a forwarded query waits for the client's answer. Sized for a // slow device (an SM-T500 answered a settle-then-tree pair late at 5 s and // fine at the next call), not for the desktop case, which answers in ms. const QUERY_TIMEOUT_MS = 10000 // The extra a raw-format snapshot or texture read gets: the reply is the // uncompressed RGBA of the window (8+ MB base64 at 2000x1092), encoded on // the client's JS thread and sent over its websocket, which a tablet takes // seconds over. const RAW_IMAGE_EXTRA_MS = 10000 // The prefix of the client's once-a-second slow-frame warning (lattice's // draw plugin), collapsed per run in /logs like identical lines are: a // continuous animation produces one a second, and the real warning in // between is what a reader is after. const SLOW_FRAME_PREFIX = "Slow frame:" // Wall time a clock query is allowed per stepped frame on top of the base // timeout: the client answers once its steps ran, one per frame, and // waits at most about this long for each before giving up. const CLOCK_STEP_WAIT_MS = 50 const MAX_WAIT_MS = 30000 // How long a settle query waits for the app to come to rest unless the // caller says otherwise (the client's own default, stated here so the // query timeout can be stretched by it). const SETTLE_MAX_MS = 5000 // Delay between acking POST /shutdown and closing the listeners, so the // response reaches the caller before its connection goes. const SHUTDOWN_ACK_MS = 100 let logs: LogEntry[] = [] let logSeq = 0 // Pending long-poll wakeups (see handleLogs). Flushed on every append; a // waiter that already timed out resolves again harmlessly. let logWaiters: Array<() => void> = [] let nextQueryId = 1 let pendingQueries = new Map void>() function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } /// A `log` message arrived from a client: buffer it and wake long-polls. /// Bundle positions in stack traces are remapped to .tsx sources on the way in. export function appendLog(client: number, level: string, text: string) { logs.push({ seq: ++logSeq, at: Date.now(), client, level, text: remapPositions(text, state.currentMaps) }) if (logs.length > LOG_CAP) logs.splice(0, logs.length - LOG_CAP) let waiters = logWaiters logWaiters = [] for (let wake of waiters) wake() } /// The client built the first frame of the last push (its `ready` /// message), or went away: release the queries waiting for it (the push /// side is rebuild.ts pushed). export function ready(ws: ServerWebSocket) { let info = state.clients.get(ws) if (info && info.loading !== null) info.loading = false let entry = state.loading.get(ws) if (!entry) return state.loading.delete(ws) for (let wake of entry.waiters) wake() } // Wait up to `timeoutMs` for the client's first frame after a push. Resolves // with null once it is there (or was never pending), or with the error // Response to answer instead when the wait ran out. async function awaitFirstFrame(ws: ServerWebSocket, timeoutMs: number): Promise { let entry = state.loading.get(ws) if (!entry) return null let woken = new Promise((resolve) => entry.waiters.push(() => resolve(true))) let done = await Promise.race([woken, sleep(timeoutMs).then(() => false)]) if (done) return null return Response.json( { error: `The app is still loading: no frame built since the push ${Date.now() - entry.since} ms ago. Retry, or read the logs for a startup error.`, }, { status: 503 }, ) } /// A `result` message arrived from a client: hand it to the awaiting query. export function resolveQuery(msg: { id?: number }) { if (typeof msg.id !== "number") return let resolve = pendingQueries.get(msg.id) if (resolve) { pendingQueries.delete(msg.id) resolve(msg) } } // The connected-client list. `withAddress` adds each socket's peer address for // the internal API (the repl `list` display); the public control shape stays // without it. export function clientList(withAddress = false): (ClientEntry & { address?: string | null })[] { return [...state.clients.entries()].map(([ws, info]) => ({ id: info.id, platform: info.platform, version: info.version, profile: info.profile, capabilities: info.capabilities, queries: info.queries, stats: info.stats, timeScale: info.timeScale, loading: info.loading, clientDir: info.clientDir, pid: info.pid, execPath: info.execPath, host: info.host, os: info.os, kernel: info.kernel, videoDriver: info.videoDriver, refreshRate: info.refreshRate, gpu: info.gpu, ...(withAddress ? { address: ws.remoteAddr ?? null } : {}), })) } // Resolve the target client for a query: an explicit ?client=, or the only // connected client when the parameter is omitted. function findClient(param: string | undefined): { ws: ServerWebSocket } | { error: Response } { let entries = [...state.clients.entries()] if (param === undefined) { if (entries.length === 1) return { ws: entries[0]![0] } if (entries.length === 0) return { error: Response.json({ error: "No connected clients" }, { status: 503 }) } return { error: Response.json({ error: "Multiple clients connected; pass a client id (list_clients has the ids)" }, { status: 400 }) } } let id = parseInt(param, 10) let entry = entries.find(([, info]) => info.id === id) if (!entry) { let ids = entries.map(([, info]) => info.id) return { error: Response.json( { error: `Client ${param} is gone (connected ids: ${ids.length ? ids.join(", ") : "none"}). ` + "Ids reset when the dev server restarts; call list_clients for current ones.", }, { status: 404 }, ), } } return { ws: entry[0] } } // Optional crop rect shared by /snapshot and /texture: all four of // x/y/width/height in captured/texture pixels, or none. Returns undefined // when absent, a 400 Response when malformed. function parseRect(query: Map): { x: number; y: number; width: number; height: number } | Response | undefined { let params = ["x", "y", "width", "height"].map((k) => query.get(k)) if (params.every((v) => v === undefined)) return undefined let [x, y, width, height] = params.map((v) => parseInt(v ?? "", 10)) if (![x, y, width, height].every(Number.isFinite)) return Response.json({ error: "Crop rect requires all of x, y, width, height" }, { status: 400 }) return { x: x!, y: y!, width: width!, height: height! } } // Optional output format shared by /snapshot and /texture: "png" (default, // the reply carries pngBase64) or "raw" (rgbaBase64: RGBA8 bytes, rows // top-down, no decoder needed for pixel assertions). Returns undefined for // the default, or a 400 Response. function parseFormat(query: Map): "raw" | undefined | Response { let param = query.get("format") if (param === undefined || param === "png") return undefined if (param === "raw") return "raw" return Response.json({ error: 'Format must be "png" or "raw"' }, { status: 400 }) } // Optional integer magnification shared by /snapshot and /texture. Returns // undefined when absent or 1, a 400 Response when out of range. function parseScale(query: Map): number | Response | undefined { let param = query.get("scale") if (param === undefined) return undefined let scale = parseInt(param, 10) if (!Number.isFinite(scale) || scale < 1 || scale > 8) return Response.json({ error: "Scale must be an integer between 1 and 8" }, { status: 400 }) return scale === 1 ? undefined : scale } // Forward a query to one client and wait for its answer: the data it // replied with, or the error Response to send instead (no such client, a // timeout, or the client's own error). async function queryClient( ws: ServerWebSocket, kind: string, extra?: Record, timeoutMs: number = QUERY_TIMEOUT_MS, ): Promise<{ data: any } | { error: Response }> { // An engine still starting after a push answers nothing on its JS // thread: wait for its first frame first, within the same budget. let notReady = await awaitFirstFrame(ws, timeoutMs) if (notReady) return { error: notReady } let id = nextQueryId++ let reply = new Promise((resolve) => { pendingQueries.set(id, resolve) }) ws.send(JSON.stringify({ type: "query", kind, id, ...extra })) let msg = await Promise.race([reply, sleep(timeoutMs)]) pendingQueries.delete(id) if (!msg) return { error: Response.json( { error: `Query timed out after ${timeoutMs} ms: the client is connected but did not answer. A slow device or a heavy query (a raw snapshot of a large window) usually answers on retry; a repeat means its JS thread is busy or the app is wedged.`, }, { status: 504 }, ), } // Error strings may carry stack traces (e.g. a debug command threw); remap // bundle positions to .tsx sources like appendLog does for forwarded logs. if (msg.error) return { error: Response.json({ error: remapPositions(String(msg.error), state.currentMaps) }, { status: 502 }) } return { data: msg.data } } async function handleQuery( query: Map, kind: string, extra?: Record, timeoutMs: number = QUERY_TIMEOUT_MS, ): Promise { let target = findClient(query.get("client")) if ("error" in target) return target.error let result = await queryClient(target.ws, kind, extra, timeoutMs) return "error" in result ? result.error : Response.json(result.data) } // Whether two consecutive texts belong to one run: identical, or both the // client's slow-frame warning (the figures differ per frame, the warning is // the same). function sameRun(a: string, b: string): boolean { return a === b || (a.startsWith(SLOW_FRAME_PREFIX) && b.startsWith(SLOW_FRAME_PREFIX)) } // Merge runs of consecutive identical entries (same client, level, text) into // one entry carrying `repeats` and the run's last seq/at, so 176 copies of one // error read as a single line and a `since` cursor still skips the whole run. // A run of slow-frame warnings collapses the same way, to its latest line. function collapseRepeats(entries: LogEntry[]): (LogEntry & { repeats?: number })[] { let out: (LogEntry & { repeats?: number })[] = [] for (let e of entries) { let last = out[out.length - 1] if (last && last.client === e.client && last.level === e.level && sameRun(last.text, e.text)) { last.repeats = (last.repeats ?? 1) + 1 last.seq = e.seq last.at = e.at // A slow-frame run reads as its latest line, the figures of the // frame that just went over: the run is one entry, not one per second. last.text = e.text } else { out.push({ ...e }) } } return out } // GET /__control__/logs?since=N&wait=MS&level=L1,L2&contains=TEXT&client=ID: // entries with seq > since, plus the latest seq as the next cursor and the // server generation. `client` keeps one client's entries (every client's // without); `level` keeps only the listed levels; `contains` keeps entries // whose text has the substring (case-insensitive). Consecutive identical // entries come back collapsed with a `repeats` count. With `wait`, holds the // response until an entry passes the filters or the timeout expires // (long-poll), so a caller can follow the stream without tight polling. async function handleLogs(query: Map): Promise { let since = parseInt(query.get("since") ?? "0", 10) || 0 let wait = Math.min(parseInt(query.get("wait") ?? "0", 10) || 0, MAX_WAIT_MS) let levels = query .get("level") ?.split(",") .map((l) => l.trim()) .filter(Boolean) let contains = query.get("contains")?.toLowerCase() // The client filter takes any id, connected or not: a client that crashed // is gone from /clients but its output is what the caller wants to read. // Only a malformed id is refused, instead of silently disabling the filter. let client: number | undefined let clientParam = query.get("client") if (clientParam !== undefined) { client = parseInt(clientParam, 10) if (!Number.isFinite(client)) return Response.json({ error: "Client must be a numeric id" }, { status: 400 }) } let select = () => logs.filter( (e) => e.seq > since && (client === undefined || e.client === client) && (!levels || levels.length === 0 || levels.includes(e.level)) && (!contains || e.text.toLowerCase().includes(contains)), ) let entries = select() // Filtered long-poll: an append may not pass the filters, so keep waiting // until one does or the deadline runs out. let deadline = Date.now() + wait while (entries.length === 0 && Date.now() < deadline) { await new Promise((resolve) => { let timer = setTimeout(resolve, deadline - Date.now()) logWaiters.push(() => { clearTimeout(timer) resolve() }) }) entries = select() } let body: LogsResponse = { entries: collapseRepeats(entries), latest: logSeq, generation: state.generation } return Response.json(body) } // Whether `path` lies under `root` (both canonical), never equal to it. function isUnder(path: string, root: string): boolean { return path.length > root.length && path.startsWith(root) && (path[root.length] === "/" || path[root.length] === "\\") } // Load (or switch) the app entry and push it: the /load route (sol mcp's // load tool) and the repl's `load`. Moves the rebuild entry, then reuses the // reload path, so later reloads rebuild the new file. A project server stays // inside its project (the bundle resolves the project's dependencies and // assets, and the key keeps naming the project); a file server takes any // file, moving the file routes and the bundler's cwd along with the entry. // The key never moves: it names what the server was started for, and // /clients reports the entry next to it. `status` is the HTTP status the // error maps to (400 for a bad request, 502 for a failed build). export async function loadEntry(requested: string): Promise<{ entry: string } | { error: string; status: number }> { if (!ENTRY_EXTENSIONS.some((ext) => requested.endsWith(ext))) { return { error: `Not an app entry: ${requested} (expected .tsx, .jsx, .ts, .js or .sol.js)`, status: 400 } } let config = state.config let path = absolute(requested, config.projectDir ?? config.sourceDir) if (!(await file(path).exists())) return { error: `Entry not found: ${path}`, status: 400 } let entry = await realpath(path) if (config.projectDir && !isUnder(entry, config.projectDir)) { return { error: `Entry is outside the project: ${entry} is not under ${config.projectDir}. A project server only bundles sources inside its project; start sol for that file on its own.`, status: 400, } } config.entry = entry config.entryArgs[0] = entry if (!config.projectDir) { config.sourceDir = dirname(entry) config.cwd = config.sourceDir } console.log(`[cli] Loading ${entry}`) let error = await rebuildAndBroadcast() if (error) return { error, status: 502 } return { entry } } // Mute or unmute the user's own input on every client (sol mcp's // mute_user_input/unmute_user_input, the repl's `mute`): while muted, a // measurement or an interaction test is not disturbed by a stray click; // synthetic /input still goes through. Latched for clients joining while // muted (the welcome message) and broadcast to the connected ones; no ack, // the mute takes effect on arrival. export function setUserInputMuted(on: boolean) { if (on !== state.userInputMuted) { console.log(on ? "[cli] User input muted on every client" : "[cli] User input unmuted") } state.userInputMuted = on let text = JSON.stringify({ type: "mute", active: on }) for (let ws of state.clients.keys()) ws.send(text) } // Pause or resume reload-on-save (sol mcp's pause_watch/resume_watch, the // repl's `watch`): paused, an agent's saves are not pushed while it edits; // its explicit /reload is. Changes made while paused are not replayed on // resume. // The server's orderly shutdown, registered by main.ts where it lives next // to the handles it releases: `release` drops the registry record and the // local client (what a caller may act on once acked), `closeListeners` ends // the listeners (after the ack has gone out). type ShutdownHooks = { release: () => Promise; closeListeners: () => Promise } let shutdownHooks: ShutdownHooks | null = null export function onShutdownRequest(hooks: ShutdownHooks) { shutdownHooks = hooks } export function setWatchActive(on: boolean) { if (on === state.watchPaused) { console.log(on ? "[cli] Reload on save resumed" : "[cli] Reload on save paused") } state.watchPaused = !on } // Toggle the stats overlay on one client, or on every client (the repl's // `stats`) - then it is also the server's setting, which the welcome message // carries to clients joining later. Each client's entry remembers its own // state, so /clients reports what is actually drawn where. export function setStats(on: boolean, ws?: ServerWebSocket) { let text = JSON.stringify({ type: "stats", stats: on }) let targets = ws ? [ws] : [...state.clients.keys()] if (!ws) state.stats = on for (let target of targets) { let info = state.clients.get(target) if (info) info.stats = on target.send(text) } return targets.length } // Every endpoint the switch below handles, kept in step with its cases: // the 404 lists them so a caller with a wrong path (or a guessed shape // like /debug/ for /debug?name=) learns the real surface // from the error instead of the docs. Per-endpoint 400s teach the args. const CONTROL_ENDPOINTS = [ "/__control__/clients", "/__control__/logs", "/__control__/tree", "/__control__/stats", "/__control__/snapshot", "/__control__/gpu", "/__control__/debug", "/__control__/link", "/__control__/texture", "/__control__/clock", "/__control__/input", "/__control__/settle", "/__control__/buffer", "/__control__/reload", "/__control__/load", "/__control__/mute", "/__control__/watch", "/__control__/shutdown", ] export async function handleControl(req: Request, path: string, query: Map): Promise { switch (path) { case "/__control__/clients": // `key`/`mode`/`entry` identify what this server serves, for a caller // that resolved it from the registry and wants to confirm the match. let body: ClientsResponse = { generation: state.generation, key: state.config.key, mode: state.config.mode, entry: state.config.entry, projectDir: state.config.projectDir, userInputMuted: state.userInputMuted, watchPaused: state.watchPaused, clients: clientList(), } return Response.json(body) case "/__control__/logs": return handleLogs(query) case "/__control__/tree": { let extra: Record = {} let root = parseInt(query.get("root") ?? "", 10) if (Number.isFinite(root)) extra.root = root let depth = parseInt(query.get("depth") ?? "", 10) if (Number.isFinite(depth)) extra.depth = depth let q = query.get("query") if (q) extra.query = q // at=,: the nodes a pointer at that window point reaches. let at = query.get("at") if (at !== undefined) { let [x, y] = at.split(",").map(Number) if (at.split(",").length !== 2 || !Number.isFinite(x) || !Number.isFinite(y)) { return Response.json({ error: "Invalid at: expected , in logical points" }, { status: 400 }) } extra.at = { x, y } } if (query.get("props") === "true") extra.props = true return handleQuery(query, "tree", extra) } case "/__control__/stats": { // GET reads one client's statistics; POST switches the overlay, on one // client with ?client= or on all of them without. if (req.method === "POST") { let active = query.get("active") if (active !== "true" && active !== "false") { return Response.json({ error: "Stats requires ?active=true or ?active=false" }, { status: 400 }) } let on = active === "true" let target = query.get("client") let ws: ServerWebSocket | undefined if (target !== undefined) { let found = findClient(target) if ("error" in found) return found.error ws = found.ws } let body: StatsResponse = { ok: true, active: on, clients: setStats(on, ws) } return Response.json(body) } let extra: Record = {} let windowMs = parseInt(query.get("window") ?? "", 10) if (Number.isFinite(windowMs)) extra.windowMs = windowMs let windowFrames = parseInt(query.get("frames") ?? "", 10) if (Number.isFinite(windowFrames)) extra.windowFrames = windowFrames return handleQuery(query, "stats", extra) } case "/__control__/snapshot": { let nodeId = parseInt(query.get("node") ?? "", 10) if (!Number.isFinite(nodeId)) return Response.json({ error: "Snapshot requires ?node=" }, { status: 400 }) let extra: Record = { nodeId } let rect = parseRect(query) if (rect instanceof Response) return rect if (rect) extra.rect = rect let scale = parseScale(query) if (scale instanceof Response) return scale if (scale) extra.scale = scale let format = parseFormat(query) if (format instanceof Response) return format if (format) extra.format = format // step=1: advance one frame of a paused clock and capture inside it - // the frame as its own code drew it (the client refuses it while the // clock runs). let step = query.get("step") if (step !== undefined) { if (step !== "1" && step !== "true") return Response.json({ error: 'Step must be "1" or "true"' }, { status: 400 }) extra.step = true } return handleQuery(query, "snapshot", extra, QUERY_TIMEOUT_MS + (format === "raw" ? RAW_IMAGE_EXTRA_MS : 0)) } case "/__control__/gpu": { // ?label= keeps only resources created with exactly that label; // ?draw= reports that draw entry's params in full (matrix-valued // params are elided everywhere else). let extra: Record = {} let label = query.get("label") if (label !== undefined) extra.label = label let draw = parseInt(query.get("draw") ?? "", 10) if (Number.isFinite(draw)) extra.draw = draw // ?lose=1 forces a GL context loss instead of reading the inventory // (the recovery's test hook, ANGLE only); the reply's `forced` says // whether the driver had it. if (query.get("lose") === "1" || query.get("lose") === "true") extra.lose = true return handleQuery(query, "gpu", extra) } case "/__control__/debug": { // GET lists the app's registered debug commands; POST calls one, with // an optional JSON body as its args. if (req.method !== "POST") return handleQuery(query, "debug_list") let name = query.get("name") if (!name) return Response.json({ error: "Debug call requires ?name=" }, { status: 400 }) let args: unknown = null try { args = await req.json() } catch {} return handleQuery(query, "debug_call", { name, args }) } case "/__control__/dialog": { // GET reads the file dialog state (the request waiting for an answer, // the last one with its answer, the queue); POST answers the waiting // dialog or queues the answer for the next one, the JSON body being // { files: [path, ...] }, { directory: path } or { cancel: true }. if (req.method !== "POST") return handleQuery(query, "dialog") let answer: unknown = null try { answer = await req.json() } catch { return Response.json({ error: "Dialog answer requires a JSON body: { files: [...] }, { directory } or { cancel: true }" }, { status: 400 }) } return handleQuery(query, "dialog_answer", { answer }) } case "/__control__/link": { // GET reads the location the app reported (reportLocation from // sol:dev; null without one); POST ?link= delivers a link to // the app exactly as an OS-routed one arrives (the raw string on // onLink), and answers whether anything listened. if (req.method !== "POST") return handleQuery(query, "location") let link = query.get("link") if (!link) return Response.json({ error: "Link delivery requires ?link=" }, { status: 400 }) return handleQuery(query, "link", { link }) } case "/__control__/texture": { let textureId = parseInt(query.get("id") ?? "", 10) if (!Number.isFinite(textureId)) return Response.json({ error: "Texture requires ?id=" }, { status: 400 }) let extra: Record = { textureId } let rect = parseRect(query) if (rect instanceof Response) return rect if (rect) extra.rect = rect let scale = parseScale(query) if (scale instanceof Response) return scale if (scale) extra.scale = scale let format = parseFormat(query) if (format instanceof Response) return format if (format) extra.format = format return handleQuery(query, "texture", extra, QUERY_TIMEOUT_MS + (format === "raw" ? RAW_IMAGE_EXTRA_MS : 0)) } case "/__control__/clock": { // Clock control: POST ?scale= sets the client's time scale (0 // pauses), ?step= advances n frames while paused. Applied by the // client runtime; the reply carries the resulting clock state. A // GET reads that state without touching it - `pendingSteps` says // whether stepped frames have all been applied yet. if (req.method !== "POST" && req.method !== "GET") return Response.json({ error: "Clock requires GET or POST" }, { status: 405 }) let extra: Record = {} if (req.method === "GET") { let target = findClient(query.get("client")) if ("error" in target) return target.error let result = await queryClient(target.ws, "clock", extra) if ("error" in result) return result.error return Response.json(result.data) } let scaleParam = query.get("scale") if (scaleParam !== undefined) { let scale = parseFloat(scaleParam) if (!Number.isFinite(scale) || scale < 0) return Response.json({ error: "Clock scale must be a number >= 0" }, { status: 400 }) extra.scale = scale } let stepParam = query.get("step") let steps = 0 if (stepParam !== undefined) { let step = parseInt(stepParam, 10) if (!Number.isFinite(step) || step < 1 || step > 1000) return Response.json({ error: "Clock step must be an integer between 1 and 1000" }, { status: 400 }) extra.step = step steps = step } if (!("scale" in extra) && !("step" in extra)) return Response.json({ error: "Clock requires ?scale= or ?step=" }, { status: 400 }) // The reply is the clock as the client now has it, sent once its // stepped frames ran (so a read right after sees the stepped state); // its scale is kept on the client's entry so /clients reports it (a // push resets it). let target = findClient(query.get("client")) if ("error" in target) return target.error let result = await queryClient(target.ws, "clock", extra, QUERY_TIMEOUT_MS + steps * CLOCK_STEP_WAIT_MS) if ("error" in result) return result.error let info = state.clients.get(target.ws) if (info && typeof result.data?.scale === "number") info.timeScale = result.data.scale return Response.json(result.data) } case "/__control__/input": { // Synthetic input injection: POST {events: [...]} forwards a timed // event sequence to the client, which feeds it through the real input // pipeline. Shape checks only here - the runtime validates each event // and rejects the whole sequence on any bad one. The query timeout // stretches by the sequence's own delays, since the client replies // only after the last event has been sent. if (req.method !== "POST") return Response.json({ error: "Input requires POST" }, { status: 405 }) let body: any = null try { body = await req.json() } catch {} let events = body?.events if (!Array.isArray(events) || events.length === 0) return Response.json({ error: "Input requires a body {events: [...]} with at least one event" }, { status: 400 }) if (events.length > 200) return Response.json({ error: "Input sequences are capped at 200 events" }, { status: 400 }) let totalMs = 0 for (let e of events) { if (typeof e !== "object" || e === null) return Response.json({ error: "Each event must be an object" }, { status: 400 }) for (let f of ["delayMs", "holdMs"]) { let v = e[f] if (v !== undefined) { if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 5000) return Response.json({ error: `Event ${f} must be an integer between 0 and 5000` }, { status: 400 }) totalMs += v } } } if (totalMs > 30000) return Response.json({ error: "Input sequence too long: delays and holds total over 30000 ms" }, { status: 400 }) return handleQuery(query, "input", { events }, QUERY_TIMEOUT_MS + totalMs) } case "/__control__/settle": { // Wait for the app to come to rest: nothing in flight, no timer due, // no frame demanded. ?max= bounds the wait (wall time); the reply // says whether it settled and, if not, what is left. ?frames=0 waits // for the app's work alone, whatever animates. The client does the // waiting, so the query timeout stretches by the cap. let maxMs = SETTLE_MAX_MS let maxParam = query.get("max") if (maxParam !== undefined) { maxMs = parseInt(maxParam, 10) if (!Number.isFinite(maxMs) || maxMs < 0 || maxMs > MAX_WAIT_MS) return Response.json({ error: `Settle max must be an integer between 0 and ${MAX_WAIT_MS}` }, { status: 400 }) } let frames = true let framesParam = query.get("frames") if (framesParam !== undefined) { if (framesParam === "0" || framesParam === "false") frames = false else if (framesParam !== "1" && framesParam !== "true") return Response.json({ error: "Settle frames must be 0 or 1" }, { status: 400 }) } return handleQuery(query, "settle", { maxMs, frames }, QUERY_TIMEOUT_MS + maxMs) } case "/__control__/buffer": { let bufferId = parseInt(query.get("id") ?? "", 10) if (!Number.isFinite(bufferId)) return Response.json({ error: "Buffer requires ?id=" }, { status: 400 }) let extra: Record = { bufferId } let byteOffset = parseInt(query.get("offset") ?? "", 10) if (Number.isFinite(byteOffset)) extra.byteOffset = byteOffset let length = parseInt(query.get("length") ?? "", 10) if (Number.isFinite(length)) extra.length = length let as = query.get("as") if (as !== undefined) extra.as = as return handleQuery(query, "buffer", extra) } case "/__control__/reload": { // Explicit rebuild-and-push, the way a coding agent applies its edits // (sol mcp's reload tool): a burst of edits collapses into one reload, // with reload-on-save paused meanwhile (/watch). if (req.method !== "POST") return Response.json({ error: "Reload requires POST" }, { status: 405 }) let error = await rebuildAndBroadcast() if (error) return Response.json({ error }, { status: 502 }) let body: ReloadResponse = { ok: true, clients: state.clients.size } return Response.json(body) } case "/__control__/load": { if (req.method !== "POST") return Response.json({ error: "Load requires POST" }, { status: 405 }) let requested = (await req.json().catch(() => null))?.entry if (typeof requested !== "string" || !requested) { return Response.json({ error: "Load requires { entry: }" }, { status: 400 }) } let result = await loadEntry(requested) if ("error" in result) return Response.json({ error: result.error }, { status: result.status }) let body: LoadResponse = { ok: true, entry: result.entry, clients: state.clients.size } return Response.json(body) } case "/__control__/mute": { if (req.method !== "POST") return Response.json({ error: "Mute requires POST" }, { status: 405 }) let active = query.get("active") if (active !== "true" && active !== "false") { return Response.json({ error: "Mute requires ?active=true or ?active=false" }, { status: 400 }) } let on = active === "true" setUserInputMuted(on) let body: MuteResponse = { ok: true, active: on, clients: state.clients.size } return Response.json(body) } case "/__control__/watch": { if (req.method !== "POST") return Response.json({ error: "Watch requires POST" }, { status: 405 }) let active = query.get("active") if (active !== "true" && active !== "false") { return Response.json({ error: "Watch requires ?active=true or ?active=false" }, { status: 400 }) } let on = active === "true" setWatchActive(on) let body: WatchResponse = { ok: true, active: on } return Response.json(body) } case "/__control__/shutdown": { // Orderly server exit over the wire, the counterpart of the repl's // `quit`: releases the record and the local client first, acks, and // closes the listeners after a short delay so the response gets out. // Acking after the release means a caller that starts the next server // on the ack never meets this one's record. Address by port: this // only ever reaches the server whose port the caller was given. if (req.method !== "POST") return Response.json({ error: "Shutdown requires POST" }, { status: 405 }) if (!shutdownHooks) return Response.json({ error: "Shutdown is not available on this server" }, { status: 503 }) console.log("[cli] Shutdown requested over the control API") let hooks = shutdownHooks await hooks.release() setTimeout(() => void hooks.closeListeners(), SHUTDOWN_ACK_MS) let body: ShutdownResponse = { ok: true } return Response.json(body) } default: return Response.json({ error: "Unknown control endpoint " + path, endpoints: CONTROL_ENDPOINTS }, { status: 404 }) } }