@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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/components.json +19 -0
- package/index.html +12 -0
- package/package.json +66 -0
- package/src/App.vue +305 -0
- package/src/components/EmptyState.stories.ts +53 -0
- package/src/components/EmptyState.vue +28 -0
- package/src/components/ErrorBoundary.vue +60 -0
- package/src/components/FilterInput.stories.ts +32 -0
- package/src/components/FilterInput.vue +33 -0
- package/src/components/JsonView.stories.ts +38 -0
- package/src/components/JsonView.vue +34 -0
- package/src/components/KindBadge.stories.ts +72 -0
- package/src/components/KindBadge.vue +59 -0
- package/src/components/ListRow.stories.ts +56 -0
- package/src/components/ListRow.vue +48 -0
- package/src/components/MasterDetail.stories.ts +74 -0
- package/src/components/MasterDetail.vue +35 -0
- package/src/components/MonacoViewer.vue +143 -0
- package/src/components/PageHeader.stories.ts +45 -0
- package/src/components/PageHeader.vue +46 -0
- package/src/components/SchemaNode.vue +208 -0
- package/src/components/SchemaTree.vue +65 -0
- package/src/components/SourceDrawer.vue +136 -0
- package/src/components/SourcePill.vue +103 -0
- package/src/components/__tests__/EmptyState.test.ts +28 -0
- package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
- package/src/components/__tests__/FilterInput.test.ts +38 -0
- package/src/components/__tests__/JsonView.test.ts +33 -0
- package/src/components/__tests__/KindBadge.test.ts +39 -0
- package/src/components/__tests__/ListRow.test.ts +39 -0
- package/src/components/__tests__/MasterDetail.test.ts +40 -0
- package/src/components/__tests__/PageHeader.test.ts +42 -0
- package/src/components/index.ts +17 -0
- package/src/components/ui/badge/Badge.vue +17 -0
- package/src/components/ui/badge/index.ts +25 -0
- package/src/components/ui/button/Button.vue +28 -0
- package/src/components/ui/button/index.ts +34 -0
- package/src/components/ui/card/Card.vue +14 -0
- package/src/components/ui/card/CardContent.vue +14 -0
- package/src/components/ui/card/CardDescription.vue +14 -0
- package/src/components/ui/card/CardFooter.vue +14 -0
- package/src/components/ui/card/CardHeader.vue +14 -0
- package/src/components/ui/card/CardTitle.vue +14 -0
- package/src/components/ui/card/index.ts +6 -0
- package/src/components/ui/dialog/Dialog.vue +15 -0
- package/src/components/ui/dialog/DialogClose.vue +12 -0
- package/src/components/ui/dialog/DialogContent.vue +47 -0
- package/src/components/ui/dialog/DialogDescription.vue +22 -0
- package/src/components/ui/dialog/DialogFooter.vue +12 -0
- package/src/components/ui/dialog/DialogHeader.vue +14 -0
- package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
- package/src/components/ui/dialog/DialogTitle.vue +22 -0
- package/src/components/ui/dialog/DialogTrigger.vue +12 -0
- package/src/components/ui/dialog/index.ts +9 -0
- package/src/components/ui/input/Input.vue +32 -0
- package/src/components/ui/input/index.ts +1 -0
- package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
- package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
- package/src/components/ui/scroll-area/index.ts +2 -0
- package/src/components/ui/separator/Separator.vue +27 -0
- package/src/components/ui/separator/index.ts +1 -0
- package/src/components/ui/skeleton/Skeleton.vue +14 -0
- package/src/components/ui/skeleton/index.ts +1 -0
- package/src/components/ui/tabs/Tabs.vue +15 -0
- package/src/components/ui/tabs/TabsContent.vue +25 -0
- package/src/components/ui/tabs/TabsList.vue +25 -0
- package/src/components/ui/tabs/TabsTrigger.vue +29 -0
- package/src/components/ui/tabs/index.ts +4 -0
- package/src/components/ui/tooltip/Tooltip.vue +15 -0
- package/src/components/ui/tooltip/TooltipContent.vue +40 -0
- package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
- package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
- package/src/components/ui/tooltip/index.ts +4 -0
- package/src/composables/useCopy.ts +31 -0
- package/src/lib/__tests__/normalize-cache.test.ts +104 -0
- package/src/lib/cache.ts +334 -0
- package/src/lib/normalize-cache.ts +92 -0
- package/src/lib/project-catalog.ts +125 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.ts +112 -0
- package/src/pages/Actions.vue +180 -0
- package/src/pages/Commands.vue +262 -0
- package/src/pages/Dispatch.vue +431 -0
- package/src/pages/Events.vue +166 -0
- package/src/pages/Home.stories.ts +47 -0
- package/src/pages/Home.vue +485 -0
- package/src/pages/Hooks.vue +297 -0
- package/src/pages/Live.vue +249 -0
- package/src/pages/Modules.vue +174 -0
- package/src/pages/Overview.vue +159 -0
- package/src/pages/Plugins.stories.ts +44 -0
- package/src/pages/Plugins.vue +403 -0
- package/src/pages/Projects.vue +272 -0
- package/src/pages/Run.vue +479 -0
- package/src/pages/Topology.vue +164 -0
- package/src/pages/Trace.vue +511 -0
- package/src/pages/TraceNode.vue +166 -0
- package/src/pages/Workflows.vue +191 -0
- package/src/pages/__tests__/Actions.test.ts +98 -0
- package/src/pages/__tests__/Home.test.ts +98 -0
- package/src/pages/__tests__/Hooks.test.ts +119 -0
- package/src/pages/__tests__/Plugins.test.ts +80 -0
- package/src/style.css +40 -0
- package/tsconfig.json +20 -0
- 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
|
+
});
|