@nwire/studio 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/components.json +19 -0
  4. package/index.html +12 -0
  5. package/package.json +66 -0
  6. package/src/App.vue +305 -0
  7. package/src/components/EmptyState.stories.ts +53 -0
  8. package/src/components/EmptyState.vue +28 -0
  9. package/src/components/ErrorBoundary.vue +60 -0
  10. package/src/components/FilterInput.stories.ts +32 -0
  11. package/src/components/FilterInput.vue +33 -0
  12. package/src/components/JsonView.stories.ts +38 -0
  13. package/src/components/JsonView.vue +34 -0
  14. package/src/components/KindBadge.stories.ts +72 -0
  15. package/src/components/KindBadge.vue +59 -0
  16. package/src/components/ListRow.stories.ts +56 -0
  17. package/src/components/ListRow.vue +48 -0
  18. package/src/components/MasterDetail.stories.ts +74 -0
  19. package/src/components/MasterDetail.vue +35 -0
  20. package/src/components/MonacoViewer.vue +143 -0
  21. package/src/components/PageHeader.stories.ts +45 -0
  22. package/src/components/PageHeader.vue +46 -0
  23. package/src/components/SchemaNode.vue +208 -0
  24. package/src/components/SchemaTree.vue +65 -0
  25. package/src/components/SourceDrawer.vue +136 -0
  26. package/src/components/SourcePill.vue +103 -0
  27. package/src/components/__tests__/EmptyState.test.ts +28 -0
  28. package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
  29. package/src/components/__tests__/FilterInput.test.ts +38 -0
  30. package/src/components/__tests__/JsonView.test.ts +33 -0
  31. package/src/components/__tests__/KindBadge.test.ts +39 -0
  32. package/src/components/__tests__/ListRow.test.ts +39 -0
  33. package/src/components/__tests__/MasterDetail.test.ts +40 -0
  34. package/src/components/__tests__/PageHeader.test.ts +42 -0
  35. package/src/components/index.ts +17 -0
  36. package/src/components/ui/badge/Badge.vue +17 -0
  37. package/src/components/ui/badge/index.ts +25 -0
  38. package/src/components/ui/button/Button.vue +28 -0
  39. package/src/components/ui/button/index.ts +34 -0
  40. package/src/components/ui/card/Card.vue +14 -0
  41. package/src/components/ui/card/CardContent.vue +14 -0
  42. package/src/components/ui/card/CardDescription.vue +14 -0
  43. package/src/components/ui/card/CardFooter.vue +14 -0
  44. package/src/components/ui/card/CardHeader.vue +14 -0
  45. package/src/components/ui/card/CardTitle.vue +14 -0
  46. package/src/components/ui/card/index.ts +6 -0
  47. package/src/components/ui/dialog/Dialog.vue +15 -0
  48. package/src/components/ui/dialog/DialogClose.vue +12 -0
  49. package/src/components/ui/dialog/DialogContent.vue +47 -0
  50. package/src/components/ui/dialog/DialogDescription.vue +22 -0
  51. package/src/components/ui/dialog/DialogFooter.vue +12 -0
  52. package/src/components/ui/dialog/DialogHeader.vue +14 -0
  53. package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
  54. package/src/components/ui/dialog/DialogTitle.vue +22 -0
  55. package/src/components/ui/dialog/DialogTrigger.vue +12 -0
  56. package/src/components/ui/dialog/index.ts +9 -0
  57. package/src/components/ui/input/Input.vue +32 -0
  58. package/src/components/ui/input/index.ts +1 -0
  59. package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
  60. package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
  61. package/src/components/ui/scroll-area/index.ts +2 -0
  62. package/src/components/ui/separator/Separator.vue +27 -0
  63. package/src/components/ui/separator/index.ts +1 -0
  64. package/src/components/ui/skeleton/Skeleton.vue +14 -0
  65. package/src/components/ui/skeleton/index.ts +1 -0
  66. package/src/components/ui/tabs/Tabs.vue +15 -0
  67. package/src/components/ui/tabs/TabsContent.vue +25 -0
  68. package/src/components/ui/tabs/TabsList.vue +25 -0
  69. package/src/components/ui/tabs/TabsTrigger.vue +29 -0
  70. package/src/components/ui/tabs/index.ts +4 -0
  71. package/src/components/ui/tooltip/Tooltip.vue +15 -0
  72. package/src/components/ui/tooltip/TooltipContent.vue +40 -0
  73. package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
  74. package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
  75. package/src/components/ui/tooltip/index.ts +4 -0
  76. package/src/composables/useCopy.ts +31 -0
  77. package/src/lib/__tests__/normalize-cache.test.ts +104 -0
  78. package/src/lib/cache.ts +334 -0
  79. package/src/lib/normalize-cache.ts +92 -0
  80. package/src/lib/project-catalog.ts +125 -0
  81. package/src/lib/utils.ts +6 -0
  82. package/src/main.ts +112 -0
  83. package/src/pages/Actions.vue +180 -0
  84. package/src/pages/Commands.vue +262 -0
  85. package/src/pages/Dispatch.vue +431 -0
  86. package/src/pages/Events.vue +166 -0
  87. package/src/pages/Home.stories.ts +47 -0
  88. package/src/pages/Home.vue +485 -0
  89. package/src/pages/Hooks.vue +297 -0
  90. package/src/pages/Live.vue +249 -0
  91. package/src/pages/Modules.vue +174 -0
  92. package/src/pages/Overview.vue +159 -0
  93. package/src/pages/Plugins.stories.ts +44 -0
  94. package/src/pages/Plugins.vue +403 -0
  95. package/src/pages/Projects.vue +272 -0
  96. package/src/pages/Run.vue +479 -0
  97. package/src/pages/Topology.vue +164 -0
  98. package/src/pages/Trace.vue +511 -0
  99. package/src/pages/TraceNode.vue +166 -0
  100. package/src/pages/Workflows.vue +191 -0
  101. package/src/pages/__tests__/Actions.test.ts +98 -0
  102. package/src/pages/__tests__/Home.test.ts +98 -0
  103. package/src/pages/__tests__/Hooks.test.ts +119 -0
  104. package/src/pages/__tests__/Plugins.test.ts +80 -0
  105. package/src/style.css +40 -0
  106. package/tsconfig.json +20 -0
  107. package/vite.config.ts +892 -0
