@nwire/cli 0.12.1 → 0.13.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 (131) hide show
  1. package/dist/cli.d.ts +1 -1
  2. package/dist/cli.js +1 -1
  3. package/dist/commands/cache.js +1 -1
  4. package/dist/commands/dev.d.ts +20 -7
  5. package/dist/commands/dev.js +142 -45
  6. package/dist/commands/ls.js +5 -3
  7. package/dist/commands/please.js +4 -2
  8. package/dist/commands/run.js +6 -2
  9. package/dist/commands/studio.js +74 -7
  10. package/dist/lib/dev-entry.d.ts +8 -9
  11. package/dist/lib/dev-entry.js +24 -25
  12. package/dist/lib/dev-host.d.ts +88 -0
  13. package/dist/lib/dev-host.js +438 -0
  14. package/dist/lib/ensure-built.d.ts +32 -0
  15. package/dist/lib/ensure-built.js +62 -0
  16. package/dist/lib/ensure-scan.d.ts +9 -4
  17. package/dist/lib/ensure-scan.js +46 -19
  18. package/dist/lib/studio-host-api.d.ts +97 -0
  19. package/dist/lib/studio-host-api.js +337 -0
  20. package/dist/lib/studio-probe.d.ts +63 -0
  21. package/dist/lib/studio-probe.js +132 -0
  22. package/dist/lib/vite-node.d.ts +7 -6
  23. package/dist/lib/vite-node.js +8 -8
  24. package/dist/lib/vite-run.d.ts +15 -0
  25. package/dist/lib/vite-run.js +33 -0
  26. package/dist/load-config.d.ts +10 -10
  27. package/dist/load-config.js +3 -3
  28. package/dist/studio/assets/abap-DVwoIrM0.js +1 -0
  29. package/dist/studio/assets/apex-B9XtvxSu.js +1 -0
  30. package/dist/studio/assets/azcli-D7JTNGKs.js +1 -0
  31. package/dist/studio/assets/bat-BNHAuPwR.js +1 -0
  32. package/dist/studio/assets/bicep-C3w6oSfK.js +2 -0
  33. package/dist/studio/assets/cameligo-DM9kSiq7.js +1 -0
  34. package/dist/studio/assets/clojure-FWLBUPxU.js +1 -0
  35. package/dist/studio/assets/codicon-ngg6Pgfi.ttf +0 -0
  36. package/dist/studio/assets/coffee-DCoMPIwW.js +1 -0
  37. package/dist/studio/assets/cpp-BNbIvdcw.js +1 -0
  38. package/dist/studio/assets/csharp-Dj4ULDZr.js +1 -0
  39. package/dist/studio/assets/csp-C-n5jZKF.js +1 -0
  40. package/dist/studio/assets/css-COIa8ZTR.js +3 -0
  41. package/dist/studio/assets/css.worker-CpJJqcA4.js +89 -0
  42. package/dist/studio/assets/cssMode-CFR5_xwk.js +1 -0
  43. package/dist/studio/assets/cypher-CW08XVUh.js +1 -0
  44. package/dist/studio/assets/dart-Bz550Pyv.js +1 -0
  45. package/dist/studio/assets/dockerfile-DW5REF8E.js +1 -0
  46. package/dist/studio/assets/ecl-BqdYhwmw.js +1 -0
  47. package/dist/studio/assets/editor-Br_kD0ds.css +1 -0
  48. package/dist/studio/assets/editor.api2-CTGEM8gT.js +872 -0
  49. package/dist/studio/assets/editor.main-sW1qgHFj.js +6 -0
  50. package/dist/studio/assets/elixir-Oi_9aIAu.js +1 -0
  51. package/dist/studio/assets/flow9-CIb9youF.js +1 -0
  52. package/dist/studio/assets/freemarker2-CPrni8hw.js +3 -0
  53. package/dist/studio/assets/fsharp-64tUaD-0.js +1 -0
  54. package/dist/studio/assets/go-DLKGL0rd.js +1 -0
  55. package/dist/studio/assets/graphql-Bz88xn3Q.js +1 -0
  56. package/dist/studio/assets/handlebars-DkvSNpQB.js +1 -0
  57. package/dist/studio/assets/hcl-Cq76tSVN.js +1 -0
  58. package/dist/studio/assets/html-ceN7ITxG.js +1 -0
  59. package/dist/studio/assets/html.worker-wsVgX3gp.js +502 -0
  60. package/dist/studio/assets/htmlMode-wduanCXn.js +1 -0
  61. package/dist/studio/assets/index-4tH0-1cA.js +41 -0
  62. package/dist/studio/assets/index-Fy3xDmV2.css +1 -0
  63. package/dist/studio/assets/ini-BTNe9zdh.js +1 -0
  64. package/dist/studio/assets/java-DzRJKRF3.js +1 -0
  65. package/dist/studio/assets/javascript-WF3LGLje.js +1 -0
  66. package/dist/studio/assets/json.worker-CcNiYOcv.js +58 -0
  67. package/dist/studio/assets/jsonMode-DSujY8tB.js +7 -0
  68. package/dist/studio/assets/julia-Bgv08lKa.js +1 -0
  69. package/dist/studio/assets/kotlin-Dzz8TWAt.js +1 -0
  70. package/dist/studio/assets/less-ak6GUtsl.js +2 -0
  71. package/dist/studio/assets/lexon-zuaObGAE.js +1 -0
  72. package/dist/studio/assets/liquid-C5Z7zFr3.js +1 -0
  73. package/dist/studio/assets/lspLanguageFeatures-B55yfFgf.js +4 -0
  74. package/dist/studio/assets/lua-ClKCZMmP.js +1 -0
  75. package/dist/studio/assets/m3-C7XHeDz_.js +1 -0
  76. package/dist/studio/assets/markdown-LT3qFBoR.js +1 -0
  77. package/dist/studio/assets/mdx-Bu5jRl19.js +1 -0
  78. package/dist/studio/assets/mips-B8clQ9KB.js +1 -0
  79. package/dist/studio/assets/monaco.contribution-KjQ4yOxj.js +2 -0
  80. package/dist/studio/assets/msdax-DBxc5qPJ.js +1 -0
  81. package/dist/studio/assets/mysql-qocW_xba.js +1 -0
  82. package/dist/studio/assets/objective-c-DhkpBlGX.js +1 -0
  83. package/dist/studio/assets/pascal-C_PJR40u.js +1 -0
  84. package/dist/studio/assets/pascaligo-BI_Gz9Bp.js +1 -0
  85. package/dist/studio/assets/perl-CIqGOHTo.js +1 -0
  86. package/dist/studio/assets/pgsql-DI_z9qfW.js +1 -0
  87. package/dist/studio/assets/php-Dkwn_yn0.js +1 -0
  88. package/dist/studio/assets/pla-DvzjACL6.js +1 -0
  89. package/dist/studio/assets/postiats-Cc9-hkUx.js +1 -0
  90. package/dist/studio/assets/powerquery-IGzsITFg.js +1 -0
  91. package/dist/studio/assets/powershell-BHlZlUN6.js +1 -0
  92. package/dist/studio/assets/protobuf-pGrmMUz5.js +2 -0
  93. package/dist/studio/assets/pug-B4eH693Y.js +1 -0
  94. package/dist/studio/assets/python-DpEFuk0I.js +1 -0
  95. package/dist/studio/assets/qsharp-CwO3kTIU.js +1 -0
  96. package/dist/studio/assets/r-CiZUpdIa.js +1 -0
  97. package/dist/studio/assets/razor-BF1svRn9.js +1 -0
  98. package/dist/studio/assets/redis-DjdIzLdf.js +1 -0
  99. package/dist/studio/assets/redshift-vCL5QMyw.js +1 -0
  100. package/dist/studio/assets/restructuredtext-D5hoMHB1.js +1 -0
  101. package/dist/studio/assets/ruby-ByPQrqP4.js +1 -0
  102. package/dist/studio/assets/rust-Nz5wukP7.js +1 -0
  103. package/dist/studio/assets/sb-DgR1RbMJ.js +1 -0
  104. package/dist/studio/assets/scala-BCgNuXrV.js +1 -0
  105. package/dist/studio/assets/scheme-TgKpKGpb.js +1 -0
  106. package/dist/studio/assets/scss-BKxAkvnT.js +3 -0
  107. package/dist/studio/assets/shell-COgstXIQ.js +1 -0
  108. package/dist/studio/assets/solidity-DaqmtBSV.js +1 -0
  109. package/dist/studio/assets/sophia-Da67i9pL.js +1 -0
  110. package/dist/studio/assets/sparql-CtN8jEDV.js +1 -0
  111. package/dist/studio/assets/sql-BnJfQHXt.js +1 -0
  112. package/dist/studio/assets/st-cGKU4FoP.js +1 -0
  113. package/dist/studio/assets/swift-DyyME8zA.js +1 -0
  114. package/dist/studio/assets/systemverilog-BYZY5TEG.js +1 -0
  115. package/dist/studio/assets/tcl-CHv1_zaR.js +1 -0
  116. package/dist/studio/assets/ts.worker-DfMAw22J.js +67719 -0
  117. package/dist/studio/assets/tsMode-BiiF1JOM.js +11 -0
  118. package/dist/studio/assets/twig-CNwULq4h.js +1 -0
  119. package/dist/studio/assets/typescript-BefpzegH.js +1 -0
  120. package/dist/studio/assets/typespec-B3KUNs_P.js +1 -0
  121. package/dist/studio/assets/vb-phZZ2pCs.js +1 -0
  122. package/dist/studio/assets/wgsl-Df-y4I4e.js +298 -0
  123. package/dist/studio/assets/workers-DqAl3RFu.js +1 -0
  124. package/dist/studio/assets/xml-CWw7bbeo.js +1 -0
  125. package/dist/studio/assets/yaml-CZ0k9DUm.js +1 -0
  126. package/dist/studio/index.html +13 -0
  127. package/dist/wire-runner.d.ts +12 -0
  128. package/dist/wire-runner.js +19 -0
  129. package/package.json +8 -6
  130. package/dist/cache-runner.d.ts +0 -1
  131. package/dist/cache-runner.js +0 -206
