import { defineConfig } from "vite"; import { resolve, dirname, sep } from "node:path"; import { fileURLToPath } from "node:url"; import vue from "@vitejs/plugin-vue"; import tailwindcss from "@tailwindcss/vite"; import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync, } from "node:fs"; import { readManifest, listTelemetryRuns, readTelemetryRun, type TelemetryRunMeta, } from "./src/server/nwire-read"; import { spawnSync } from "node:child_process"; import { RunnerSupervisor, inspectHealthCheck } from "@nwire/supervisor"; import type { IncomingMessage, ServerResponse } from "node:http"; import { request as httpRequest } from "node:http"; import { createConnection, createServer as createNetServer, type AddressInfo } from "node:net"; const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, "..", ".."); /** * The consumer's working directory — wherever the user invoked `nwire studio`. * The CLI passes it through `NWIRE_CWD` so Studio reads `.nwire/`, topologies, * etc. from the right project when launched outside the framework repo. * * Multi-project Studio (Shape A): every middleware endpoint accepts an * optional `?project=` query param that overrides this default, so a * single Studio process can pivot between projects the browser knows about. * The CWD passed must be one this Studio has been told about — see * `knownProjects` below — otherwise we refuse the request to avoid serving * arbitrary filesystem paths. */ const consumerCwd = process.env.NWIRE_CWD ?? repoRoot; /** * Liveness check for a PID. `process.kill(pid, 0)` doesn't actually send * a signal — it just probes whether the OS would deliver one. On POSIX * a missing process throws ESRCH; on Windows the same call throws EPERM * when the PID exists but is owned by another user (or when we lack * SIGNAL access to it). Both POSIX-EPERM and Windows-EPERM mean "alive * but we can't signal it" — treat as alive. Only ESRCH = dead. */ function isPidAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "EPERM") return true; return false; } } interface ExternalProcessRecord { id: string; topology: string; port?: number; startedAt: string; cwd: string; pid: number; status: "starting" | "running" | "stopping" | "exited" | "crashed"; logPath: string; } /** * Read `.nwire/processes/*.json` for the given cwd and project each * `ProcessRecord` (written by `nwire dev`) into the shape Studio's Run * endpoint returns. Dead pids are filtered. Used to surface processes * the user started in another terminal alongside Studio-spawned ones. */ interface ExternalLogLine { seq: number; ts: string; stream: "stdout"; line: string; } /** * Read the last N lines of a process log file. Each line becomes one * `ExternalLogLine` so the SSE consumer sees the same shape Studio's * supervisor emits for in-process children. */ function tailLogFile(path: string, n: number): ExternalLogLine[] { if (!path || !existsSync(path)) return []; try { const text = readFileSync(path, "utf8"); const lines = text.split(/\r?\n/).filter(Boolean); const tail = lines.slice(-n); const out: ExternalLogLine[] = []; const now = new Date().toISOString(); for (let i = 0; i < tail.length; i++) { out.push({ seq: i, ts: now, stream: "stdout", line: tail[i]! }); } return out; } catch { return []; } } /** * Poll a log file for new content and call `onLine` with each newly * appended line. Returns a stop handle to clean up. Used to live-tail * external `nwire dev` processes — we don't own their stdout so we * watch the disk file they tee into. */ function openLogTail(path: string, onLine: (line: ExternalLogLine) => void): { stop: () => void } { if (!path || !existsSync(path)) return { stop: () => {} }; let offset = 0; try { offset = statSync(path).size; } catch { offset = 0; } let seq = 0; let stopped = false; const poll = setInterval(() => { if (stopped) return; try { const size = statSync(path).size; if (size <= offset) return; const fd = openSync(path, "r"); try { const buf = Buffer.alloc(size - offset); readSync(fd, buf, 0, buf.length, offset); offset = size; const text = buf.toString("utf8"); for (const raw of text.split(/\r?\n/)) { if (!raw) continue; onLine({ seq: seq++, ts: new Date().toISOString(), stream: "stdout", line: raw }); } } finally { closeSync(fd); } } catch { // file rotated or removed — surrender quietly } }, 500); return { stop: () => { stopped = true; clearInterval(poll); }, }; } function readExternalProcesses(cwd: string): ExternalProcessRecord[] { const dir = resolve(cwd, ".nwire", "processes"); if (!existsSync(dir)) return []; const out: ExternalProcessRecord[] = []; let entries: string[]; try { entries = readdirSync(dir); } catch { return []; } for (const f of entries) { if (!f.endsWith(".json")) continue; try { const rec = JSON.parse(readFileSync(resolve(dir, f), "utf8")) as { id?: string; name?: string; pid?: number; port?: number; status?: string; startedAt?: string; cwd?: string; logPath?: string; }; if (typeof rec.pid !== "number" || rec.pid <= 0) continue; if (!isPidAlive(rec.pid)) continue; out.push({ id: rec.id ?? f.replace(/\.json$/, ""), topology: rec.name ?? "external", port: typeof rec.port === "number" ? rec.port : undefined, startedAt: rec.startedAt ?? new Date(0).toISOString(), cwd: rec.cwd ?? cwd, pid: rec.pid, status: (rec.status as ExternalProcessRecord["status"]) === "running" ? "running" : "running", logPath: rec.logPath ?? "", }); } catch { // malformed entry — skip } } return out; } /** * Set of CWDs the frontend has registered as "known projects." Populated by * POST /__nwire/projects/register (called on Studio mount with every cwd * from the localStorage catalog) plus the launch-time NWIRE_CWD. Acts as * the allowlist for `?project=` query params. */ const knownProjects = new Set([consumerCwd]); /** * Resolve the cwd a request is targeting. Reads `?project=` from the * URL and validates it against `knownProjects`. Falls back to the launch- * time consumerCwd when no param is present, preserving single-project * behavior. Returns null on a disallowed cwd so callers can 400. */ function targetCwd(req: IncomingMessage): string | null { const u = new URL(req.url ?? "/", "http://localhost"); const param = u.searchParams.get("project"); if (!param) return consumerCwd; return knownProjects.has(param) ? param : null; } /** * Build the `.nwire/` cache on demand, or refresh it when source changed. * Uses the CLI's fingerprint cheap-path (a SHA over the apps entry + * package.json + workspace handshake) so re-running is free when nothing * moved. Synchronous so the Vite middleware can serve the freshly built * file on the same request. * * Studio calls this on every `GET /__nwire/manifest.json` so a user * editing source never sees stale data — the cache rebuilds itself on * the next page load. * * The auto-rebuild is skipped when: * - `NWIRE_NO_AUTOCACHE=1` is set in the environment (e.g. the e2e harness * points Studio at a fixture directory that has a committed manifest but no * real source — running `nwire cache` there would overwrite the fixture). * - The cwd has no `package.json` (nothing to scan; serve whatever manifest * is already on disk rather than producing an empty one). * In both cases we fall through to serving the existing manifest, or 404 if * it isn't there yet. */ function ensureCacheBuilt(cwd: string = consumerCwd, opts: { force?: boolean } = {}): boolean { const manifestPath = resolve(cwd, ".nwire", "manifest.json"); // Guard: a fixture-only directory (no package.json) or an explicit opt-out // from the caller env. Serve whatever is on disk; don't overwrite it. if (process.env.NWIRE_NO_AUTOCACHE === "1" && !opts.force) { return existsSync(manifestPath); } if (!existsSync(resolve(cwd, "package.json")) && !opts.force) { return existsSync(manifestPath); } const cliPath = resolve(here, "..", "nwire-cli", "dist", "cli.js"); if (!existsSync(cliPath)) { return existsSync(manifestPath); } // `nwire cache --if-stale` uses the CLI's fingerprint cheap-path — // when nothing changed it's a few ms; when source moved it rebuilds // before returning. `--quiet` keeps Studio's dev-server log clean. const args = ["exec", "node", cliPath, "cache", "--quiet"]; if (!opts.force) args.push("--if-stale"); const result = spawnSync("pnpm", args, { cwd, stdio: "inherit", // Windows: `pnpm` resolves to a `.cmd` shim; `shell:true` is needed // for `spawn` to find it. POSIX is unaffected. shell: process.platform === "win32", }); if (result.status !== 0) return existsSync(manifestPath); return existsSync(manifestPath); } // ── Telemetry run helpers ───────────────────────────────────────────────── // listTelemetryRuns, readTelemetryRun, TelemetryRunMeta imported from // ./src/server/nwire-read — shared with studio-host-api.ts. /** * Open a JSONL file tail: poll for new content and call `onLine` with each * newly appended, parsed JSON record. Returns a stop handle. Same pattern as * `openLogTail` but parses JSON (telemetry records) instead of raw text. */ function openJsonlTail(path: string, onRecord: (rec: unknown) => void): { stop: () => void } { if (!existsSync(path)) return { stop: () => {} }; let offset = 0; let leftover = ""; try { offset = statSync(path).size; } catch { offset = 0; } let stopped = false; const poll = setInterval(() => { if (stopped) return; try { const size = statSync(path).size; if (size <= offset) return; const fd = openSync(path, "r"); try { const buf = Buffer.alloc(size - offset); readSync(fd, buf, 0, buf.length, offset); offset = size; const text = leftover + buf.toString("utf8"); const lines = text.split(/\r?\n/); // Last element may be an incomplete line — hold it over. leftover = lines.pop() ?? ""; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; try { onRecord(JSON.parse(trimmed)); } catch { // malformed JSON line — skip } } } finally { closeSync(fd); } } catch { // file removed or rotated — stop quietly } }, 250); return { stop: () => { stopped = true; clearInterval(poll); }, }; } /** * Vite middleware that exposes the consumer's `.nwire/manifest.json` to the * Studio dev server. If the cache is missing, build it on the fly so the * user never has to remember `nwire cache` before `nwire studio`. */ function nwireDataPlugin() { return { name: "nwire-data", configureServer(server: { middlewares: { use: (path: string, fn: (req: IncomingMessage, res: ServerResponse) => void) => void; }; }) { /** * Project identity — name + cwd. Honors `?project=` for the * multi-project shell; defaults to the launch-time NWIRE_CWD when * no override is present. Sidebar header + Projects page consume it. */ server.middlewares.use("/__nwire/project", (req, res) => { const cwd = targetCwd(req); if (!cwd) { res.statusCode = 400; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ error: "unknown project — register it first" })); return; } let name = "(unnamed)"; try { const pj = resolve(cwd, "package.json"); if (existsSync(pj)) { const parsed = JSON.parse(readFileSync(pj, "utf8")) as { name?: string }; if (parsed.name) name = parsed.name; } } catch { // keep default } res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ name, cwd, launchedFrom: consumerCwd })); }); /** * Register a project so subsequent `?project=` calls accept it. * POST /__nwire/projects/register { cwd } * The frontend calls this on mount for every entry in its localStorage * catalog so a fresh Studio process can serve them without restart. */ server.middlewares.use("/__nwire/projects/register", (req, res) => { if (req.method !== "POST") { res.statusCode = 405; res.end(); return; } const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c as Buffer)); req.on("end", () => { try { const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as { cwd?: string; }; if (body.cwd && existsSync(body.cwd)) { knownProjects.add(body.cwd); res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ ok: true, registered: body.cwd })); } else { res.statusCode = 400; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ error: "cwd missing or not on disk" })); } } catch (err) { res.statusCode = 400; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ error: (err as Error).message })); } }); }); /** * Project catalog status — multi-project Studio. * * POST /__nwire/projects/status { cwds: string[] } * * Returns running-process info for each cwd by reading its * `.nwire/processes/*.json` registry. This lets the frontend * catalog page show green/red dots without needing each project's * Studio to be open. Stale entries (PID not alive) are filtered. */ server.middlewares.use("/__nwire/projects/status", (req, res) => { if (req.method !== "POST") { res.statusCode = 405; res.end(); return; } const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c as Buffer)); req.on("end", () => { let cwds: string[] = []; try { const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as { cwds?: string[]; }; cwds = Array.isArray(body.cwds) ? body.cwds : []; } catch { // empty body → empty result } type ProcessRec = { id: string; port?: number; pid: number; status: string; startedAt: string; }; const result: Record = {}; for (const cwd of cwds) { const procDir = resolve(cwd, ".nwire", "processes"); const manifestPath = resolve(cwd, ".nwire", "manifest.json"); const live: ProcessRec[] = []; if (existsSync(procDir)) { try { for (const f of readdirSync(procDir)) { if (!f.endsWith(".json")) continue; try { const rec = JSON.parse(readFileSync(resolve(procDir, f), "utf8")) as ProcessRec; if (rec.pid > 0 && isPidAlive(rec.pid)) { live.push(rec); } } catch { // malformed entry — skip } } } catch { // unreadable dir } } result[cwd] = { hasManifest: existsSync(manifestPath), processes: live }; } res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(result)); }); }); server.middlewares.use("/__nwire/manifest.json", (req, res) => { const cwd = targetCwd(req); if (!cwd) { res.statusCode = 400; res.end(JSON.stringify({ error: "unknown project" })); return; } // Always run the if-stale path. When the fingerprint matches, // the CLI exits in a few ms with no spawn cost beyond its own // bootstrap; when source moved, the manifest is rebuilt before // we serve it. That's how Studio stays honest while a user is // actively editing. // // `?force=true` is the manual override for the Rebuild button. const u = new URL(req.url ?? "/", "http://localhost"); const force = u.searchParams.get("force") === "true"; if (!ensureCacheBuilt(cwd, { force })) { res.statusCode = 404; res.end( JSON.stringify({ error: "nwire cache could not be built. Check that apps/apps.ts or " + "nwire.config.ts points at a valid apps registry.", }), ); return; } const raw = readManifest(cwd); if (!raw) { res.statusCode = 404; res.end(JSON.stringify({ error: "manifest.json missing after build" })); return; } res.setHeader("Content-Type", "application/json"); res.end(raw); }); // Source viewer — reads a file from disk, returns { content, language }. // Permission scope: the active project's cwd OR any registered project // (multi-project Shape A) OR our own workspace dist for monorepo dev. // Anything outside → 403. server.middlewares.use("/__nwire/source", (req, res) => { try { const u = new URL(req.url ?? "/", "http://localhost"); const requested = u.searchParams.get("path"); if (!requested) { res.statusCode = 400; res.end(JSON.stringify({ error: "missing ?path=" })); return; } const absolute = resolve(requested); const allowedRoot = [...knownProjects].some((root) => absolute.startsWith(root)); // Use `path.sep` so the framework-source fallback works on Windows too. // Match the three workspace folder prefixes: core-, nwire-, tool-. const frameworkSegments = [ `${sep}packages${sep}core-`, `${sep}packages${sep}nwire-`, `${sep}packages${sep}tool-`, ]; if (!allowedRoot && !frameworkSegments.some((seg) => absolute.includes(seg))) { res.statusCode = 403; res.end( JSON.stringify({ error: "path is outside the Studio working directory", path: absolute, }), ); return; } if (!existsSync(absolute)) { res.statusCode = 404; res.end(JSON.stringify({ error: "file not found", path: absolute })); return; } const content = readFileSync(absolute, "utf8"); const language = inferLanguage(absolute); res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ content, language, path: absolute })); } catch (err) { res.statusCode = 500; res.end(JSON.stringify({ error: (err as Error).message })); } }); /** * Telemetry run list + single-run records. * * GET /__nwire/telemetry/runs → { runs: TelemetryRunMeta[] } * GET /__nwire/telemetry/runs/:id → { id, records: unknown[] } * * Both honor `?project=`. */ server.middlewares.use("/__nwire/telemetry/runs", (req, res) => { const cwd = targetCwd(req); if (!cwd) { res.statusCode = 400; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ error: "unknown project" })); return; } // connect strips the mount prefix, so req.url is "/" (or "") for the // list and "/" for a single run. const sub = new URL(req.url ?? "/", "http://x").pathname; // Exact: list all runs. if (sub === "/" || sub === "") { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ runs: listTelemetryRuns(cwd) })); return; } // Sub-path: single run records. const runIdMatch = /^\/([^/]+)$/.exec(sub); if (runIdMatch) { const runId = decodeURIComponent(runIdMatch[1]!); const file = resolve(cwd, ".nwire", "telemetry", `${runId}.jsonl`); if (!existsSync(file)) { res.statusCode = 404; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ error: "run not found", id: runId })); return; } const records = readTelemetryRun(cwd, runId); res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ id: runId, records })); return; } res.statusCode = 404; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ error: "not found" })); }); /** * Live telemetry tail (SSE). * GET /__nwire/telemetry/live[?project=] * * Finds the current (newest) run file and tails it, streaming each new * JSON record as an SSE `data:` event. Sends backfill (the entire current * file at connection time) first, then polls for appended lines. * Keepalive comments every 25 s to prevent proxy timeouts. */ server.middlewares.use("/__nwire/telemetry/live", (req, res) => { const cwd = targetCwd(req); if (!cwd) { res.statusCode = 400; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ error: "unknown project" })); return; } const runs = listTelemetryRuns(cwd); const current = runs[0]; // newest, or undefined when no runs yet const filePath = current ? resolve(cwd, ".nwire", "telemetry", `${current.id}.jsonl`) : null; res.statusCode = 200; res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache, no-transform"); res.setHeader("Connection", "keep-alive"); // Emit a meta event so the client knows which run it's tailing. const runMeta = current ?? null; try { res.write(`event: run\ndata: ${JSON.stringify(runMeta)}\n\n`); } catch { return; } // Backfill: send all records already in the file. if (filePath && existsSync(filePath)) { const existing = readTelemetryRun(cwd, current!.id); for (const rec of existing) { try { res.write(`data: ${JSON.stringify(rec)}\n\n`); } catch { return; } } } if (!filePath) { // No run file yet — just keepalive and wait. const keepalive = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(keepalive); } }, 25_000); req.on("close", () => clearInterval(keepalive)); return; } // Tail for new lines appended after the backfill. const tail = openJsonlTail(filePath, (rec) => { try { res.write(`data: ${JSON.stringify(rec)}\n\n`); } catch { // socket gone — tail.stop() will fire via req.close } }); const keepalive = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(keepalive); } }, 25_000); req.on("close", () => { tail.stop(); clearInterval(keepalive); }); }); }, }; } function inferLanguage(file: string): string { if ( file.endsWith(".ts") || file.endsWith(".tsx") || file.endsWith(".mts") || file.endsWith(".cts") ) return "typescript"; if ( file.endsWith(".js") || file.endsWith(".jsx") || file.endsWith(".mjs") || file.endsWith(".cjs") ) return "javascript"; if (file.endsWith(".vue")) return "html"; if (file.endsWith(".json")) return "json"; if (file.endsWith(".md")) return "markdown"; if (file.endsWith(".yaml") || file.endsWith(".yml")) return "yaml"; if (file.endsWith(".css")) return "css"; return "plaintext"; } /** * Studio-as-runner middleware. Exposes a small HTTP surface so Studio's UI * can list topologies, start/stop processes, and stream their logs. * * GET /__nwire/run/topologies * GET /__nwire/run/commands * GET /__nwire/run/processes * POST /__nwire/run/start { topology, port? } * POST /__nwire/run/exec { command, args? } * POST /__nwire/run/stop/:id * POST /__nwire/run/forget/:id * GET /__nwire/run/logs/:id/recent?limit=… * GET /__nwire/run/logs/:id/stream (SSE) * * One supervisor instance per Vite dev server. Lifecycle is tied to the * Vite process — when the user kills Studio, every spawned child is * stopped. */ /** * The single RunnerSupervisor instance for this Vite dev server. Lives at * module scope so both the runner middleware AND the dynamic `/_nwire/*` * proxy middleware see the same managed processes. */ const supervisor = new RunnerSupervisor(); const cleanupSupervisor = () => { void supervisor.stopAll(); }; process.once("SIGINT", cleanupSupervisor); process.once("SIGTERM", cleanupSupervisor); process.once("exit", cleanupSupervisor); /** * Pick the port for the most-recently-started running process Studio * should target. Looks in three places, in order: * * 1. Studio's in-process supervisor (processes started via the Run page). * 2. The consumer's `.nwire/processes/*.json` registry — populated by * `nwire dev` running in another terminal. This makes "Studio in * tab A, `nwire dev` in tab B" Just Work. * 3. A short TCP-port probe of `NWIRE_PROBE_PORTS` (defaults 3000–3010), * so the most common case — `pnpm dev` (vite-node) in another terminal, * no managed-process registry — also Just Works. Cached for 5s. * * Returns undefined when nothing's running — the static proxy then falls * back to `NWIRE_INSPECT_URL`, and finally to localhost:3000. */ const probePorts: readonly number[] = process.env.NWIRE_PROBE_PORTS?.split(",") .map((s) => Number(s.trim())) .filter((n) => n > 0) ?? // 3000-3010 are common dev defaults; 3030 is the station-management example; // 4000 + 8080 are popular alternates. Probes are HTTP-validated so unrelated // services on these ports are safely skipped. [3000, 3001, 3002, 3003, 3004, 3005, 3006, 3007, 3008, 3009, 3010, 3030, 3040, 3050, 4000, 8080]; let probedPort: number | undefined; let probedExpires: number = 0; async function probeOpenPort(): Promise { if (Date.now() < probedExpires) return probedPort; for (const port of probePorts) { if (await isNwireWire(port, 150)) { probedPort = port; probedExpires = Date.now() + 5_000; return port; } } probedPort = undefined; probedExpires = Date.now() + 1_500; // brief negative cache return undefined; } /** * Probe a single port. We want to *only* match a running nwire wire, * not any random local service on the same port, so we do a real HTTP * GET against `/_nwire/events/recent` (always present when inspect is * mounted) and require a JSON-array response. A TCP-only probe matched * unrelated services and broke the proxy. */ function isNwireWire(port: number, timeoutMs: number): Promise { return new Promise((resolve) => { // Skip if the socket can't even open. const sock = createConnection({ host: "127.0.0.1", port }); sock.setTimeout(timeoutMs, () => { sock.destroy(); resolve(false); }); sock.once("error", () => resolve(false)); sock.once("connect", () => { sock.destroy(); const req = httpRequest( { host: "127.0.0.1", port, path: "/_nwire/events/recent?limit=1", method: "GET" }, (res) => { if (res.statusCode !== 200) { res.resume(); return resolve(false); } let buf = ""; res.setEncoding("utf8"); res.on("data", (c: string) => { buf += c; if (buf.length > 1024) res.destroy(); }); res.on("end", () => resolve(buf.trimStart().startsWith("["))); }, ); req.setTimeout(timeoutMs, () => { req.destroy(); resolve(false); }); req.once("error", () => resolve(false)); req.end(); }); }); } async function activeProcessPort(cwd: string): Promise { // 1. In-process supervisor — scoped to processes whose cwd matches. // Cross-project Studio: a process started from project A shouldn't // receive proxy traffic for project B. const managed = supervisor .list() .filter((p) => p.status === "running" && p.port && (p as { cwd?: string }).cwd === cwd); if (managed.length > 0) { managed.sort((a, b) => b.startedAt.localeCompare(a.startedAt)); return managed[0]!.port; } // 2. External `nwire dev` registry. Filter to entries whose PID is // still alive — stale records linger after a Ctrl+C without cleanup. const procDir = resolve(cwd, ".nwire", "processes"); if (existsSync(procDir)) { let entries: string[] = []; try { entries = readdirSync(procDir); } catch { // ignore } type ExternalRec = { status: string; port?: number; pid: number; startedAt: string }; const live: ExternalRec[] = []; for (const f of entries) { if (!f.endsWith(".json")) continue; try { const raw = readFileSync(resolve(procDir, f), "utf8"); const rec = JSON.parse(raw) as ExternalRec; if (rec.status !== "running" || !rec.port || rec.pid <= 0) continue; if (!isPidAlive(rec.pid)) continue; live.push(rec); } catch { // skip malformed entry } } if (live.length > 0) { live.sort((a, b) => b.startedAt.localeCompare(a.startedAt)); return live[0]!.port; } } // 3. TCP port probe — catches `pnpm dev` / vite-node / any non-CLI launcher. return probeOpenPort(); } /** * Dynamic `/_nwire/*` proxy. When a managed process is running, forward * to its port. When none is up, fall through to Vite's static proxy (which * uses `NWIRE_INSPECT_URL`). Supports SSE — pipes upstream chunks straight * through without buffering. */ function nwireProxyPlugin() { return { name: "nwire-dynamic-proxy", configureServer(server: { middlewares: { use: ( handler: (req: IncomingMessage, res: ServerResponse, next: () => void) => void, ) => void; }; }) { server.middlewares.use(async (req, res, next) => { const url = req.url ?? ""; if (!url.startsWith("/_nwire/")) return next(); // The frontend appends `?project=` to every fetch when // multi-project Studio is active; we resolve the target project's // wire here so /_nwire/* always lands on the right backend. const cwd = targetCwd(req); if (!cwd) { res.statusCode = 400; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ error: "unknown project" })); return; } // Read the body up-front. If we issue the upstream request before // the body is in hand, Node flushes the proxy headers on the next // tick and the wire waits forever for Content-Length bytes that // arrive too late, surfacing here as "socket hang up". For SSE we // skip body collection entirely (the stream IS the body). const method = (req.method ?? "GET").toUpperCase(); const hasBody = method !== "GET" && method !== "HEAD" && method !== "DELETE"; const body: Buffer = hasBody ? await new Promise((resolveBody, rejectBody) => { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); req.on("end", () => resolveBody(Buffer.concat(chunks))); req.on("error", rejectBody); }) : Buffer.alloc(0); const port = await activeProcessPort(cwd); if (!port) { // No managed process — let Vite's static proxy take it. return next(); } // Strip browser-side noise before forwarding. Studio's origin shares // a port with whatever else on localhost the user's browser has // accumulated cookies for — sending a 5KB cookie blob upstream // wedges some wires (Koa default header parsing, etc.) and surfaces // as 'socket hang up'. The wire never authenticates against these, // so dropping them is pure win. const forwardedHeaders: Record = {}; for (const [k, v] of Object.entries(req.headers)) { if (v === undefined) continue; const lower = k.toLowerCase(); if (lower === "cookie" || lower === "referer" || lower === "origin") continue; if (lower === "host" || lower === "connection") continue; forwardedHeaders[k] = v as string | string[]; } forwardedHeaders.host = `127.0.0.1:${port}`; // Avoid stale keep-alive reuse — short-lived dev requests don't // benefit, and a reset upstream socket otherwise surfaces here as // 'socket hang up'. forwardedHeaders.connection = "close"; const upstream = httpRequest( { host: "127.0.0.1", port, method: req.method, path: url, headers: forwardedHeaders, }, (upRes) => { res.statusCode = upRes.statusCode ?? 502; for (const [k, v] of Object.entries(upRes.headers)) { if (v !== undefined) res.setHeader(k, v as string | string[]); } upRes.pipe(res); }, ); upstream.on("error", (err) => { res.statusCode = 502; res.setHeader("Content-Type", "application/json"); res.end( JSON.stringify({ error: `proxy failed: ${err.message}`, upstream: `127.0.0.1:${port}${url}`, hint: "wire may have crashed or rejected the request; check the dev terminal for a stack trace", }), ); }); if (body.length > 0) upstream.write(body); upstream.end(); // SSE / long-lived: tear down upstream when client disconnects. req.on("close", () => upstream.destroy()); }); }, }; } function nwireRunnerPlugin() { // No-op: cleanup hooks live at module scope above. const cleanup = () => { /* registered at module load */ }; void cleanup; function listTopologies(cwd: string): string[] { const dir = resolve(cwd, "apps", "topologies"); if (!existsSync(dir)) return []; return readdirSync(dir) .filter((n) => n.endsWith(".topology.ts")) .map((n) => n.replace(/\.topology\.ts$/, "")) .sort(); } /** * Consumer's `package.json` scripts — used as a Run-page fallback for * single-app projects that don't ship a topology folder. Returned in * declaration order; callers POST `{ script }` to `/__nwire/run/exec-script` * to launch one through the same supervisor that hosts topologies. */ /** * Find an unused TCP port the OS will let us bind. Returns the assigned * port from a transient `listen(0)` then immediately releases it. Used as * the default for managed-process starts so collisions with the user's * existing services don't break the dev loop. */ function findFreePort(): Promise { return new Promise((resolve, reject) => { const srv = createNetServer(); srv.unref(); srv.once("error", reject); srv.listen(0, "127.0.0.1", () => { const addr = srv.address() as AddressInfo | null; const port = addr?.port; srv.close(() => { if (port) resolve(port); else reject(new Error("findFreePort: could not allocate")); }); }); }); } function listScripts(cwd: string): Array<{ name: string; command: string }> { const pj = resolve(cwd, "package.json"); if (!existsSync(pj)) return []; try { const raw = readFileSync(pj, "utf8"); const parsed = JSON.parse(raw) as { scripts?: Record }; const scripts = parsed.scripts ?? {}; return Object.entries(scripts).map(([name, command]) => ({ name, command })); } catch { return []; } } async function readBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const raw = Buffer.concat(chunks).toString("utf8"); if (!raw) return undefined; try { return JSON.parse(raw); } catch { return undefined; } } function send(res: ServerResponse, status: number, body: unknown) { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(body)); } return { name: "nwire-runner", configureServer(server: { middlewares: { use: ( handler: (req: IncomingMessage, res: ServerResponse, next: () => void) => void, ) => void; }; }) { server.middlewares.use((req, res, next) => { const url = req.url ?? ""; const method = req.method ?? "GET"; if (!url.startsWith("/__nwire/run/")) return next(); // URLs now carry `?project=` for multi-project Studio. Match // routes against the pathname only; resolve target cwd via the // shared helper so each request acts on the right project's // topologies / scripts / processes. const pathname = new URL(url, "http://x").pathname; const cwd = targetCwd(req); if (!cwd) { return send(res, 400, { error: "unknown project" }); } if (method === "GET" && pathname === "/__nwire/run/topologies") { return send(res, 200, { topologies: listTopologies(cwd) }); } if (method === "GET" && pathname === "/__nwire/run/scripts") { return send(res, 200, { scripts: listScripts(cwd) }); } if (method === "POST" && pathname === "/__nwire/run/exec-script") { // Launch `pnpm run