package/vite.config.ts ADDED
@@ -0,0 +1,892 @@
1
+ import { defineConfig } from "vite";
2
+ import { resolve, dirname, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import vue from "@vitejs/plugin-vue";
5
+ import tailwindcss from "@tailwindcss/vite";
6
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
7
+ import { spawnSync } from "node:child_process";
8
+ import { RunnerSupervisor, inspectHealthCheck } from "@nwire/kernel";
9
+ import type { IncomingMessage, ServerResponse } from "node:http";
10
+ import { request as httpRequest } from "node:http";
11
+ import { createConnection, createServer as createNetServer, type AddressInfo } from "node:net";
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ const repoRoot = resolve(here, "..", "..");
15
+
16
+ /**
17
+ * The consumer's working directory — wherever the user invoked `nwire studio`.
18
+ * The CLI passes it through `NWIRE_CWD` so Studio reads `.nwire/`, topologies,
19
+ * etc. from the right project when launched outside the framework repo.
20
+ *
21
+ * Multi-project Studio (Shape A): every middleware endpoint accepts an
22
+ * optional `?project=<cwd>` query param that overrides this default, so a
23
+ * single Studio process can pivot between projects the browser knows about.
24
+ * The CWD passed must be one this Studio has been told about — see
25
+ * `knownProjects` below — otherwise we refuse the request to avoid serving
26
+ * arbitrary filesystem paths.
27
+ */
28
+ const consumerCwd = process.env.NWIRE_CWD ?? repoRoot;
29
+
30
+ /**
31
+ * Liveness check for a PID. `process.kill(pid, 0)` doesn't actually send
32
+ * a signal — it just probes whether the OS would deliver one. On POSIX
33
+ * a missing process throws ESRCH; on Windows the same call throws EPERM
34
+ * when the PID exists but is owned by another user (or when we lack
35
+ * SIGNAL access to it). Both POSIX-EPERM and Windows-EPERM mean "alive
36
+ * but we can't signal it" — treat as alive. Only ESRCH = dead.
37
+ */
38
+ function isPidAlive(pid: number): boolean {
39
+ try {
40
+ process.kill(pid, 0);
41
+ return true;
42
+ } catch (err) {
43
+ const code = (err as NodeJS.ErrnoException).code;
44
+ if (code === "EPERM") return true;
45
+ return false;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Set of CWDs the frontend has registered as "known projects." Populated by
51
+ * POST /__nwire/projects/register (called on Studio mount with every cwd
52
+ * from the localStorage catalog) plus the launch-time NWIRE_CWD. Acts as
53
+ * the allowlist for `?project=` query params.
54
+ */
55
+ const knownProjects = new Set<string>([consumerCwd]);
56
+
57
+ /**
58
+ * Resolve the cwd a request is targeting. Reads `?project=<cwd>` from the
59
+ * URL and validates it against `knownProjects`. Falls back to the launch-
60
+ * time consumerCwd when no param is present, preserving single-project
61
+ * behavior. Returns null on a disallowed cwd so callers can 400.
62
+ */
63
+ function targetCwd(req: IncomingMessage): string | null {
64
+ const u = new URL(req.url ?? "/", "http://localhost");
65
+ const param = u.searchParams.get("project");
66
+ if (!param) return consumerCwd;
67
+ return knownProjects.has(param) ? param : null;
68
+ }
69
+
70
+ /**
71
+ * Build the `.nwire/` cache on demand by running the CLI's cache-runner in
72
+ * the consumer's project. Returns true if the manifest is present after the
73
+ * attempt. Synchronous so the Vite middleware can serve the freshly built
74
+ * file on the same request.
75
+ */
76
+ function ensureCacheBuilt(cwd: string = consumerCwd): boolean {
77
+ const manifestPath = resolve(cwd, ".nwire", "manifest.json");
78
+ if (existsSync(manifestPath)) return true;
79
+ const cacheRunnerPath = resolve(here, "..", "nwire-cli", "dist", "cache-runner.js");
80
+ if (!existsSync(cacheRunnerPath)) return false;
81
+ const result = spawnSync("pnpm", ["exec", "vite-node", cacheRunnerPath], {
82
+ cwd,
83
+ stdio: "inherit",
84
+ // Windows: `pnpm` resolves to a `.cmd` shim; `shell:true` is needed
85
+ // for `spawn` to find it. POSIX is unaffected.
86
+ shell: process.platform === "win32",
87
+ });
88
+ if (result.status !== 0) return false;
89
+ return existsSync(manifestPath);
90
+ }
91
+
92
+ /**
93
+ * Vite middleware that exposes the consumer's `.nwire/manifest.json` to the
94
+ * Studio dev server. If the cache is missing, build it on the fly so the
95
+ * user never has to remember `nwire cache` before `nwire studio`.
96
+ */
97
+ function nwireDataPlugin() {
98
+ return {
99
+ name: "nwire-data",
100
+ configureServer(server: {
101
+ middlewares: {
102
+ use: (path: string, fn: (req: IncomingMessage, res: ServerResponse) => void) => void;
103
+ };
104
+ }) {
105
+ /**
106
+ * Project identity — name + cwd. Honors `?project=<cwd>` for the
107
+ * multi-project shell; defaults to the launch-time NWIRE_CWD when
108
+ * no override is present. Sidebar header + Projects page consume it.
109
+ */
110
+ server.middlewares.use("/__nwire/project", (req, res) => {
111
+ const cwd = targetCwd(req);
112
+ if (!cwd) {
113
+ res.statusCode = 400;
114
+ res.setHeader("Content-Type", "application/json");
115
+ res.end(JSON.stringify({ error: "unknown project — register it first" }));
116
+ return;
117
+ }
118
+ let name = "(unnamed)";
119
+ try {
120
+ const pj = resolve(cwd, "package.json");
121
+ if (existsSync(pj)) {
122
+ const parsed = JSON.parse(readFileSync(pj, "utf8")) as { name?: string };
123
+ if (parsed.name) name = parsed.name;
124
+ }
125
+ } catch {
126
+ // keep default
127
+ }
128
+ res.setHeader("Content-Type", "application/json");
129
+ res.end(JSON.stringify({ name, cwd, launchedFrom: consumerCwd }));
130
+ });
131
+
132
+ /**
133
+ * Register a project so subsequent `?project=<cwd>` calls accept it.
134
+ * POST /__nwire/projects/register { cwd }
135
+ * The frontend calls this on mount for every entry in its localStorage
136
+ * catalog so a fresh Studio process can serve them without restart.
137
+ */
138
+ server.middlewares.use("/__nwire/projects/register", (req, res) => {
139
+ if (req.method !== "POST") {
140
+ res.statusCode = 405;
141
+ res.end();
142
+ return;
143
+ }
144
+ const chunks: Buffer[] = [];
145
+ req.on("data", (c) => chunks.push(c as Buffer));
146
+ req.on("end", () => {
147
+ try {
148
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as {
149
+ cwd?: string;
150
+ };
151
+ if (body.cwd && existsSync(body.cwd)) {
152
+ knownProjects.add(body.cwd);
153
+ res.setHeader("Content-Type", "application/json");
154
+ res.end(JSON.stringify({ ok: true, registered: body.cwd }));
155
+ } else {
156
+ res.statusCode = 400;
157
+ res.setHeader("Content-Type", "application/json");
158
+ res.end(JSON.stringify({ error: "cwd missing or not on disk" }));
159
+ }
160
+ } catch (err) {
161
+ res.statusCode = 400;
162
+ res.setHeader("Content-Type", "application/json");
163
+ res.end(JSON.stringify({ error: (err as Error).message }));
164
+ }
165
+ });
166
+ });
167
+
168
+ /**
169
+ * Project catalog status — multi-project Studio.
170
+ *
171
+ * POST /__nwire/projects/status { cwds: string[] }
172
+ *
173
+ * Returns running-process info for each cwd by reading its
174
+ * `.nwire/processes/*.json` registry. This lets the frontend
175
+ * catalog page show green/red dots without needing each project's
176
+ * Studio to be open. Stale entries (PID not alive) are filtered.
177
+ */
178
+ server.middlewares.use("/__nwire/projects/status", (req, res) => {
179
+ if (req.method !== "POST") {
180
+ res.statusCode = 405;
181
+ res.end();
182
+ return;
183
+ }
184
+ const chunks: Buffer[] = [];
185
+ req.on("data", (c) => chunks.push(c as Buffer));
186
+ req.on("end", () => {
187
+ let cwds: string[] = [];
188
+ try {
189
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as {
190
+ cwds?: string[];
191
+ };
192
+ cwds = Array.isArray(body.cwds) ? body.cwds : [];
193
+ } catch {
194
+ // empty body → empty result
195
+ }
196
+ type ProcessRec = {
197
+ id: string;
198
+ port?: number;
199
+ pid: number;
200
+ status: string;
201
+ startedAt: string;
202
+ };
203
+ const result: Record<string, { hasManifest: boolean; processes: ProcessRec[] }> = {};
204
+ for (const cwd of cwds) {
205
+ const procDir = resolve(cwd, ".nwire", "processes");
206
+ const manifestPath = resolve(cwd, ".nwire", "manifest.json");
207
+ const live: ProcessRec[] = [];
208
+ if (existsSync(procDir)) {
209
+ try {
210
+ for (const f of readdirSync(procDir)) {
211
+ if (!f.endsWith(".json")) continue;
212
+ try {
213
+ const rec = JSON.parse(readFileSync(resolve(procDir, f), "utf8")) as ProcessRec;
214
+ if (rec.pid > 0 && isPidAlive(rec.pid)) {
215
+ live.push(rec);
216
+ }
217
+ } catch {
218
+ // malformed entry — skip
219
+ }
220
+ }
221
+ } catch {
222
+ // unreadable dir
223
+ }
224
+ }
225
+ result[cwd] = { hasManifest: existsSync(manifestPath), processes: live };
226
+ }
227
+ res.setHeader("Content-Type", "application/json");
228
+ res.end(JSON.stringify(result));
229
+ });
230
+ });
231
+
232
+ server.middlewares.use("/__nwire/manifest.json", (req, res) => {
233
+ const cwd = targetCwd(req);
234
+ if (!cwd) {
235
+ res.statusCode = 400;
236
+ res.end(JSON.stringify({ error: "unknown project" }));
237
+ return;
238
+ }
239
+ const manifestPath = resolve(cwd, ".nwire", "manifest.json");
240
+ if (!existsSync(manifestPath) && !ensureCacheBuilt(cwd)) {
241
+ res.statusCode = 404;
242
+ res.end(
243
+ JSON.stringify({
244
+ error:
245
+ "nwire cache could not be built. Check that apps/apps.ts or " +
246
+ "nwire.config.ts points at a valid apps registry.",
247
+ }),
248
+ );
249
+ return;
250
+ }
251
+ res.setHeader("Content-Type", "application/json");
252
+ res.end(readFileSync(manifestPath, "utf8"));
253
+ });
254
+
255
+ // Source viewer — reads a file from disk, returns { content, language }.
256
+ // Permission scope: the active project's cwd OR any registered project
257
+ // (multi-project Shape A) OR our own workspace dist for monorepo dev.
258
+ // Anything outside → 403.
259
+ server.middlewares.use("/__nwire/source", (req, res) => {
260
+ try {
261
+ const u = new URL(req.url ?? "/", "http://localhost");
262
+ const requested = u.searchParams.get("path");
263
+ if (!requested) {
264
+ res.statusCode = 400;
265
+ res.end(JSON.stringify({ error: "missing ?path=" }));
266
+ return;
267
+ }
268
+ const absolute = resolve(requested);
269
+ const allowedRoot = [...knownProjects].some((root) => absolute.startsWith(root));
270
+ // Use `path.sep` so the framework-source fallback works on
271
+ // Windows too — V8/Node emit `C:\…\packages\nwire-…` paths with
272
+ // backslashes, which the hardcoded `/packages/nwire-` substring
273
+ // sniff would never match.
274
+ const frameworkSegment = `${sep}packages${sep}nwire-`;
275
+ if (!allowedRoot && !absolute.includes(frameworkSegment)) {
276
+ res.statusCode = 403;
277
+ res.end(
278
+ JSON.stringify({
279
+ error: "path is outside the Studio working directory",
280
+ path: absolute,
281
+ }),
282
+ );
283
+ return;
284
+ }
285
+ if (!existsSync(absolute)) {
286
+ res.statusCode = 404;
287
+ res.end(JSON.stringify({ error: "file not found", path: absolute }));
288
+ return;
289
+ }
290
+ const content = readFileSync(absolute, "utf8");
291
+ const language = inferLanguage(absolute);
292
+ res.setHeader("Content-Type", "application/json");
293
+ res.end(JSON.stringify({ content, language, path: absolute }));
294
+ } catch (err) {
295
+ res.statusCode = 500;
296
+ res.end(JSON.stringify({ error: (err as Error).message }));
297
+ }
298
+ });
299
+ },
300
+ };
301
+ }
302
+
303
+ function inferLanguage(file: string): string {
304
+ if (
305
+ file.endsWith(".ts") ||
306
+ file.endsWith(".tsx") ||
307
+ file.endsWith(".mts") ||
308
+ file.endsWith(".cts")
309
+ )
310
+ return "typescript";
311
+ if (
312
+ file.endsWith(".js") ||
313
+ file.endsWith(".jsx") ||
314
+ file.endsWith(".mjs") ||
315
+ file.endsWith(".cjs")
316
+ )
317
+ return "javascript";
318
+ if (file.endsWith(".vue")) return "html";
319
+ if (file.endsWith(".json")) return "json";
320
+ if (file.endsWith(".md")) return "markdown";
321
+ if (file.endsWith(".yaml") || file.endsWith(".yml")) return "yaml";
322
+ if (file.endsWith(".css")) return "css";
323
+ return "plaintext";
324
+ }
325
+
326
+ /**
327
+ * Studio-as-runner middleware. Exposes a small HTTP surface so Studio's UI
328
+ * can list topologies, start/stop processes, and stream their logs.
329
+ *
330
+ * GET /__nwire/run/topologies
331
+ * GET /__nwire/run/commands
332
+ * GET /__nwire/run/processes
333
+ * POST /__nwire/run/start { topology, port? }
334
+ * POST /__nwire/run/exec { command, args? }
335
+ * POST /__nwire/run/stop/:id
336
+ * POST /__nwire/run/forget/:id
337
+ * GET /__nwire/run/logs/:id/recent?limit=…
338
+ * GET /__nwire/run/logs/:id/stream (SSE)
339
+ *
340
+ * One supervisor instance per Vite dev server. Lifecycle is tied to the
341
+ * Vite process — when the user kills Studio, every spawned child is
342
+ * stopped.
343
+ */
344
+ /**
345
+ * The single RunnerSupervisor instance for this Vite dev server. Lives at
346
+ * module scope so both the runner middleware AND the dynamic `/_nwire/*`
347
+ * proxy middleware see the same managed processes.
348
+ */
349
+ const supervisor = new RunnerSupervisor();
350
+
351
+ const cleanupSupervisor = () => {
352
+ void supervisor.stopAll();
353
+ };
354
+ process.once("SIGINT", cleanupSupervisor);
355
+ process.once("SIGTERM", cleanupSupervisor);
356
+ process.once("exit", cleanupSupervisor);
357
+
358
+ /**
359
+ * Pick the port for the most-recently-started running process Studio
360
+ * should target. Looks in three places, in order:
361
+ *
362
+ * 1. Studio's in-process supervisor (processes started via the Run page).
363
+ * 2. The consumer's `.nwire/processes/*.json` registry — populated by
364
+ * `nwire dev` running in another terminal. This makes "Studio in
365
+ * tab A, `nwire dev` in tab B" Just Work.
366
+ * 3. A short TCP-port probe of `NWIRE_PROBE_PORTS` (defaults 3000–3010),
367
+ * so the most common case — `pnpm dev` (vite-node) in another terminal,
368
+ * no managed-process registry — also Just Works. Cached for 5s.
369
+ *
370
+ * Returns undefined when nothing's running — the static proxy then falls
371
+ * back to `NWIRE_INSPECT_URL`, and finally to localhost:3000.
372
+ */
373
+ const probePorts: readonly number[] = process.env.NWIRE_PROBE_PORTS?.split(",")
374
+ .map((s) => Number(s.trim()))
375
+ .filter((n) => n > 0) ??
376
+ // 3000-3010 are common dev defaults; 3030 is the station-management example;
377
+ // 4000 + 8080 are popular alternates. Probes are HTTP-validated so unrelated
378
+ // services on these ports are safely skipped.
379
+ [3000, 3001, 3002, 3003, 3004, 3005, 3006, 3007, 3008, 3009, 3010, 3030, 3040, 3050, 4000, 8080];
380
+
381
+ let probedPort: number | undefined;
382
+ let probedExpires: number = 0;
383
+
384
+ async function probeOpenPort(): Promise<number | undefined> {
385
+ if (Date.now() < probedExpires) return probedPort;
386
+ for (const port of probePorts) {
387
+ if (await isNwireWire(port, 150)) {
388
+ probedPort = port;
389
+ probedExpires = Date.now() + 5_000;
390
+ return port;
391
+ }
392
+ }
393
+ probedPort = undefined;
394
+ probedExpires = Date.now() + 1_500; // brief negative cache
395
+ return undefined;
396
+ }
397
+
398
+ /**
399
+ * Probe a single port. We want to *only* match a running nwire wire,
400
+ * not any random local service on the same port, so we do a real HTTP
401
+ * GET against `/_nwire/events/recent` (always present when inspect is
402
+ * mounted) and require a JSON-array response. A TCP-only probe matched
403
+ * unrelated services and broke the proxy.
404
+ */
405
+ function isNwireWire(port: number, timeoutMs: number): Promise<boolean> {
406
+ return new Promise((resolve) => {
407
+ // Skip if the socket can't even open.
408
+ const sock = createConnection({ host: "127.0.0.1", port });
409
+ sock.setTimeout(timeoutMs, () => {
410
+ sock.destroy();
411
+ resolve(false);
412
+ });
413
+ sock.once("error", () => resolve(false));
414
+ sock.once("connect", () => {
415
+ sock.destroy();
416
+ const req = httpRequest(
417
+ { host: "127.0.0.1", port, path: "/_nwire/events/recent?limit=1", method: "GET" },
418
+ (res) => {
419
+ if (res.statusCode !== 200) {
420
+ res.resume();
421
+ return resolve(false);
422
+ }
423
+ let buf = "";
424
+ res.setEncoding("utf8");
425
+ res.on("data", (c: string) => {
426
+ buf += c;
427
+ if (buf.length > 1024) res.destroy();
428
+ });
429
+ res.on("end", () => resolve(buf.trimStart().startsWith("[")));
430
+ },
431
+ );
432
+ req.setTimeout(timeoutMs, () => {
433
+ req.destroy();
434
+ resolve(false);
435
+ });
436
+ req.once("error", () => resolve(false));
437
+ req.end();
438
+ });
439
+ });
440
+ }
441
+ async function activeProcessPort(cwd: string): Promise<number | undefined> {
442
+ // 1. In-process supervisor — scoped to processes whose cwd matches.
443
+ // Cross-project Studio: a process started from project A shouldn't
444
+ // receive proxy traffic for project B.
445
+ const managed = supervisor
446
+ .list()
447
+ .filter((p) => p.status === "running" && p.port && (p as { cwd?: string }).cwd === cwd);
448
+ if (managed.length > 0) {
449
+ managed.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
450
+ return managed[0]!.port;
451
+ }
452
+ // 2. External `nwire dev` registry. Filter to entries whose PID is
453
+ // still alive — stale records linger after a Ctrl+C without cleanup.
454
+ const procDir = resolve(cwd, ".nwire", "processes");
455
+ if (existsSync(procDir)) {
456
+ let entries: string[] = [];
457
+ try {
458
+ entries = readdirSync(procDir);
459
+ } catch {
460
+ // ignore
461
+ }
462
+ type ExternalRec = { status: string; port?: number; pid: number; startedAt: string };
463
+ const live: ExternalRec[] = [];
464
+ for (const f of entries) {
465
+ if (!f.endsWith(".json")) continue;
466
+ try {
467
+ const raw = readFileSync(resolve(procDir, f), "utf8");
468
+ const rec = JSON.parse(raw) as ExternalRec;
469
+ if (rec.status !== "running" || !rec.port || rec.pid <= 0) continue;
470
+ if (!isPidAlive(rec.pid)) continue;
471
+ live.push(rec);
472
+ } catch {
473
+ // skip malformed entry
474
+ }
475
+ }
476
+ if (live.length > 0) {
477
+ live.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
478
+ return live[0]!.port;
479
+ }
480
+ }
481
+ // 3. TCP port probe — catches `pnpm dev` / vite-node / any non-CLI launcher.
482
+ return probeOpenPort();
483
+ }
484
+
485
+ /**
486
+ * Dynamic `/_nwire/*` proxy. When a managed process is running, forward
487
+ * to its port. When none is up, fall through to Vite's static proxy (which
488
+ * uses `NWIRE_INSPECT_URL`). Supports SSE — pipes upstream chunks straight
489
+ * through without buffering.
490
+ */
491
+ function nwireProxyPlugin() {
492
+ return {
493
+ name: "nwire-dynamic-proxy",
494
+ configureServer(server: {
495
+ middlewares: {
496
+ use: (
497
+ handler: (req: IncomingMessage, res: ServerResponse, next: () => void) => void,
498
+ ) => void;
499
+ };
500
+ }) {
501
+ server.middlewares.use(async (req, res, next) => {
502
+ const url = req.url ?? "";
503
+ if (!url.startsWith("/_nwire/")) return next();
504
+ // The frontend appends `?project=<cwd>` to every fetch when
505
+ // multi-project Studio is active; we resolve the target project's
506
+ // wire here so /_nwire/* always lands on the right backend.
507
+ const cwd = targetCwd(req);
508
+ if (!cwd) {
509
+ res.statusCode = 400;
510
+ res.setHeader("Content-Type", "application/json");
511
+ res.end(JSON.stringify({ error: "unknown project" }));
512
+ return;
513
+ }
514
+ const port = await activeProcessPort(cwd);
515
+ if (!port) {
516
+ // No managed process — let Vite's static proxy take it.
517
+ return next();
518
+ }
519
+ // Strip browser-side noise before forwarding. Studio's origin shares
520
+ // a port with whatever else on localhost the user's browser has
521
+ // accumulated cookies for — sending a 5KB cookie blob upstream
522
+ // wedges some wires (Koa default header parsing, etc.) and surfaces
523
+ // as 'socket hang up'. The wire never authenticates against these,
524
+ // so dropping them is pure win.
525
+ const forwardedHeaders: Record<string, string | string[]> = {};
526
+ for (const [k, v] of Object.entries(req.headers)) {
527
+ if (v === undefined) continue;
528
+ const lower = k.toLowerCase();
529
+ if (lower === "cookie" || lower === "referer" || lower === "origin") continue;
530
+ if (lower === "host" || lower === "connection") continue;
531
+ forwardedHeaders[k] = v as string | string[];
532
+ }
533
+ forwardedHeaders.host = `127.0.0.1:${port}`;
534
+ // Avoid stale keep-alive reuse — short-lived dev requests don't
535
+ // benefit, and a reset upstream socket otherwise surfaces here as
536
+ // 'socket hang up'.
537
+ forwardedHeaders.connection = "close";
538
+
539
+ const upstream = httpRequest(
540
+ {
541
+ host: "127.0.0.1",
542
+ port,
543
+ method: req.method,
544
+ path: url,
545
+ headers: forwardedHeaders,
546
+ },
547
+ (upRes) => {
548
+ res.statusCode = upRes.statusCode ?? 502;
549
+ for (const [k, v] of Object.entries(upRes.headers)) {
550
+ if (v !== undefined) res.setHeader(k, v as string | string[]);
551
+ }
552
+ upRes.pipe(res);
553
+ },
554
+ );
555
+ upstream.on("error", (err) => {
556
+ res.statusCode = 502;
557
+ res.setHeader("Content-Type", "application/json");
558
+ res.end(
559
+ JSON.stringify({
560
+ error: `proxy failed: ${err.message}`,
561
+ upstream: `127.0.0.1:${port}${url}`,
562
+ hint: "wire may have crashed or rejected the request; check the dev terminal for a stack trace",
563
+ }),
564
+ );
565
+ });
566
+ // GET / DELETE / HEAD have no body — calling pipe() on a Vite-parsed
567
+ // IncomingMessage that's already been consumed by upstream middleware
568
+ // can hang upstream waiting for bytes that never arrive. End directly
569
+ // for bodyless methods.
570
+ const method = (req.method ?? "GET").toUpperCase();
571
+ if (method === "GET" || method === "HEAD" || method === "DELETE") {
572
+ upstream.end();
573
+ } else {
574
+ req.pipe(upstream);
575
+ }
576
+ // SSE / long-lived: tear down upstream when client disconnects.
577
+ req.on("close", () => upstream.destroy());
578
+ });
579
+ },
580
+ };
581
+ }
582
+
583
+ function nwireRunnerPlugin() {
584
+ // No-op: cleanup hooks live at module scope above.
585
+ const cleanup = () => {
586
+ /* registered at module load */
587
+ };
588
+ void cleanup;
589
+
590
+ function listTopologies(cwd: string): string[] {
591
+ const dir = resolve(cwd, "apps", "topologies");
592
+ if (!existsSync(dir)) return [];
593
+ return readdirSync(dir)
594
+ .filter((n) => n.endsWith(".topology.ts"))
595
+ .map((n) => n.replace(/\.topology\.ts$/, ""))
596
+ .sort();
597
+ }
598
+
599
+ /**
600
+ * Consumer's `package.json` scripts — used as a Run-page fallback for
601
+ * single-app projects that don't ship a topology folder. Returned in
602
+ * declaration order; callers POST `{ script }` to `/__nwire/run/exec-script`
603
+ * to launch one through the same supervisor that hosts topologies.
604
+ */
605
+ /**
606
+ * Find an unused TCP port the OS will let us bind. Returns the assigned
607
+ * port from a transient `listen(0)` then immediately releases it. Used as
608
+ * the default for managed-process starts so collisions with the user's
609
+ * existing services don't break the dev loop.
610
+ */
611
+ function findFreePort(): Promise<number> {
612
+ return new Promise((resolve, reject) => {
613
+ const srv = createNetServer();
614
+ srv.unref();
615
+ srv.once("error", reject);
616
+ srv.listen(0, "127.0.0.1", () => {
617
+ const addr = srv.address() as AddressInfo | null;
618
+ const port = addr?.port;
619
+ srv.close(() => {
620
+ if (port) resolve(port);
621
+ else reject(new Error("findFreePort: could not allocate"));
622
+ });
623
+ });
624
+ });
625
+ }
626
+
627
+ function listScripts(cwd: string): Array<{ name: string; command: string }> {
628
+ const pj = resolve(cwd, "package.json");
629
+ if (!existsSync(pj)) return [];
630
+ try {
631
+ const raw = readFileSync(pj, "utf8");
632
+ const parsed = JSON.parse(raw) as { scripts?: Record<string, string> };
633
+ const scripts = parsed.scripts ?? {};
634
+ return Object.entries(scripts).map(([name, command]) => ({ name, command }));
635
+ } catch {
636
+ return [];
637
+ }
638
+ }
639
+
640
+ async function readBody(req: IncomingMessage): Promise<unknown> {
641
+ const chunks: Buffer[] = [];
642
+ for await (const chunk of req) chunks.push(chunk as Buffer);
643
+ const raw = Buffer.concat(chunks).toString("utf8");
644
+ if (!raw) return undefined;
645
+ try {
646
+ return JSON.parse(raw);
647
+ } catch {
648
+ return undefined;
649
+ }
650
+ }
651
+
652
+ function send(res: ServerResponse, status: number, body: unknown) {
653
+ res.statusCode = status;
654
+ res.setHeader("Content-Type", "application/json");
655
+ res.end(JSON.stringify(body));
656
+ }
657
+
658
+ return {
659
+ name: "nwire-runner",
660
+ configureServer(server: {
661
+ middlewares: {
662
+ use: (
663
+ handler: (req: IncomingMessage, res: ServerResponse, next: () => void) => void,
664
+ ) => void;
665
+ };
666
+ }) {
667
+ server.middlewares.use((req, res, next) => {
668
+ const url = req.url ?? "";
669
+ const method = req.method ?? "GET";
670
+ if (!url.startsWith("/__nwire/run/")) return next();
671
+ // URLs now carry `?project=<cwd>` for multi-project Studio. Match
672
+ // routes against the pathname only; resolve target cwd via the
673
+ // shared helper so each request acts on the right project's
674
+ // topologies / scripts / processes.
675
+ const pathname = new URL(url, "http://x").pathname;
676
+ const cwd = targetCwd(req);
677
+ if (!cwd) {
678
+ return send(res, 400, { error: "unknown project" });
679
+ }
680
+
681
+ if (method === "GET" && pathname === "/__nwire/run/topologies") {
682
+ return send(res, 200, { topologies: listTopologies(cwd) });
683
+ }
684
+
685
+ if (method === "GET" && pathname === "/__nwire/run/scripts") {
686
+ return send(res, 200, { scripts: listScripts(cwd) });
687
+ }
688
+
689
+ if (method === "POST" && pathname === "/__nwire/run/exec-script") {
690
+ // Launch `pnpm run <script>` from the active project's cwd via
691
+ // the shared supervisor. The `topology` label becomes `pnpm
692
+ // <script>` so the existing UI renders it interchangeably with
693
+ // topology + nwire-command rows.
694
+ void (async () => {
695
+ const body = (await readBody(req)) as { script?: string } | undefined;
696
+ if (!body?.script) {
697
+ return send(res, 400, { error: "body.script required" });
698
+ }
699
+ try {
700
+ // Same auto-port story as topology start. Scripts that read
701
+ // process.env.PORT pick it up; scripts that don't ignore it
702
+ // harmlessly.
703
+ const port = await findFreePort();
704
+ const proc = await supervisor.start({
705
+ topology: `pnpm ${body.script}`,
706
+ cwd,
707
+ command: ["pnpm", "run", body.script],
708
+ port,
709
+ });
710
+ return send(res, 200, { process: proc });
711
+ } catch (err) {
712
+ return send(res, 500, { error: (err as Error).message });
713
+ }
714
+ })();
715
+ return;
716
+ }
717
+
718
+ if (method === "GET" && pathname === "/__nwire/run/commands") {
719
+ // The Commands panel lists the nwire CLI surface a user can
720
+ // launch directly. We hard-code the safe subset; future work
721
+ // can read the citty subcommand manifest if richer metadata
722
+ // is wanted.
723
+ return send(res, 200, {
724
+ commands: [
725
+ { name: "dev", description: "Boot the dev-all topology with watch mode" },
726
+ { name: "test", description: "Run vitest layers (units, integration, e2e, bdd)" },
727
+ { name: "build", description: "Build deployable bundles for one or every wire" },
728
+ { name: "cache", description: "Rebuild .nwire/ cache" },
729
+ { name: "ls", description: "List discovered wires + actions" },
730
+ { name: "fmt", description: "Format the project with oxfmt" },
731
+ { name: "lint", description: "Lint the project with oxlint" },
732
+ { name: "check", description: "format-check + lint + typecheck" },
733
+ ],
734
+ });
735
+ }
736
+
737
+ if (method === "GET" && pathname === "/__nwire/run/processes") {
738
+ // Scoped to active project — users see only processes started
739
+ // from this project's cwd (managed via Studio's Run page).
740
+ const processes = supervisor.list().filter((p) => (p as { cwd?: string }).cwd === cwd);
741
+ return send(res, 200, { processes });
742
+ }
743
+
744
+ if (method === "POST" && pathname === "/__nwire/run/start") {
745
+ void (async () => {
746
+ const body = (await readBody(req)) as { topology?: string; port?: number } | undefined;
747
+ if (!body?.topology) {
748
+ return send(res, 400, { error: "body.topology required" });
749
+ }
750
+ try {
751
+ // Auto-port: skip collisions on 3000 (commonly squatted). The
752
+ // supervisor forwards this as PORT env so consumer endpoints
753
+ // that honor process.env.PORT bind there automatically.
754
+ const port = body.port ?? (await findFreePort());
755
+ const proc = await supervisor.start({
756
+ topology: body.topology,
757
+ port,
758
+ cwd,
759
+ healthCheck: inspectHealthCheck(port),
760
+ healthIntervalMs: 400,
761
+ healthTimeoutMs: 60_000,
762
+ });
763
+ return send(res, 200, { process: proc });
764
+ } catch (err) {
765
+ return send(res, 500, { error: (err as Error).message });
766
+ }
767
+ })();
768
+ return;
769
+ }
770
+
771
+ if (method === "POST" && pathname === "/__nwire/run/exec") {
772
+ // Run an `nwire <command> [...args]` invocation via the same
773
+ // supervisor that hosts topologies. The `topology` label on the
774
+ // managed process becomes `nwire <command>` so the existing UI
775
+ // can render it interchangeably.
776
+ //
777
+ // Optional `cwd` lets the Projects page run a command against a
778
+ // different project's directory (e.g., "Re-scan composition"
779
+ // dispatches `nwire cache` against the project's cwd, not Studio's).
780
+ void (async () => {
781
+ const body = (await readBody(req)) as
782
+ | { command?: string; args?: readonly string[]; cwd?: string }
783
+ | undefined;
784
+ if (!body?.command) {
785
+ return send(res, 400, { error: "body.command required" });
786
+ }
787
+ try {
788
+ const proc = await supervisor.start({
789
+ topology: `nwire ${body.command}`,
790
+ cwd: typeof body.cwd === "string" && body.cwd.length > 0 ? body.cwd : cwd,
791
+ command: ["pnpm", "exec", "nwire", body.command, ...(body.args ?? [])],
792
+ });
793
+ return send(res, 200, { process: proc });
794
+ } catch (err) {
795
+ return send(res, 500, { error: (err as Error).message });
796
+ }
797
+ })();
798
+ return;
799
+ }
800
+
801
+ const stopMatch = /^\/__nwire\/run\/stop\/([^/]+)$/.exec(pathname);
802
+ if (method === "POST" && stopMatch) {
803
+ void (async () => {
804
+ await supervisor.stop(stopMatch[1]!);
805
+ return send(res, 200, { process: supervisor.get(stopMatch[1]!) });
806
+ })();
807
+ return;
808
+ }
809
+
810
+ const forgetMatch = /^\/__nwire\/run\/forget\/([^/]+)$/.exec(pathname);
811
+ if (method === "POST" && forgetMatch) {
812
+ try {
813
+ supervisor.forget(forgetMatch[1]!);
814
+ return send(res, 200, { ok: true });
815
+ } catch (err) {
816
+ return send(res, 400, { error: (err as Error).message });
817
+ }
818
+ }
819
+
820
+ const logsRecentMatch = /^\/__nwire\/run\/logs\/([^/]+)\/recent$/.exec(pathname);
821
+ if (method === "GET" && logsRecentMatch) {
822
+ const id = logsRecentMatch[1]!;
823
+ const search = new URL(url, "http://x").searchParams;
824
+ const limit = search.get("limit") ? Number(search.get("limit")) : undefined;
825
+ return send(res, 200, { logs: supervisor.recentLogs(id, limit) });
826
+ }
827
+
828
+ const logsStreamMatch = /^\/__nwire\/run\/logs\/([^/]+)\/stream$/.exec(pathname);
829
+ if (method === "GET" && logsStreamMatch) {
830
+ const id = logsStreamMatch[1]!;
831
+ res.statusCode = 200;
832
+ res.setHeader("Content-Type", "text/event-stream");
833
+ res.setHeader("Cache-Control", "no-cache, no-transform");
834
+ res.setHeader("Connection", "keep-alive");
835
+ // Backfill.
836
+ for (const line of supervisor.recentLogs(id, 200)) {
837
+ res.write(`data: ${JSON.stringify(line)}\n\n`);
838
+ }
839
+ const listener = (procId: string, line: unknown) => {
840
+ if (procId !== id) return;
841
+ try {
842
+ res.write(`data: ${JSON.stringify(line)}\n\n`);
843
+ } catch {
844
+ // socket gone
845
+ }
846
+ };
847
+ supervisor.on("log", listener);
848
+ const keepalive = setInterval(() => {
849
+ try {
850
+ res.write(": keepalive\n\n");
851
+ } catch {
852
+ // socket gone
853
+ }
854
+ }, 25_000);
855
+ req.on("close", () => {
856
+ supervisor.off("log", listener);
857
+ clearInterval(keepalive);
858
+ });
859
+ return;
860
+ }
861
+
862
+ return send(res, 404, { error: `unknown runner route: ${method} ${url}` });
863
+ });
864
+ },
865
+ };
866
+ }
867
+
868
+ export default defineConfig({
869
+ // Order matters: nwireProxyPlugin must run BEFORE Vite's static `proxy`
870
+ // config so it can intercept /_nwire/* when a managed process is active.
871
+ // When no managed process is up, the static proxy below handles the
872
+ // fallback to NWIRE_INSPECT_URL.
873
+ plugins: [vue(), tailwindcss(), nwireDataPlugin(), nwireProxyPlugin(), nwireRunnerPlugin()],
874
+ resolve: {
875
+ alias: { "@": resolve(here, "src") },
876
+ },
877
+ server: {
878
+ port: 7777,
879
+ host: "0.0.0.0",
880
+ strictPort: false,
881
+ // `/_nwire/*` is served by the running wire (httpInterface({ inspect: true })),
882
+ // not by Studio itself. Proxy through so Studio's pages can fetch live state
883
+ // and open the SSE stream without CORS.
884
+ proxy: {
885
+ "/_nwire": {
886
+ target: process.env.NWIRE_INSPECT_URL ?? "http://localhost:3000",
887
+ changeOrigin: true,
888
+ ws: false,
889
+ },
890
+ },
891
+ },
892
+ });