@@ -4,36 +4,63 @@
4
4
  * every CLI command that reads the scan cache.
5
5
  *
6
6
  * Cheap path: compute fingerprint, compare to saved; if match, return.
7
- * Expensive path: spawn the cache-runner under vite-node to rebuild,
8
- * then save the new fingerprint. The expensive path runs at most once
9
- * per source change.
7
+ * Expensive path: rebuild the manifest IN-PROCESS via `@nwire/scan`'s
8
+ * `buildManifest` + `writeManifest` a pure static scan of the source
9
+ * tree plus the folded `.nwire/topology.json` a running app self-emits.
10
+ * No app boot, no loader, no subprocess.
11
+ *
12
+ * If `.nwire/topology.json` is absent (the app has never run), the
13
+ * manifest still writes — static-only; the topology layer fills in the
14
+ * next time the app runs and re-emits.
10
15
  */
11
- import { existsSync } from "node:fs";
12
- import { dirname, resolve } from "node:path";
13
- import { fileURLToPath } from "node:url";
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { resolve } from "node:path";
18
+ import { buildManifest, writeManifest } from "@nwire/scan";
14
19
  import { palette } from "./colors.js";
15
- import { execSync } from "./exec.js";
20
+ import { loadConfig } from "../load-config.js";
16
21
  import { fingerprint, isFresh, writeFingerprint } from "./scan-cache.js";
