// The sol dev server as a flux script, complete on its own: started as // `flux server.js [flags]` from a project root (or with a file), by `sol run` // / `sol server` (bun launchers that only resolve the binaries and spawn it) // or by the console. It resolves its mode (mode.ts), owns everything with a // lifetime - the port (bound here: remembered, given, or the first free one), // the registry record, the local client, the latched bundle, the control API - // and spawns the two things only bun can do, the bundle (`sol bundle --json`, // rebuild.ts) and the startup typecheck (`sol check`), by command name // (okf/done/sol-command-folders.md). import { serve } from "flux:http" import type { FluxRequest, Server, ServerWebSocket } from "flux:http" import { file, realpath } from "flux:fs" import { exit, on as onSignal } from "flux:process" import { join, resolveWithin } from "flux:path" import { command } from "flux:subprocess" import { interfaces, probe } from "flux:net" import type { Child } from "flux:subprocess" import type { GpuDeviceLimits } from "../types/control" import { state } from "./state" import { fail, parseArgs } from "./args" import type { ServerConfig } from "./config" import { absolute, resolveMode, sourceDirOf } from "./mode" import { requireBinary, solCommand } from "./binaries" import * as cache from "./cache" import { handleProxy } from "./proxy" import { appendLog, handleControl, onShutdownRequest, ready, resolveQuery } from "./control" import { crashReason } from "./crash" import { printIdentity } from "./identity" import { createTunnelEndpoint, TUNNEL_PROTOCOL } from "./tunnel" import { pushed, rebuildAndBroadcast, showBuildFailure } from "./rebuild" import { stopWatcher } from "./watcher" import { startRepl } from "./repl" import { devDir, pruneDeadRecords, rememberedPort, removeRecord, runningFor, serverDirFor, writeRecord } from "./registry" let args = parseArgs() let mode = await resolveMode(args) // Records left behind by crashed servers are fossils; clear them first. await pruneDeadRecords() // One server per key: a second run in the same project (or on the same // file) points at the running one instead of racing it. let running = await runningFor(mode.key) if (running) { fail(`A dev server already serves ${mode.key} on port ${running.port} (pid ${running.pid}). Stop it first, or attach a client with sol client.`) } let sol = solCommand() let serverDir = await serverDirFor(mode.key) // The LAN address (for --lan): the IPv4 of the interface holding the default // route, which is the one other hosts reach; VPN and bridge interfaces (wg0, // docker0) are up too, so first-up would announce them at random. Without a // default route, the first up, non-loopback IPv4. let lanAddress = args.lan ? lanAddressOf(interfaces()) : undefined function lanAddressOf(ifaces: ReturnType): string | undefined { let v4 = (list: typeof ifaces) => list.flatMap((i) => i.addrs).find((a) => a.family === "v4")?.ip let reachable = ifaces.filter((i) => i.up && !i.loopback) return v4(reachable.filter((i) => i.default)) ?? v4(reachable) } // Storage flags for the local client: dev client trees live under // ~/.solidrt/clients/client/ (an explicit --data-root wins), passed // absolute because the client chdirs into its app sandbox at startup. let clientArgs: string[] = [] if (args.client !== null) { clientArgs.push("--data-root", args.dataRoot ? absolute(args.dataRoot, await realpath(".")) : devDir("clients")) clientArgs.push("--client", String(args.client)) if (args.size) clientArgs.push("--size", args.size) } let config: ServerConfig = { mode: mode.mode, key: mode.key, serverDir, entry: mode.entry, sourceDir: sourceDirOf(mode), projectDir: mode.projectDir, cwd: mode.projectDir ?? sourceDirOf(mode), entryArgs: [mode.entry, mode.mode === "project" ? "--project" : "--file"], sol, port: args.port, lan: args.lan, address: lanAddress ?? "127.0.0.1", proxyHttp: args.proxyHttp, args: args.appArgs, minify: args.minify, cache: args.proxyHttp, // Build outputs and the proxy cache: the project's .solidrt-data, or the // server folder for a file served on its own (nothing else owns it). cacheDir: mode.projectDir ? join(mode.projectDir, ".solidrt-data") : join(serverDir, "data"), capture: args.capture, stats: args.stats, tunnel: args.tunnel, client: args.client !== null ? { args: clientArgs } : null, } state.config = config state.stats = config.stats if (config.cache) { await cache.initCache({ dir: config.cacheDir }) console.log("[cli] HTTP cache enabled") } if (config.capture) { // Start each capture from an empty file: appends would otherwise tack onto // whatever a previous run left behind. await file(config.capture).write("") state.captureStartMs = Date.now() } // Split an origin-form request URL ("/path?a=1&b=2") into its decoded path and // query parameters. flux has no URL global; this covers what the routes need. function splitQuery(url: string): { path: string; query: Map } { let i = url.indexOf("?") let path = i < 0 ? url : url.slice(0, i) let query = new Map() if (i >= 0) { for (let pair of url.slice(i + 1).split("&")) { if (!pair) continue let j = pair.indexOf("=") let k = j < 0 ? pair : pair.slice(0, j) let v = j < 0 ? "" : pair.slice(j + 1) query.set(decodeURIComponent(k), decodeURIComponent(v.replace(/\+/g, " "))) } } return { path: decodeURIComponent(path), query } } // The file routes: GET file with single-range 206 support. All paths are // contained in `root` (the source directory, or the project dir for the // /assets/ convention route). async function handleFiles(req: FluxRequest, path: string, root: string): Promise { let filePath = resolveWithin(root, "." + path) if (!filePath) { return new Response("Forbidden", { status: 403 }) } console.log("[cli] get " + path) let stat try { stat = await file(filePath).stat() } catch { console.log(`[cli] file not found ${path}`) return new Response("Not found", { status: 404 }) } if (stat.type === "directory") { return new Response("Not found", { status: 404 }) } let baseHeaders: Record = { "Accept-Ranges": "bytes" } // Honor a single byte-range request (e.g. streaming audio decoding on the // client, which seeks and reads on demand). Only the common "bytes=a-b" / // "bytes=a-" / "bytes=-n" forms; anything else falls through to the whole // file. Range makes proxied streaming viable without pulling the whole // track over the wire. let range = req.headers.get("range") let match = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null if (match) { let size = stat.size let start: number let end: number if (match[1] === "") { // Suffix range: the last N bytes. let n = parseInt(match[2]!, 10) start = isNaN(n) ? 0 : Math.max(0, size - n) end = size - 1 } else { start = parseInt(match[1]!, 10) end = match[2] === "" ? size - 1 : Math.min(parseInt(match[2]!, 10), size - 1) } if (start > end || start >= size) { return new Response("Range not satisfiable", { status: 416, headers: { ...baseHeaders, "Content-Range": `bytes */${size}` }, }) } return new Response(await file(filePath).read(start, end - start + 1), { status: 206, headers: { ...baseHeaders, "Content-Range": `bytes ${start}-${end}/${size}`, "Content-Length": String(end - start + 1), }, }) } return new Response(await file(filePath).bytes(), { headers: baseHeaders }) } async function handleRequest(req: FluxRequest, server: Server): Promise { if (server.upgrade(req)) return let { path, query } = splitQuery(req.url) if (path === "/__proxy__") { return handleProxy(req) } if (path.startsWith("/__control__/")) { // Every control response names the key this server serves, so a caller // that resolved the port from the registry can confirm it reached the // server it meant. let resp = await handleControl(req, path, query) resp.headers.set("x-solidrt-project", config.key) resp.headers.set("x-solidrt-generation", String(state.generation)) return resp } // The assets/ convention roots at the project dir (package.json), which is // not necessarily the entry's dir the file routes serve; clients fetch // manifest asset paths here (live proxy reads and store installs alike). // File mode has no project and so no assets. if (path === "/assets" || path.startsWith("/assets/")) { if (!config.projectDir) return new Response("Not found", { status: 404 }) return handleFiles(req, path, config.projectDir) } // Isolate bundles are build outputs, not project files: the rebuild writes // them under /isolates/, and the manifest lists them as // isolates/.js. if (path.startsWith("/isolates/")) { return handleFiles(req, path, config.cacheDir) } return handleFiles(req, path, config.sourceDir) } // A string field of a client's `info`, or null when absent or malformed // (an older runtime, or one without the fact). function text(value: unknown): string | null { return typeof value === "string" ? value : null } // The device-limits object of a client's `info.gpu`, or null when absent // (an older runtime): every field a number or the object is dropped whole, // so a consumer never sees a partial limits report. function gpuLimits(value: unknown): GpuDeviceLimits | null { if (!value || typeof value !== "object") return null let v = value as Record let limits = {} as Record for (let key of ["maxTextureSize", "maxTextureUnits", "maxVertexAttribs", "maxAnisotropy", "maxVertexUniformVectors"]) { if (typeof v[key] !== "number") return null limits[key] = v[key] } return limits as GpuDeviceLimits } function onOpen(ws: ServerWebSocket) { let id = state.nextClientId++ state.clients.set(ws, { platform: "unknown", version: "unknown", profile: "unknown", id, capabilities: [], queries: [], stats: state.stats, timeScale: 1, loading: null, clientDir: null, pid: null, execPath: null, host: null, os: null, kernel: null, videoDriver: null, refreshRate: null, gpu: null, }) console.log(`[cli] Client connected ${ws.remoteAddr ?? "unknown"}`) // Advertise the address we are reachable on, so clients dialed over a // loopback hop can show/remember it (see connection.rs). ws.send(JSON.stringify({ type: "welcome", address: state.serverUrl, stats: state.stats, capture: !!config.capture, mute: state.userInputMuted })) if (state.currentReload) { ws.send(state.currentReload) } } function onClose(ws: ServerWebSocket) { let info = state.clients.get(ws) ready(ws) state.clients.delete(ws) console.log(`[cli] Client disconnected: ${info?.platform ?? "unknown"}`) } function onMessage(ws: ServerWebSocket, msg: string | Uint8Array) { try { let data = JSON.parse(typeof msg === "string" ? msg : new TextDecoder().decode(msg)) if (data.type === "info") { let existing = state.clients.get(ws) state.clients.set(ws, { platform: data.platform ?? "unknown", version: data.version ?? "unknown", profile: data.profile ?? "unknown", id: existing?.id ?? state.nextClientId++, capabilities: Array.isArray(data.capabilities) ? data.capabilities.map(String) : [], queries: Array.isArray(data.queries) ? data.queries.map(String) : [], stats: existing?.stats ?? state.stats, timeScale: existing?.timeScale ?? 1, // A runtime that reports its first frame (`ready`) is loading from // the moment it got the latched push in onOpen; its info always // precedes that frame. An older runtime never reports, so it is // never waited for. loading: data.ready === true ? state.currentReload !== null : null, clientDir: text(data.clientDir), pid: typeof data.pid === "number" ? data.pid : null, execPath: text(data.execPath), host: text(data.host), os: text(data.os), kernel: text(data.kernel), videoDriver: text(data.videoDriver), refreshRate: typeof data.refreshRate === "number" ? data.refreshRate : null, gpu: data.gpu && typeof data.gpu === "object" ? { vendor: text(data.gpu.vendor) ?? "", renderer: text(data.gpu.renderer) ?? "", version: text(data.gpu.version) ?? "", limits: gpuLimits(data.gpu.limits), } : null, }) console.log(`[cli] Client info ${ws.remoteAddr ?? "unknown"} ${data.platform} (${data.version})`) if (data.ready === true && state.currentReload !== null) pushed(ws) } else if (data.type === "ready") { // The client built the first frame of the last push: the queries // waiting for an engine go out now (see control.ts). ready(ws) } else if (data.type === "log") { // Forwarded console output / runtime errors from the client's engine // logger, buffered for the control API (see control.ts). Not printed // here: the local client already writes to this terminal, so echoing // would duplicate every line. let device = state.clients.get(ws)?.id ?? -1 appendLog(device, String(data.level ?? "log"), String(data.text ?? "")) } else if (data.type === "result") { // Reply to a query the control API forwarded to this client. resolveQuery(data) } else if (data.type === "capture" && config.capture) { let device = state.clients.get(ws)?.id ?? -1 // Milliseconds, integer: Date.now() is already integer ms, so the // delta needs no rounding. let at = Date.now() - state.captureStartMs let after = at - state.captureLastAt state.captureLastAt = at // JSON Lines: one event object per line, streamed to disk as it // arrives rather than buffered - no in-memory growth for a long // capture, and the file is always complete on disk mid-session. // Appends are chained so events land in arrival order. let line = JSON.stringify({ after, type: data.kind, key: data.key, device }) + "\n" state.captureChain = state.captureChain.then(() => file(config.capture!).append(line)) } } catch {} } // The port: an explicit --port, else the one this server bound last time // (so a project keeps its port in practice), else the first free one from // DEFAULT_PORT upward, so servers on one machine read as 34884, 34885, ... // A remembered or default port that is taken is skipped; an explicit one // that is taken is the user's problem to see. const DEFAULT_PORT = 0x8844 const PORT_TRIES = 100 let host = config.lan ? "0.0.0.0" : "127.0.0.1" let remembered = config.port ?? (await rememberedPort(config.serverDir)) // Ticket-paired clients connect through this endpoint; serve() accepts its // connections directly alongside the TCP listener. Its UDP port follows the // remembered port so a ticket stays stable across restarts. let tunnel = config.tunnel ? await createTunnelEndpoint(remembered, config.serverDir) : null if (tunnel) state.tunnelTicket = await tunnel.ticket() function bind(port: number): Server { return serve({ host, port, p2p: tunnel ? { endpoint: tunnel, protocol: TUNNEL_PROTOCOL } : undefined, fetch: handleRequest, websocket: { open: onOpen, close: onClose, message: onMessage }, }) } // A bind alone does not prove a port free: on macOS (BSD SO_REUSEADDR, the // default on a listener) a loopback bind coexists with another process's // all-interfaces listener on the same port, and the newcomer then silently // takes the loopback traffic; Linux and Windows refuse the second bind. So // each candidate is dialed first, and an answer means taken. Anything else // is left to the bind: a refusal cannot be waited for, because Windows only // reports one after ~2 s of SYN retries, so it reads as filtered within the // probe budget and the bind is the truth. async function bindFirstFree(): Promise { if (config.port !== undefined) return bind(config.port) let candidates: number[] = remembered !== null ? [remembered] : [] for (let p = DEFAULT_PORT; p < DEFAULT_PORT + PORT_TRIES; p++) candidates.push(p) let last: unknown = null for (let port of candidates) { if ((await probe("127.0.0.1", port, { timeoutMs: 200 })) === "open") continue try { return bind(port) } catch (e) { last = e } } throw last ?? new Error(`No free port between ${DEFAULT_PORT} and ${DEFAULT_PORT + PORT_TRIES - 1}`) } let server = await bindFirstFree() let address = config.lan ? config.address : "127.0.0.1" state.serverUrl = `${address}:${server.port}` await writeRecord(config, server.port, address) printIdentity() // Keepalive let keepalive = setInterval(() => { for (let ws of state.clients.keys()) { ws.ping() } }, 5000) let shuttingDown = false let stopRepl = () => {} let localClient: Child | null = null let signalOffs = ["SIGINT", "SIGTERM"].map((signal) => onSignal(signal, () => { shutdown() }), ) // Orderly exit in two steps: `release` gives up what makes this server // visible (the registry record, the local client, the watcher, the repl, // the keepalive), `closeListeners` ends the listeners; after both, every // handle that keeps the loop alive is gone and the process ends on its own // (flux has no exit call; an idle loop is the exit). The split is for the // control API: it acks a shutdown only once the record is gone, so a `sol // run` issued right after the ack never meets the dying server's record, // and closes the listeners after the ack has gone out. async function release() { if (shuttingDown) return shuttingDown = true clearInterval(keepalive) for (let off of signalOffs) off() stopRepl() stopWatcher() await removeRecord(config.serverDir) if (localClient) localClient.kill() } async function closeListeners() { server.close() if (tunnel) await tunnel.close() } async function shutdown() { await release() await closeListeners() } onShutdownRequest({ release, closeListeners }) // Native engine diagnostics on the spawned client's stderr - flutter and // Impeller "[ERROR:path] message" lines, validation breaks among them - // never pass the engine logger, so without forwarding they are invisible // over the control API: a rendering bug can spam stderr every frame while // /logs stays clean. Buffer the error-shaped lines as log entries under // their severity (client -1: the process, not a connected device); the rest // of the stderr chatter (GL extension dumps, [alloy] startup notes) stays // terminal-only. Read-side repeat collapsing keeps the per-frame copies // down to one entry. Only the client this server spawned is covered - a // remote client's stderr never reaches the server. const NATIVE_STDERR_LINE = /^\[(ERROR|FATAL|WARNING):[^\]]*\]/ function forwardNativeStderr(line: string) { let match = NATIVE_STDERR_LINE.exec(line) if (!match) return appendLog(-1, match[1] === "WARNING" ? "warn" : "error", line) } // Print a child's output line by line as it arrives. async function pump(stream: AsyncIterable, print: (line: string) => void) { let decoder = new TextDecoder() let rest = "" for await (let chunk of stream) { rest += decoder.decode(chunk, { stream: true }) let lines = rest.split("\n") rest = lines.pop() ?? "" for (let line of lines) print(line) } if (rest) print(rest) } // The initial bundle, latched for the clients about to connect. A failed // build shows the BSOD rather than nothing; the next reload retries. The // rebuild arms reload-on-save from the bundle's inputs (watcher.ts). console.log("[cli] Bundling (development)") let buildError = await rebuildAndBroadcast() if (buildError) { console.error(buildError) showBuildFailure() } console.log("[cli] Reload on save is on (pause it with the MCP pause_watch tool)") stopRepl = startRepl(shutdown) // Startup typecheck (`sol check `), deliberately not awaited: the // report prints when tsc finishes, and a type error never gates the boot // (sol check is the hard gate). Once per server lifetime; reloads never // typecheck. A prebuilt .sol.js has no checkable program. if (!config.entry.endsWith(".sol.js")) { let check = command(config.sol[0]!, [...config.sol.slice(1), "check", config.entry], { cwd: config.cwd }).spawn() pump(check.stdout, (line) => console.log(line)) pump(check.stderr, (line) => console.error(line)) } if (config.client) { // The client's binary, now that the bundle says whether the project's own // is required (binaries.ts): a project binary that is missing while the // app imports a module of its runtime ends the server here, in order, // instead of leaving the app to fail at that import. let runner try { runner = await requireBinary("solidrt-go", mode.runtime, state.projectModules[0] ?? null) } catch (e) { console.error(e instanceof Error ? e.message : String(e)) await shutdown() exit(1) } state.stockClientFor = runner.missing // The local client dials the port bound above; sol could not know it. let child = command(runner.path, [...config.client.args, "--dev-server", `127.0.0.1:${server.port}`]).spawn() localClient = child pump(child.stdout, (line) => console.log(line)) pump(child.stderr, (line) => { console.error(line) forwardNativeStderr(line) }) // The server outlives its client: a wedged or crashed client is restarted // with `sol client` (it reattaches by cwd) without losing the server, its // bundle, the watcher or the MCP session. The server stops on quit/signal. child.status().then((status) => { localClient = null if (shuttingDown) return let reason = crashReason(status.code, status.signal) let how = reason === null ? "exited" : `crashed (${reason})` console.log(`[cli] Local client ${how}; the server keeps running (sol client reattaches)`) }) }