17
- const here = dirname(fileURLToPath(import.meta.url));
18
- export function ensureScanFresh(cwd = process.cwd(), opts = {}) {
22
+ /**
23
+ * Read the app name from the emitted `<cwd>/.nwire/topology.json` so the
24
+ * static manifest tags records with the same name a running app reports.
25
+ * The static scan treats the name as a label only, so an empty string is
26
+ * harmless when no topology has been emitted yet.
27
+ */
28
+ function appNameFromTopology(cwd) {
29
+ const p = resolve(cwd, ".nwire", "topology.json");
30
+ if (!existsSync(p))
31
+ return "";
32
+ try {
33
+ const file = JSON.parse(readFileSync(p, "utf8"));
34
+ return file.topology?.apps?.[0]?.name ?? "";
35
+ }
36
+ catch {
37
+ return "";
38
+ }
39
+ }
40
+ export async function ensureScanFresh(cwd = process.cwd(), opts = {}) {
19
41
  const fp = fingerprint(cwd);
20
42
  if (!opts.force && isFresh(cwd, fp)) {
21
43
  return { rebuilt: false, fingerprint: fp };
22
44
  }
23
- const builderPath = resolve(here, "..", "cache-runner.js");
24
- if (!existsSync(builderPath)) {
25
- // Builder is missing from dist (dev-mode oddity). Skip — commands
26
- // that need the cache will surface their own "no manifest" error.
27
- return { rebuilt: false, fingerprint: fp };
28
- }
29
45
  if (!opts.quiet) {
30
46
  // eslint-disable-next-line no-console
31
47
  console.error(palette.dim("scanning project…"));
32
48
  }
33
- const code = execSync("pnpm", ["exec", "vite-node", builderPath]);
34
- if (code !== 0) {
35
- // Cache build failed; keep whatever's on disk. Don't update the
36
- // fingerprint, so the next call retries.
49
+ try {
50
+ const config = await loadConfig(cwd);
51
+ const appName = appNameFromTopology(cwd);
52
+ // Pure static scan + folded topology.json — no app boot, no loader.
53
+ const manifest = buildManifest(cwd, appName);
54
+ const outDir = resolve(cwd, config.cacheDir ?? ".nwire");
55
+ await writeManifest(manifest, outDir);
56
+ }
57
+ catch (err) {
58
+ if (!opts.quiet) {
59
+ // eslint-disable-next-line no-console
60
+ console.error(palette.err("nwire cache:") + ` scan failed — ${err.message}`);
61
+ }
62
+ // Keep whatever's on disk; don't update the fingerprint so the next
63
+ // call retries.
37
64
  return { rebuilt: false, fingerprint: fp };
38
65
  }
39
66
  writeFingerprint(cwd, fp);
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Studio data API for the one-Vite dev host.
3
+ *
4
+ * The standalone Studio process serves `/__nwire/*` by reading `.nwire/`
5
+ * from disk and proxying `/_nwire/*` to a separately-started wire process.
6
+ * The one-Vite host has the wire running IN-PROCESS, which is strictly better:
7
+ *
8
+ * - telemetry/live: subscribe `runtime.onTelemetry` directly — no disk writes
9
+ * required, records arrive at zero-latency with no file-tail polling loop.
10
+ * - manifest.json: serve the disk `.nwire/manifest.json` (built by `nwire cache`)
11
+ * and, when a live runtime is present, fold in the runtime topology so the
12
+ * manifest stays fresh even during HMR reloads without a full cache rebuild.
13
+ * - telemetry/runs: disk-based history — same as standalone Studio (these are
14
+ * past run files; there is no in-process equivalent).
15
+ *
16
+ * Routes owned here (all under `/__nwire/*`):
17
+ *
18
+ * GET /__nwire/manifest.json disk (.nwire/manifest.json) + runtime fold
19
+ * GET /__nwire/telemetry/live SSE — runtime.onTelemetry (runtime-direct)
20
+ * GET /__nwire/telemetry/runs disk list { runs: TelemetryRunMeta[] }
21
+ * GET /__nwire/telemetry/runs/:id disk single run { id, records: unknown[] }
22
+ * GET /__nwire/endpoints list { endpoints: EndpointInfo[] }
23
+ * POST /__nwire/endpoints/active { name } → switch active HTTP front
24
+ * * /__nwire/run/* 501 — supervisor belongs to standalone Studio
25
+ * * /__nwire/* 501 — not yet implemented
26
+ *
27
+ * `/__nwire/project`, `/__nwire/source`, `/__nwire/projects/*` are the
28
+ * multi-project shell routes. They are out of scope for the dev-host context
29
+ * (the host always serves one project — the one it loaded) and return 501.
30
+ *
31
+ * The wire's own `/_nwire/*` surface (inspect, events) is NOT owned here. The
32
+ * dev-host routes it to `wireHandlers[0]` via the hardcoded prefix check in
33
+ * dev-host.ts (a known tech-debt; see the review findings). Once that prefix
34
+ * check is removed, the wire's handler self-dispatches via Connect-style `next`.
35
+ */
36
+ import type { IncomingMessage, ServerResponse } from "node:http";
37
+ import type { DevHostMount } from "@nwire/endpoint";
38
+ /** Minimal runtime surface needed by this module. Duck-typed so the host never
39
+ * imports from `@nwire/runtime` directly — it reads whatever the app exposes. */
40
+ export interface RuntimeLike {
41
+ /** Subscribe to the telemetry stream. Returns an unsubscribe function. */
42
+ onTelemetry(listener: (record: unknown) => void): () => void;
43
+ }
44
+ /** Per-endpoint summary returned by `GET /__nwire/endpoints`. */
45
+ export interface EndpointInfo {
46
+ /** Endpoint name — from `endpoint("name")`. */
47
+ readonly name: string;
48
+ /** True when this is the currently active HTTP front. */
49
+ readonly active: boolean;
50
+ /** True when this endpoint registered at least one HTTP handler. */
51
+ readonly hasHttp: boolean;
52
+ }
53
+ export interface StudioHostApiOptions {
54
+ /**
55
+ * Live mounts from the endpoint collector. The API reads `mount.apps[0].runtime`
56
+ * (duck-typed as `RuntimeLike`) from whichever mount has a booted forge app.
57
+ * Safe to pass a live, mutable array — the handler reads it on each request.
58
+ */
59
+ readonly mounts: readonly DevHostMount[];
60
+ /**
61
+ * Project root — where `.nwire/` lives. Defaults to `process.cwd()`.
62
+ */
63
+ readonly cwd?: string;
64
+ /**
65
+ * Return the name of the currently active HTTP front. Used by
66
+ * `GET /__nwire/endpoints` to mark the active entry. Optional — when
67
+ * absent, the API reports no endpoint as active.
68
+ */
69
+ readonly getActiveEndpointName?: () => string | undefined;
70
+ /**
71
+ * Switch the active HTTP front. Called by `POST /__nwire/endpoints/active`.
72
+ * Should throw when the named endpoint is not found. Optional — when absent,
73
+ * the switch endpoint returns 501.
74
+ */
75
+ readonly setActiveEndpoint?: (name: string) => void;
76
+ }
77
+ /** The handler signature: returns `true` when it handled the request (caller
78
+ * must not pass to the next handler), `false` to fall through. */
79
+ export type StudioHostApiHandler = (req: IncomingMessage, res: ServerResponse) => boolean;
80
+ /**
81
+ * Create the `/__nwire/*` request handler for the one-Vite dev host.
82
+ *
83
+ * Returns `true` when it handled the request (caller stops). `false` means
84
+ * the request was not for `/__nwire/*` and should fall through to Vite's
85
+ * middlewares.
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * const studioApi = createStudioHostApi({ mounts, cwd: opts.cwd });
90
+ *
91
+ * const server = createHttpServer((req, res) => {
92
+ * if (studioApi(req, res)) return;
93
+ * vite.middlewares(req, res, () => { ... });
94
+ * });
95
+ * ```
96
+ */
97
+ export declare function createStudioHostApi(opts: StudioHostApiOptions): StudioHostApiHandler;
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Studio data API for the one-Vite dev host.
3
+ *
4
+ * The standalone Studio process serves `/__nwire/*` by reading `.nwire/`
5
+ * from disk and proxying `/_nwire/*` to a separately-started wire process.
6
+ * The one-Vite host has the wire running IN-PROCESS, which is strictly better:
7
+ *
8
+ * - telemetry/live: subscribe `runtime.onTelemetry` directly — no disk writes
9
+ * required, records arrive at zero-latency with no file-tail polling loop.
10
+ * - manifest.json: serve the disk `.nwire/manifest.json` (built by `nwire cache`)
11
+ * and, when a live runtime is present, fold in the runtime topology so the
12
+ * manifest stays fresh even during HMR reloads without a full cache rebuild.
13
+ * - telemetry/runs: disk-based history — same as standalone Studio (these are
14
+ * past run files; there is no in-process equivalent).
15
+ *
16
+ * Routes owned here (all under `/__nwire/*`):
17
+ *
18
+ * GET /__nwire/manifest.json disk (.nwire/manifest.json) + runtime fold
19
+ * GET /__nwire/telemetry/live SSE — runtime.onTelemetry (runtime-direct)
20
+ * GET /__nwire/telemetry/runs disk list { runs: TelemetryRunMeta[] }
21
+ * GET /__nwire/telemetry/runs/:id disk single run { id, records: unknown[] }
22
+ * GET /__nwire/endpoints list { endpoints: EndpointInfo[] }
23
+ * POST /__nwire/endpoints/active { name } → switch active HTTP front
24
+ * * /__nwire/run/* 501 — supervisor belongs to standalone Studio
25
+ * * /__nwire/* 501 — not yet implemented
26
+ *
27
+ * `/__nwire/project`, `/__nwire/source`, `/__nwire/projects/*` are the
28
+ * multi-project shell routes. They are out of scope for the dev-host context
29
+ * (the host always serves one project — the one it loaded) and return 501.
30
+ *
31
+ * The wire's own `/_nwire/*` surface (inspect, events) is NOT owned here. The
32
+ * dev-host routes it to `wireHandlers[0]` via the hardcoded prefix check in
33
+ * dev-host.ts (a known tech-debt; see the review findings). Once that prefix
34
+ * check is removed, the wire's handler self-dispatches via Connect-style `next`.
35
+ */
36
+ import { existsSync, readFileSync } from "node:fs";
37
+ import { resolve } from "node:path";
38
+ import { listTelemetryRuns, readTelemetryRun } from "@nwire/scan";
39
+ // ── Internal helpers ──────────────────────────────────────────────────────────
40
+ function json(res, status, body) {
41
+ res.statusCode = status;
42
+ res.setHeader("Content-Type", "application/json");
43
+ res.end(JSON.stringify(body));
44
+ }
45
+ /**
46
+ * Walk the mounts to find the first `RuntimeLike` available. Returns `undefined`
47
+ * when no forge runtime is mounted (transport-only endpoints without an app, or
48
+ * apps that haven't exposed `.runtime`).
49
+ */
50
+ function findRuntime(mounts) {
51
+ for (const mount of mounts) {
52
+ for (const app of mount.apps) {
53
+ const runtime = app.runtime;
54
+ if (runtime !== null &&
55
+ runtime !== undefined &&
56
+ typeof runtime.onTelemetry === "function") {
57
+ return runtime;
58
+ }
59
+ }
60
+ }
61
+ return undefined;
62
+ }
63
+ // ── SSE helpers ───────────────────────────────────────────────────────────────
64
+ const KEEPALIVE_MS = 25_000;
65
+ /** Write a raw SSE event frame. Returns false when the socket has closed. */
66
+ function sseWrite(res, event, data) {
67
+ try {
68
+ if (event !== null) {
69
+ res.write(`event: ${event}\ndata: ${data}\n\n`);
70
+ }
71
+ else {
72
+ res.write(`data: ${data}\n\n`);
73
+ }
74
+ return true;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ // ── Route handlers ───────────────────────────────────────────────────────────
81
+ /**
82
+ * GET /__nwire/manifest.json
83
+ *
84
+ * Serves the disk-built `.nwire/manifest.json`. When a live runtime is
85
+ * present the static file already reflects the source tree as of the last
86
+ * scan; the intent for future increments is to fold live handler
87
+ * registrations from the runtime so the manifest stays accurate during HMR.
88
+ * Today the disk file is served verbatim (the same as standalone Studio) —
89
+ * the dev host calls `ensureScanFresh` on each (re)load so the file exists
90
+ * from the first request and tracks source edits across HMR.
91
+ *
92
+ * A `?force=true` query param is reserved for a future "rebuild" button
93
+ * trigger but is not acted on here (that requires spawning `nwire cache`).
94
+ */
95
+ function handleManifest(req, res, cwd) {
96
+ const manifestPath = resolve(cwd, ".nwire", "manifest.json");
97
+ if (!existsSync(manifestPath)) {
98
+ json(res, 404, {
99
+ error: "manifest not built — run `nwire cache` or start with `nwire dev` which builds it automatically",
100
+ });
101
+ return;
102
+ }
103
+ let raw;
104
+ try {
105
+ raw = readFileSync(manifestPath, "utf8");
106
+ }
107
+ catch (err) {
108
+ json(res, 500, { error: err.message });
109
+ return;
110
+ }
111
+ res.statusCode = 200;
112
+ res.setHeader("Content-Type", "application/json");
113
+ res.end(raw);
114
+ }
115
+ /**
116
+ * GET /__nwire/telemetry/live (SSE)
117
+ *
118
+ * Runtime-direct: subscribes `runtime.onTelemetry` and streams each record
119
+ * as a plain SSE `data:` event. This replaces the file-tail approach used in
120
+ * standalone Studio — records arrive at zero-latency with no disk I/O.
121
+ *
122
+ * On open the server emits a `run` event carrying `null` (no discrete run ID
123
+ * in the in-process path — the runtime emits all records on one continuous
124
+ * stream). Studio's `useTelemetry` composable handles a null runId gracefully.
125
+ *
126
+ * When no runtime is available (transport-only endpoint), the stream stays
127
+ * open with keepalive comments — Studio will show an empty live view rather
128
+ * than an error.
129
+ */
130
+ function handleTelemetryLive(req, res, mounts) {
131
+ res.statusCode = 200;
132
+ res.setHeader("Content-Type", "text/event-stream");
133
+ res.setHeader("Cache-Control", "no-cache, no-transform");
134
+ res.setHeader("Connection", "keep-alive");
135
+ // Signal which run the client is tailing. In dev-host mode there is no
136
+ // discrete run file — the stream is continuous — so we emit null.
137
+ sseWrite(res, "run", JSON.stringify(null));
138
+ const runtime = findRuntime(mounts);
139
+ let unsubscribe;
140
+ if (runtime) {
141
+ unsubscribe = runtime.onTelemetry((record) => {
142
+ sseWrite(res, null, JSON.stringify(record));
143
+ });
144
+ }
145
+ const keepalive = setInterval(() => {
146
+ try {
147
+ res.write(": keepalive\n\n");
148
+ }
149
+ catch {
150
+ clearInterval(keepalive);
151
+ }
152
+ }, KEEPALIVE_MS);
153
+ // Don't let the keepalive timer hold the process open: an idle SSE
154
+ // connection must never be the reason Node (or a test runner) can't exit.
155
+ keepalive.unref();
156
+ req.on("close", () => {
157
+ unsubscribe?.();
158
+ clearInterval(keepalive);
159
+ });
160
+ }
161
+ /**
162
+ * GET /__nwire/telemetry/runs
163
+ * GET /__nwire/telemetry/runs/:id
164
+ *
165
+ * Disk-based telemetry history — same as standalone Studio. History records
166
+ * come from `.nwire/telemetry/*.jsonl` files written by the telemetry reporter.
167
+ * The dev-host writes these too (the reporter is installed per-app at boot).
168
+ *
169
+ * The sub-path after the `/runs` prefix determines whether this is a list
170
+ * or a single-run fetch. In the Vite middleware model the mount prefix is
171
+ * stripped before `req.url` is set; in the raw Node http.Server model used
172
+ * by dev-host, `req.url` still carries the full path. We parse against the
173
+ * full path to be safe.
174
+ */
175
+ function handleTelemetryRuns(req, res, cwd) {
176
+ const url = req.url ?? "/";
177
+ const pathname = new URL(url, "http://x").pathname;
178
+ // Exact list: /__nwire/telemetry/runs
179
+ if (pathname === "/__nwire/telemetry/runs" || pathname === "/__nwire/telemetry/runs/") {
180
+ json(res, 200, { runs: listTelemetryRuns(cwd) });
181
+ return;
182
+ }
183
+ // Single run: /__nwire/telemetry/runs/:id
184
+ const runIdMatch = /^\/__nwire\/telemetry\/runs\/([^/]+)$/.exec(pathname);
185
+ if (runIdMatch) {
186
+ const runId = decodeURIComponent(runIdMatch[1]);
187
+ const file = resolve(cwd, ".nwire", "telemetry", `${runId}.jsonl`);
188
+ if (!existsSync(file)) {
189
+ json(res, 404, { error: "run not found", id: runId });
190
+ return;
191
+ }
192
+ const records = readTelemetryRun(cwd, runId);
193
+ json(res, 200, { id: runId, records });
194
+ return;
195
+ }
196
+ json(res, 404, { error: "not found" });
197
+ }
198
+ // ── Endpoint list + active-front switch ──────────────────────────────────────
199
+ /**
200
+ * GET /__nwire/endpoints
201
+ *
202
+ * Returns the list of collected endpoint mounts with their HTTP status and
203
+ * which one is currently active. The payload shape is intentionally minimal —
204
+ * Studio uses it only for the picker and does not need adapter-level detail.
205
+ *
206
+ * For a single-endpoint project the list has one entry that is always active;
207
+ * the picker renders as a no-op label rather than a selectable list.
208
+ *
209
+ * Response: `{ endpoints: EndpointInfo[] }`
210
+ */
211
+ function handleEndpointsList(_req, res, mounts, getActive) {
212
+ const activeName = getActive();
213
+ const endpoints = mounts.map((m) => ({
214
+ name: m.name,
215
+ active: m.name === activeName,
216
+ hasHttp: m.handlers.length > 0,
217
+ }));
218
+ json(res, 200, { endpoints });
219
+ }
220
+ /**
221
+ * POST /__nwire/endpoints/active
222
+ *
223
+ * Body: `{ name: string }` — the endpoint to make the active HTTP front.
224
+ * Response: `{ name }` on success, `{ error }` on failure.
225
+ *
226
+ * Reads the JSON body (the server never gets large bodies here so we buffer
227
+ * synchronously via the request `data` event — no streaming needed).
228
+ */
229
+ function handleSetActiveEndpoint(req, res, setter) {
230
+ let body = "";
231
+ req.on("data", (chunk) => {
232
+ body += typeof chunk === "string" ? chunk : chunk.toString("utf8");
233
+ });
234
+ req.on("end", () => {
235
+ let parsed;
236
+ try {
237
+ parsed = JSON.parse(body);
238
+ }
239
+ catch {
240
+ json(res, 400, { error: "Invalid JSON body" });
241
+ return;
242
+ }
243
+ const name = parsed !== null &&
244
+ typeof parsed === "object" &&
245
+ typeof parsed.name === "string"
246
+ ? parsed.name
247
+ : undefined;
248
+ if (!name) {
249
+ json(res, 400, { error: 'Body must be { "name": "<endpoint-name>" }' });
250
+ return;
251
+ }
252
+ try {
253
+ setter(name);
254
+ json(res, 200, { name });
255
+ }
256
+ catch (err) {
257
+ json(res, 404, { error: err.message });
258
+ }
259
+ });
260
+ req.on("error", () => {
261
+ json(res, 400, { error: "Failed to read request body" });
262
+ });
263
+ }
264
+ // ── Public factory ────────────────────────────────────────────────────────────
265
+ /**
266
+ * Create the `/__nwire/*` request handler for the one-Vite dev host.
267
+ *
268
+ * Returns `true` when it handled the request (caller stops). `false` means
269
+ * the request was not for `/__nwire/*` and should fall through to Vite's
270
+ * middlewares.
271
+ *
272
+ * @example
273
+ * ```ts
274
+ * const studioApi = createStudioHostApi({ mounts, cwd: opts.cwd });
275
+ *
276
+ * const server = createHttpServer((req, res) => {
277
+ * if (studioApi(req, res)) return;
278
+ * vite.middlewares(req, res, () => { ... });
279
+ * });
280
+ * ```
281
+ */
282
+ export function createStudioHostApi(opts) {
283
+ const cwd = opts.cwd ?? process.cwd();
284
+ const getActive = opts.getActiveEndpointName ?? (() => undefined);
285
+ return function studioHostApi(req, res) {
286
+ const url = req.url ?? "/";
287
+ const pathname = new URL(url, "http://x").pathname;
288
+ if (!pathname.startsWith("/__nwire/"))
289
+ return false;
290
+ // ── manifest ───────────────────────────────────────────────────────────
291
+ if (req.method === "GET" && pathname === "/__nwire/manifest.json") {
292
+ handleManifest(req, res, cwd);
293
+ return true;
294
+ }
295
+ // ── telemetry live (SSE) ───────────────────────────────────────────────
296
+ if (req.method === "GET" && pathname === "/__nwire/telemetry/live") {
297
+ handleTelemetryLive(req, res, opts.mounts);
298
+ return true;
299
+ }
300
+ // ── telemetry runs (disk history) ──────────────────────────────────────
301
+ if (req.method === "GET" && pathname.startsWith("/__nwire/telemetry/runs")) {
302
+ handleTelemetryRuns(req, res, cwd);
303
+ return true;
304
+ }
305
+ // ── endpoints list ─────────────────────────────────────────────────────
306
+ if (req.method === "GET" && pathname === "/__nwire/endpoints") {
307
+ handleEndpointsList(req, res, opts.mounts, getActive);
308
+ return true;
309
+ }
310
+ // ── switch active HTTP front ───────────────────────────────────────────
311
+ if (req.method === "POST" && pathname === "/__nwire/endpoints/active") {
312
+ if (!opts.setActiveEndpoint) {
313
+ json(res, 501, {
314
+ error: "Active endpoint switching is not configured for this host. " +
315
+ "Pass `setActiveEndpoint` to createStudioHostApi to enable it.",
316
+ });
317
+ return true;
318
+ }
319
+ handleSetActiveEndpoint(req, res, opts.setActiveEndpoint);
320
+ return true;
321
+ }
322
+ // ── run/* — supervisor is standalone-Studio only ───────────────────────
323
+ if (pathname.startsWith("/__nwire/run/")) {
324
+ json(res, 501, {
325
+ error: "/__nwire/run/* is not available in the one-Vite dev host. " +
326
+ "Process management runs in the standalone Studio (`nwire studio`).",
327
+ });
328
+ return true;
329
+ }
330
+ // ── everything else under /__nwire/* ──────────────────────────────────
331
+ json(res, 501, {
332
+ error: `Studio data API route not yet implemented in the one-Vite dev host: ${req.method} ${pathname}`,
333
+ hint: "Use `nwire studio` for the full Studio experience while this route is being ported.",
334
+ });
335
+ return true;
336
+ };
337
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Singleton detection for `nwire studio`. Before spawning a second vite,
3
+ * probe the studio port: if a studio is already running there, register the
4
+ * current project with it and open the browser instead of starting a rival
5
+ * server. Keeps one studio owning the deterministic port (7777) so every
6
+ * later invocation attaches rather than scattering across 7778, 7779, …
7
+ *
8
+ * The decision logic is factored pure (`decideStudioProbe`) so it can be
9
+ * tested without a live server; the live HTTP is in `probeStudio` /
10
+ * `registerProject` and the platform opener in `openBrowser`.
11
+ */
12
+ /** Default studio port — mirrors tool-studio's `vite.config.ts`. */
13
+ export declare const DEFAULT_STUDIO_PORT = 7777;
14
+ /** What a probe of `GET /__nwire/project` told us about the port. */
15
+ export type ProbeResult = {
16
+ kind: "free";
17
+ } | {
18
+ kind: "studio";
19
+ cwd: string;
20
+ name?: string;
21
+ } | {
22
+ kind: "foreign";
23
+ };
24
+ /** What the studio command should do, given a probe result. */
25
+ export type StudioDecision = {
26
+ action: "attach";
27
+ cwd: string;
28
+ name?: string;
29
+ } | {
30
+ action: "spawn";
31
+ } | {
32
+ action: "spawn-foreign";
33
+ };
34
+ /**
35
+ * Pure mapping from a probe result to the action the command takes. Kept
36
+ * free of I/O so it is exhaustively unit-testable.
37
+ */
38
+ export declare function decideStudioProbe(probe: ProbeResult): StudioDecision;
39
+ /**
40
+ * Interpret a `GET /__nwire/project` response body + status into a
41
+ * ProbeResult. A 200 with a JSON body carrying a `cwd` string is a studio;
42
+ * a 200 without that shape (or non-JSON) is a foreign service. Factored
43
+ * pure so the JSON-shape branch is testable without sockets.
44
+ */
45
+ export declare function classifyProjectResponse(status: number, body: string): ProbeResult;
46
+ /** Base URL for a studio on `127.0.0.1:<port>`. */
47
+ export declare function studioBaseUrl(port: number): string;
48
+ /**
49
+ * Live probe of the studio port. Returns `free` on any connection error or
50
+ * timeout (port unbound or unresponsive), otherwise classifies the answer.
51
+ */
52
+ export declare function probeStudio(port: number, timeoutMs?: number): Promise<ProbeResult>;
53
+ /** POST the current project's cwd to a running studio's register endpoint. */
54
+ export declare function registerProject(port: number, cwd: string): Promise<boolean>;
55
+ /** Open a URL in the platform's default browser. Best-effort, fire-and-forget. */
56
+ export declare function openBrowser(url: string): void;
57
+ /**
58
+ * Resolve the studio port for this invocation. Honors a `--port <n>` or
59
+ * `--port=<n>` flag in the raw args (vite's own flag), else the default.
60
+ */
61
+ export declare function resolveStudioPort(rawArgs: readonly string[]): number;
62
+ /** True when the raw args already pin a `--port` (so we shouldn't add one). */
63
+ export declare function hasExplicitPort(rawArgs: readonly string[]): boolean;