@sigx/terminal-dev 0.5.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Andreas Ekdahl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # @sigx/terminal-dev
2
+
3
+ HMR dev runner for [SignalX terminal](https://github.com/signalxjs/terminal) apps: run your TUI under an in-process Vite dev server, save a component file, and watch the **running** terminal app update in place — no restart, no lost state.
4
+
5
+ ```bash
6
+ pnpm add -D @sigx/terminal-dev
7
+ ```
8
+
9
+ ## Usage
10
+
11
+ ```bash
12
+ sigx-terminal-dev # auto-detects src/main.tsx etc.
13
+ sigx-terminal-dev src/main.tsx
14
+ sigx-terminal-dev --root path/to/app
15
+ sigx-terminal-dev --config vite.config.ts # layer a vite config on top
16
+ ```
17
+
18
+ Point it at the module that mounts your app:
19
+
20
+ ```tsx
21
+ /** @jsxImportSource @sigx/runtime-core */
22
+ // src/main.tsx — the "mount module"
23
+ import { defineApp, terminalMount } from '@sigx/terminal';
24
+ import { App } from './App';
25
+
26
+ defineApp(<App />).mount({ fullscreen: true }, terminalMount);
27
+ ```
28
+
29
+ - **Edit a component module** (a file that calls `component(...)`, like `App.tsx`): just that module re-executes; every live instance re-runs the new setup against its existing context and re-renders in place. The terminal never tears down, and state outside the edited setup (parent components, stores, module-level signals elsewhere) survives.
30
+ - **Edit the mount module** (or a module nothing accepts): the app restarts in-process — clean terminal teardown, module cache dropped, entry re-imported.
31
+ - **Break the build**: the error is reported (above the live region while mounted); the next successful save recovers automatically.
32
+ - **Quit with Ctrl+C**: the dev process exits 0 — quitting the app is the normal end of a dev session, so wrappers like pnpm scripts don't report a failure. (Raw mode delivers Ctrl+C to the app as a key; the app's conventional exit 130 is translated by the bin. Other exit codes pass through.)
33
+
34
+ Your app's `tsconfig.json` needs the usual SignalX JSX setup (`"jsx": "react-jsx"`, `"jsxImportSource": "@sigx/runtime-core"` — or the `@sigx/terminal` facade).
35
+
36
+ ## Programmatic API
37
+
38
+ ```ts
39
+ import { startDev } from '@sigx/terminal-dev';
40
+
41
+ const handle = await startDev({
42
+ entry: 'src/main.tsx',
43
+ root: process.cwd(),
44
+ onError: (err) => { /* entry failed to (re)start */ },
45
+ onRestart: () => { /* entry (re)started */ },
46
+ });
47
+ // handle.server (ViteDevServer), handle.runner (ModuleRunner)
48
+ await handle.restart(); // manual in-process restart
49
+ await handle.close();
50
+ ```
51
+
52
+ For custom Vite setups there is `terminalDevPlugin()` (the plugin that injects HMR identity registration + self-accept into component modules and keeps `@sigx/*` external for SSR) and `@sigx/terminal-dev/hmr` (the runtime that patches live component instances; injected automatically).
53
+
54
+ ## How it works
55
+
56
+ - An in-process Vite dev server (middleware mode, silent) plus a [module runner](https://vite.dev/guide/api-environment) executes the app in node. `@sigx/*` packages stay out of the hot graph wherever possible, so the renderer's terminal state and the reactivity instance survive hot updates.
57
+ - The plugin gives every `component(...)` definition a stable identity (`moduleId:index`). When an edited module re-executes, the HMR runtime re-runs the new setup with each live instance's existing context, swaps `ctx.renderFn`, and calls `ctx.update()` — the contract `@sigx/runtime-core` exposes for HMR. Factories previously defined under the same identity are repointed at the new setup too, so parents that captured a component reference before the edit (tab catalogs, navigation) mount the new code on the next visit.
58
+ - Mount modules (those calling `defineApp`/`renderTerminal`/`mountTerminal`) never self-accept — re-executing one would mount a second app — so edits there surface as a full-reload, which the runner intercepts: tear down the terminal, clear the module cache, re-import the entry.
59
+
60
+ ## Limitations (inherent to setup-rerun HMR)
61
+
62
+ - Signals created *inside* the edited component's setup are re-created (their state resets); state anywhere else survives.
63
+ - `onMounted` does not re-fire for already-mounted instances on a hot update.
64
+
65
+ ## License
66
+
67
+ MIT
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // Thin bin shim: the real CLI is built to dist/cli.js. Kept as a committed
3
+ // wrapper so the shebang doesn't depend on build banners.
4
+ import '../dist/cli.js';
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,43 @@
1
+ import { t as e } from "./dev-CW11nYP4.js";
2
+ import { existsSync as t } from "node:fs";
3
+ import n from "node:path";
4
+ //#region src/cli.ts
5
+ var r = [
6
+ "src/main.tsx",
7
+ "src/main.ts",
8
+ "src/index.tsx",
9
+ "src/index.ts",
10
+ "main.tsx",
11
+ "main.ts"
12
+ ];
13
+ function i(e) {
14
+ process.stderr.write(`[sigx-terminal-dev] ${e}\n`), process.exit(1);
15
+ }
16
+ var a = process.argv.slice(2), o, s = process.cwd(), c = !1;
17
+ for (let e = 0; e < a.length; e++) {
18
+ let t = a[e];
19
+ if (t === "--root") {
20
+ let t = a[++e];
21
+ t || i("--root needs a directory"), s = n.resolve(s, t);
22
+ } else if (t === "--config") {
23
+ let t = a[++e];
24
+ t || i("--config needs a file"), c = t;
25
+ } else t === "--help" || t === "-h" ? (process.stdout.write("Usage: sigx-terminal-dev [entry] [--root <dir>] [--config <vite config>]\n\nRuns a SignalX terminal app under a Vite dev server with HMR:\nsaving a component module updates the running TUI in place;\nsaving the mount module restarts the app in-process.\n"), process.exit(0)) : t.startsWith("-") ? i(`unknown option ${t}`) : o = t;
26
+ }
27
+ o ? t(n.resolve(s, o)) || i(`entry not found: ${n.resolve(s, o)}`) : (o = r.find((e) => t(n.join(s, e))), o || i(`no entry given and none of ${r.join(", ")} exists in ${s}`)), process.stderr.write(`[sigx-terminal-dev] ${n.join(n.basename(s), o)} — edit a component to hot-update\n`);
28
+ var l = process.exit.bind(process);
29
+ process.exit = ((e) => l(e != null && Number(e) === 130 ? 0 : e));
30
+ var u = await e({
31
+ entry: o,
32
+ root: s,
33
+ configFile: c,
34
+ onError: (e) => {
35
+ console.error("[sigx-terminal-dev] app failed, fix the error and save to retry:\n", e);
36
+ }
37
+ });
38
+ process.on("SIGTERM", () => {
39
+ u.close().finally(() => process.exit(0));
40
+ });
41
+ //#endregion
42
+
43
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["/**\r\n * `sigx-terminal-dev [entry]` — run a SignalX terminal app with HMR.\r\n *\r\n * sigx-terminal-dev # auto-detects src/main.tsx etc.\r\n * sigx-terminal-dev src/main.tsx\r\n * sigx-terminal-dev --root examples/showcase\r\n * sigx-terminal-dev --config vite.config.ts\r\n */\r\nimport { existsSync } from 'node:fs';\r\nimport path from 'node:path';\r\nimport { startDev } from './dev.js';\r\n\r\nconst ENTRY_CANDIDATES = [\r\n 'src/main.tsx',\r\n 'src/main.ts',\r\n 'src/index.tsx',\r\n 'src/index.ts',\r\n 'main.tsx',\r\n 'main.ts',\r\n];\r\n\r\nfunction fail(message: string): never {\r\n process.stderr.write(`[sigx-terminal-dev] ${message}\\n`);\r\n process.exit(1);\r\n}\r\n\r\nconst args = process.argv.slice(2);\r\nlet entry: string | undefined;\r\nlet root = process.cwd();\r\nlet configFile: string | false = false;\r\n\r\nfor (let i = 0; i < args.length; i++) {\r\n const arg = args[i];\r\n if (arg === '--root') {\r\n const value = args[++i];\r\n if (!value) fail('--root needs a directory');\r\n root = path.resolve(root, value);\r\n } else if (arg === '--config') {\r\n const value = args[++i];\r\n if (!value) fail('--config needs a file');\r\n configFile = value;\r\n } else if (arg === '--help' || arg === '-h') {\r\n process.stdout.write(\r\n 'Usage: sigx-terminal-dev [entry] [--root <dir>] [--config <vite config>]\\n' +\r\n '\\nRuns a SignalX terminal app under a Vite dev server with HMR:\\n' +\r\n 'saving a component module updates the running TUI in place;\\n' +\r\n 'saving the mount module restarts the app in-process.\\n',\r\n );\r\n process.exit(0);\r\n } else if (arg.startsWith('-')) {\r\n fail(`unknown option ${arg}`);\r\n } else {\r\n entry = arg;\r\n }\r\n}\r\n\r\nif (!entry) {\r\n entry = ENTRY_CANDIDATES.find((candidate) => existsSync(path.join(root, candidate)));\r\n if (!entry) {\r\n fail(`no entry given and none of ${ENTRY_CANDIDATES.join(', ')} exists in ${root}`);\r\n }\r\n} else if (!existsSync(path.resolve(root, entry))) {\r\n fail(`entry not found: ${path.resolve(root, entry)}`);\r\n}\r\n\r\nprocess.stderr.write(`[sigx-terminal-dev] ${path.join(path.basename(root), entry)} — edit a component to hot-update\\n`);\r\n\r\n// Raw mode swallows SIGINT, so quitting the app with Ctrl+C is the renderer\r\n// calling process.exit(130) — only this process sees it, and a wrapping pnpm\r\n// script would report ELIFECYCLE 130. For a dev session that's the normal way\r\n// to stop, not a failure: translate it to 0. Bin-only policy — programmatic\r\n// startDev() embedders keep their own exit handling. Other codes pass through.\r\nconst realExit = process.exit.bind(process);\r\nprocess.exit = ((code?: number | string | null) =>\r\n // Coerced compare: node also accepts integer strings ('130').\r\n realExit(code != null && Number(code) === 130 ? 0 : code)) as typeof process.exit;\r\n\r\nconst handle = await startDev({\r\n entry,\r\n root,\r\n configFile,\r\n onError: (err) => {\r\n // While the app is mounted the renderer routes console.error into the\r\n // transcript above the live region; before/after a mount it's stderr.\r\n console.error('[sigx-terminal-dev] app failed, fix the error and save to retry:\\n', err);\r\n },\r\n});\r\n\r\n// The app owns SIGINT while mounted (raw-mode Ctrl+C tears down and exits).\r\n// This covers external SIGTERM and an unmounted app.\r\nconst stop = () => {\r\n void handle.close().finally(() => process.exit(0));\r\n};\r\nprocess.on('SIGTERM', stop);\r\n"],"mappings":";;;;AAYA,IAAM,IAAmB;CACrB;CACA;CACA;CACA;CACA;CACA;CACH;AAED,SAAS,EAAK,GAAwB;CAElC,AADA,QAAQ,OAAO,MAAM,uBAAuB,EAAQ,IAAI,EACxD,QAAQ,KAAK,EAAE;;AAGnB,IAAM,IAAO,QAAQ,KAAK,MAAM,EAAE,EAC9B,GACA,IAAO,QAAQ,KAAK,EACpB,IAA6B;AAEjC,KAAK,IAAI,IAAI,GAAG,IAAI,EAAK,QAAQ,KAAK;CAClC,IAAM,IAAM,EAAK;CACjB,IAAI,MAAQ,UAAU;EAClB,IAAM,IAAQ,EAAK,EAAE;EAErB,AADK,KAAO,EAAK,2BAA2B,EAC5C,IAAO,EAAK,QAAQ,GAAM,EAAM;QAC7B,IAAI,MAAQ,YAAY;EAC3B,IAAM,IAAQ,EAAK,EAAE;EAErB,AADK,KAAO,EAAK,wBAAwB,EACzC,IAAa;QACV,AAAI,MAAQ,YAAY,MAAQ,QACnC,QAAQ,OAAO,MACX,iQAIH,EACD,QAAQ,KAAK,EAAE,IACR,EAAI,WAAW,IAAI,GAC1B,EAAK,kBAAkB,IAAM,GAE7B,IAAQ;;AAIX,IAKO,EAAW,EAAK,QAAQ,GAAM,EAAM,CAAC,IAC7C,EAAK,oBAAoB,EAAK,QAAQ,GAAM,EAAM,GAAG,IALrD,IAAQ,EAAiB,MAAM,MAAc,EAAW,EAAK,KAAK,GAAM,EAAU,CAAC,CAAC,EAC/E,KACD,EAAK,8BAA8B,EAAiB,KAAK,KAAK,CAAC,aAAa,IAAO,GAM3F,QAAQ,OAAO,MAAM,uBAAuB,EAAK,KAAK,EAAK,SAAS,EAAK,EAAE,EAAM,CAAC,qCAAqC;AAOvH,IAAM,IAAW,QAAQ,KAAK,KAAK,QAAQ;AAC3C,QAAQ,SAAS,MAEb,EAAS,KAAQ,QAAQ,OAAO,EAAK,KAAK,MAAM,IAAI,EAAK;AAE7D,IAAM,IAAS,MAAM,EAAS;CAC1B;CACA;CACA;CACA,UAAU,MAAQ;EAGd,QAAQ,MAAM,sEAAsE,EAAI;;CAE/F,CAAC;AAOF,QAAQ,GAAG,iBAHQ;CACf,EAAY,OAAO,CAAC,cAAc,QAAQ,KAAK,EAAE,CAAC;EAE3B"}
@@ -0,0 +1,127 @@
1
+ import { existsSync as e } from "node:fs";
2
+ import t from "node:path";
3
+ import { fileURLToPath as n, pathToFileURL as r } from "node:url";
4
+ import { createServer as i, createServerModuleRunner as a } from "vite";
5
+ //#region src/plugin.ts
6
+ var o = [
7
+ "@sigx/terminal",
8
+ "@sigx/terminal-ui",
9
+ "@sigx/terminal-zero",
10
+ "@sigx/terminal-dev",
11
+ "@sigx/runtime-terminal",
12
+ "@sigx/runtime-core",
13
+ "@sigx/reactivity",
14
+ "sigx"
15
+ ], s = /\bcomponent\s*[<(]/, c = /\b(defineApp|renderTerminal|mountTerminal)\s*\(/;
16
+ function l() {
17
+ let r = t.dirname(n(import.meta.url));
18
+ for (let n of ["hmr.js", "hmr.ts"]) {
19
+ let i = t.join(r, n);
20
+ if (e(i)) return i.replace(/\\/g, "/");
21
+ }
22
+ return "@sigx/terminal-dev/hmr";
23
+ }
24
+ function u(e) {
25
+ return t.isAbsolute(e) || /^[A-Za-z]:[\\/]/.test(e) ? "/@fs/" + e.replace(/\\/g, "/").replace(/^\//, "") : e;
26
+ }
27
+ function d(e = {}) {
28
+ let { hmr: n = !0 } = e, r = e.hmrRuntime ?? l(), i = "serve";
29
+ return {
30
+ name: "sigx-terminal-dev",
31
+ enforce: "pre",
32
+ config(n, a) {
33
+ if (i = a.command, a.command !== "serve") return;
34
+ let s = t.isAbsolute(r) || /^[A-Za-z]:[\\/]/.test(r);
35
+ return {
36
+ ssr: { external: [...o, ...e.external ?? []] },
37
+ ...s ? { server: { fs: { allow: [t.dirname(r)] } } } : {}
38
+ };
39
+ },
40
+ configResolved(e) {
41
+ i = e.command;
42
+ },
43
+ transform(e, t) {
44
+ if (!n || i !== "serve" || !/\.[jt]sx?$/.test(t) || t.includes("node_modules") || t.includes("/dist/") || t.includes("\\dist\\")) return null;
45
+ let a = t.replace(/\\/g, "/");
46
+ if (a === r || a.endsWith("/hmr.ts") || a.endsWith("/hmr.js") || !s.test(e)) return null;
47
+ let o = a.replace(/'/g, "\\'"), l = `import { registerHMRModule as __sigxRegisterHMRModule, clearHMRModule as __sigxClearHMRModule } from '${u(r)}';\n__sigxRegisterHMRModule('${o}');\n`, d = `\n__sigxClearHMRModule('${o}');\n` + (c.test(e) ? "" : "if (import.meta.hot) {\n import.meta.hot.accept();\n}\n");
48
+ return {
49
+ code: l + e + d,
50
+ map: null
51
+ };
52
+ }
53
+ };
54
+ }
55
+ //#endregion
56
+ //#region src/dev.ts
57
+ function f(e) {
58
+ for (let t of e.evaluatedModules.idToModuleMap.values()) {
59
+ let e = t.exports?.exitTerminal;
60
+ if (typeof e == "function") try {
61
+ e();
62
+ } catch {}
63
+ }
64
+ }
65
+ async function p(e) {
66
+ let n = t.resolve(e.root ?? process.cwd()), o = r(t.resolve(n, e.entry)).href, s = e.onError ?? ((e) => {
67
+ console.error("[sigx-terminal-dev] failed to start app:", e);
68
+ }), c = await i({
69
+ root: n,
70
+ configFile: e.configFile ?? !1,
71
+ plugins: [d(e.plugin)],
72
+ appType: "custom",
73
+ logLevel: "silent",
74
+ server: { middlewareMode: !0 }
75
+ }), l = a(c.environments.ssr, { hmr: { logger: !1 } }), u = !1, p = !1, m = null, h = null;
76
+ async function g() {
77
+ try {
78
+ await l.import(o), p = !1, e.onRestart?.();
79
+ } catch (e) {
80
+ p = !0, s(e);
81
+ }
82
+ }
83
+ async function _() {
84
+ if (h) return h.then(() => u ? void 0 : _());
85
+ h = (async () => {
86
+ f(l), l.clearCache(), u || await g();
87
+ })();
88
+ try {
89
+ await h;
90
+ } finally {
91
+ h = null;
92
+ }
93
+ }
94
+ function v() {
95
+ u || m || (m = setTimeout(() => {
96
+ m = null, _();
97
+ }, 50));
98
+ }
99
+ let y = c.environments.ssr.hot, b = y.send.bind(y);
100
+ y.send = ((...e) => {
101
+ let t = e[0];
102
+ if (t && typeof t == "object" && t.type === "full-reload") {
103
+ v();
104
+ return;
105
+ }
106
+ p && t && typeof t == "object" && t.type === "update" && v(), b(...e);
107
+ });
108
+ async function x() {
109
+ if (!u) {
110
+ u = !0, m &&= (clearTimeout(m), null);
111
+ try {
112
+ await h;
113
+ } catch {}
114
+ f(l), await l.close(), await c.close();
115
+ }
116
+ }
117
+ return await g(), {
118
+ server: c,
119
+ runner: l,
120
+ restart: _,
121
+ close: x
122
+ };
123
+ }
124
+ //#endregion
125
+ export { d as n, p as t };
126
+
127
+ //# sourceMappingURL=dev-CW11nYP4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dev-CW11nYP4.js","names":[],"sources":["../src/plugin.ts","../src/dev.ts"],"sourcesContent":["/**\r\n * Vite plugin for SignalX terminal dev mode.\r\n *\r\n * Two jobs:\r\n *\r\n * 1. Keep the framework out of the hot module graph. Every `@sigx/*` package\r\n * is externalized for SSR, so the terminal renderer (raw mode, alt screen,\r\n * the render loop) and the reactivity instance are plain node singletons\r\n * that survive every hot update — only app code re-executes.\r\n *\r\n * 2. Make component modules hot. Project files that define components get\r\n * `registerHMRModule(<id>)` injected at the top (component identity for\r\n * the HMR runtime) and a self-`accept()` at the bottom, so an edit\r\n * re-executes just that module and the runtime patches live instances in\r\n * place. Modules that MOUNT the app (`defineApp`/`renderTerminal`/\r\n * `mountTerminal`) are registered but never self-accept — re-executing\r\n * them would mount a second app — so edits there bubble out as a\r\n * full-reload, which the dev runner turns into a clean in-process restart.\r\n */\r\nimport { existsSync } from 'node:fs';\r\nimport path from 'node:path';\r\nimport { fileURLToPath } from 'node:url';\r\nimport type { Plugin } from 'vite';\r\n\r\nexport interface TerminalDevPluginOptions {\r\n /**\r\n * Inject HMR registration/accept code into component modules.\r\n * @default true\r\n */\r\n hmr?: boolean;\r\n /**\r\n * Module specifier injected to import the HMR runtime from. Defaults to\r\n * the runtime file shipped next to this plugin, so apps don't need\r\n * `@sigx/terminal-dev` in their own dependency graph.\r\n */\r\n hmrRuntime?: string;\r\n /**\r\n * Extra SSR externals on top of the `@sigx/*` packages.\r\n */\r\n external?: string[];\r\n}\r\n\r\n/**\r\n * Packages that must stay singletons outside the hot module graph. The\r\n * renderer holds TTY state; reactivity holds the effect tracking; splitting\r\n * any of them between node and the module runner breaks signals silently.\r\n */\r\nconst SIGX_EXTERNALS = [\r\n '@sigx/terminal',\r\n '@sigx/terminal-ui',\r\n '@sigx/terminal-zero',\r\n '@sigx/terminal-dev',\r\n '@sigx/runtime-terminal',\r\n '@sigx/runtime-core',\r\n '@sigx/reactivity',\r\n 'sigx',\r\n];\r\n\r\n/** A module that defines at least one `component(...)`. */\r\nconst COMPONENT_RE = /\\bcomponent\\s*[<(]/;\r\n/** A module that mounts an app — re-executing it would double-mount. */\r\nconst MOUNT_RE = /\\b(defineApp|renderTerminal|mountTerminal)\\s*\\(/;\r\n\r\nfunction defaultHmrRuntime(): string {\r\n // Built layout: dist/index.js (this file, bundled) next to dist/hmr.js.\r\n // Repo/test layout: src/plugin.ts next to src/hmr.ts.\r\n const dir = path.dirname(fileURLToPath(import.meta.url));\r\n for (const candidate of ['hmr.js', 'hmr.ts']) {\r\n const p = path.join(dir, candidate);\r\n if (existsSync(p)) return p.replace(/\\\\/g, '/');\r\n }\r\n return '@sigx/terminal-dev/hmr';\r\n}\r\n\r\n/**\r\n * Turn the runtime location into a valid import specifier. Absolute fs paths\r\n * (notably Windows drive paths like `C:/…`) are not portable module\r\n * specifiers, so they're injected in Vite's explicit fs-path form\r\n * (`/@fs/C:/…` / `/@fs/home/…`); bare specifiers pass through.\r\n */\r\nfunction toImportSpecifier(runtime: string): string {\r\n // Platform-agnostic on purpose: a windows drive path must be recognized\r\n // even when this code runs under a posix `path` (and vice versa).\r\n const isAbsolute = path.isAbsolute(runtime) || /^[A-Za-z]:[\\\\/]/.test(runtime);\r\n if (!isAbsolute) return runtime;\r\n return '/@fs/' + runtime.replace(/\\\\/g, '/').replace(/^\\//, '');\r\n}\r\n\r\nexport function terminalDevPlugin(options: TerminalDevPluginOptions = {}): Plugin {\r\n const { hmr = true } = options;\r\n const hmrRuntime = options.hmrRuntime ?? defaultHmrRuntime();\r\n\r\n let command: 'serve' | 'build' = 'serve';\r\n\r\n return {\r\n name: 'sigx-terminal-dev',\r\n enforce: 'pre',\r\n\r\n config(_userConfig, env) {\r\n command = env.command;\r\n if (env.command !== 'serve') return;\r\n // The HMR runtime usually lives outside the app root (next to\r\n // this plugin). The module runner's fetches don't go through the\r\n // HTTP fs allowlist, but a browser environment served from the\r\n // same config would — allow exactly the runtime's directory\r\n // rather than disabling strict mode.\r\n const isAbsoluteRuntime = path.isAbsolute(hmrRuntime) || /^[A-Za-z]:[\\\\/]/.test(hmrRuntime);\r\n return {\r\n ssr: {\r\n external: [...SIGX_EXTERNALS, ...(options.external ?? [])],\r\n },\r\n ...(isAbsoluteRuntime\r\n ? { server: { fs: { allow: [path.dirname(hmrRuntime)] } } }\r\n : {}),\r\n };\r\n },\r\n\r\n configResolved(resolved) {\r\n command = resolved.command;\r\n },\r\n\r\n transform(code, id) {\r\n if (!hmr || command !== 'serve') return null;\r\n if (!/\\.[jt]sx?$/.test(id)) return null;\r\n if (id.includes('node_modules') || id.includes('/dist/') || id.includes('\\\\dist\\\\')) return null;\r\n\r\n const moduleId = id.replace(/\\\\/g, '/');\r\n // Never instrument the HMR runtime itself.\r\n if (moduleId === hmrRuntime || moduleId.endsWith('/hmr.ts') || moduleId.endsWith('/hmr.js')) return null;\r\n\r\n if (!COMPONENT_RE.test(code)) return null;\r\n\r\n const escapedId = moduleId.replace(/'/g, \"\\\\'\");\r\n const header =\r\n `import { registerHMRModule as __sigxRegisterHMRModule, clearHMRModule as __sigxClearHMRModule } from '${toImportSpecifier(hmrRuntime)}';\\n` +\r\n `__sigxRegisterHMRModule('${escapedId}');\\n`;\r\n\r\n // The definition scope closes after the module body, so later\r\n // definitions from non-instrumented code can't inherit this id.\r\n // Mount modules get identity registration (their components are\r\n // still patchable when OTHER modules change) but no self-accept.\r\n const footer =\r\n `\\n__sigxClearHMRModule('${escapedId}');\\n` +\r\n (MOUNT_RE.test(code)\r\n ? ''\r\n : `if (import.meta.hot) {\\n import.meta.hot.accept();\\n}\\n`);\r\n\r\n return { code: header + code + footer, map: null };\r\n },\r\n };\r\n}\r\n\r\nexport default terminalDevPlugin;\r\n","/**\r\n * The dev runner: an in-process Vite dev server + module runner that executes\r\n * a SignalX terminal app with HMR.\r\n *\r\n * Component-module edits re-execute just that module; the HMR runtime patches\r\n * live instances in place and the renderer repaints — the TUI never restarts.\r\n * Edits that can't be hot-applied (the mount module, or a module nothing\r\n * accepts) surface as a full-reload payload, which is intercepted and turned\r\n * into a clean in-process restart: tear the terminal down, drop the module\r\n * cache, re-import the entry.\r\n */\r\nimport path from 'node:path';\r\nimport { pathToFileURL } from 'node:url';\r\nimport {\r\n createServer,\r\n createServerModuleRunner,\r\n type ViteDevServer,\r\n} from 'vite';\r\nimport type { ModuleRunner } from 'vite/module-runner';\r\nimport { terminalDevPlugin, type TerminalDevPluginOptions } from './plugin.js';\r\n\r\nexport interface DevOptions {\r\n /** App entry module (the file that mounts), relative to `root`. */\r\n entry: string;\r\n /** Project root for the dev server. @default process.cwd() */\r\n root?: string;\r\n /**\r\n * Vite config file to load on top of the built-in setup.\r\n * @default false (no config file — deterministic dev setup)\r\n */\r\n configFile?: string | false;\r\n /** Options forwarded to the terminal dev plugin. */\r\n plugin?: TerminalDevPluginOptions;\r\n /** Called after every successful (re)start of the entry. */\r\n onRestart?: () => void;\r\n /** Called when (re)starting the entry fails. */\r\n onError?: (err: unknown) => void;\r\n}\r\n\r\nexport interface DevHandle {\r\n server: ViteDevServer;\r\n runner: ModuleRunner;\r\n /** Tear down the running app and re-import the entry. */\r\n restart: () => Promise<void>;\r\n /** Stop everything: unmount the app, close the runner and the server. */\r\n close: () => Promise<void>;\r\n}\r\n\r\n/**\r\n * Unmount the running app via the same renderer instance(s) the app used.\r\n * Resolution nuances (workspace links load the renderer through the runner,\r\n * published installs externalize it; an importer-less `runner.import` can\r\n * even land on a different copy) make \"import the renderer and call\r\n * exitTerminal\" unreliable — so instead walk every evaluated module and tear\r\n * down any that exposes `exitTerminal`. Idempotent per instance, synchronous,\r\n * and exact: only instances the app actually loaded are touched.\r\n */\r\nfunction exitApp(runner: ModuleRunner): void {\r\n for (const mod of runner.evaluatedModules.idToModuleMap.values()) {\r\n const exitTerminal = (mod.exports as { exitTerminal?: unknown } | undefined)?.exitTerminal;\r\n if (typeof exitTerminal === 'function') {\r\n try {\r\n (exitTerminal as () => void)();\r\n } catch {\r\n // Teardown of a half-mounted app must not block the restart.\r\n }\r\n }\r\n }\r\n}\r\n\r\nexport async function startDev(options: DevOptions): Promise<DevHandle> {\r\n const root = path.resolve(options.root ?? process.cwd());\r\n const entryAbs = path.resolve(root, options.entry);\r\n const entryUrl = pathToFileURL(entryAbs).href;\r\n const onError = options.onError ?? ((err: unknown) => {\r\n console.error('[sigx-terminal-dev] failed to start app:', err);\r\n });\r\n\r\n const server = await createServer({\r\n root,\r\n configFile: options.configFile ?? false,\r\n plugins: [terminalDevPlugin(options.plugin)],\r\n appType: 'custom',\r\n logLevel: 'silent',\r\n server: { middlewareMode: true },\r\n });\r\n\r\n const runner = createServerModuleRunner(server.environments.ssr, {\r\n hmr: { logger: false },\r\n });\r\n\r\n let closed = false;\r\n let entryBroken = false;\r\n let restartTimer: ReturnType<typeof setTimeout> | null = null;\r\n let restarting: Promise<void> | null = null;\r\n\r\n async function importEntry(): Promise<void> {\r\n try {\r\n await runner.import(entryUrl);\r\n entryBroken = false;\r\n options.onRestart?.();\r\n } catch (err) {\r\n entryBroken = true;\r\n onError(err);\r\n }\r\n }\r\n\r\n async function restart(): Promise<void> {\r\n // Serialize restarts; a request during one queues exactly one more.\r\n if (restarting) {\r\n return restarting.then(() => (closed ? undefined : restart()));\r\n }\r\n restarting = (async () => {\r\n exitApp(runner);\r\n runner.clearCache();\r\n if (!closed) await importEntry();\r\n })();\r\n try {\r\n await restarting;\r\n } finally {\r\n restarting = null;\r\n }\r\n }\r\n\r\n function scheduleRestart(): void {\r\n if (closed || restartTimer) return;\r\n restartTimer = setTimeout(() => {\r\n restartTimer = null;\r\n void restart();\r\n }, 50);\r\n }\r\n\r\n // Hot payloads flow to the runner through this channel — INCLUDING the\r\n // runner's own module-fetch round-trips (`custom`/`vite:invoke`), so only\r\n // `full-reload` may ever be swallowed; anything else must pass through or\r\n // every import deadlocks. Full-reload is swallowed and replaced with a\r\n // controlled restart: the runner's built-in handler would re-import the\r\n // entry without tearing the old app down first (a double mount). An\r\n // `update` while the entry is broken also schedules a restart (patching\r\n // modules of a dead app can't revive it) but still forwards.\r\n const hot = server.environments.ssr.hot;\r\n const origSend = hot.send.bind(hot);\r\n hot.send = ((...args: unknown[]) => {\r\n const payload = args[0] as { type?: string } | undefined;\r\n if (payload && typeof payload === 'object' && payload.type === 'full-reload') {\r\n scheduleRestart();\r\n return;\r\n }\r\n if (entryBroken && payload && typeof payload === 'object' && payload.type === 'update') {\r\n scheduleRestart();\r\n }\r\n (origSend as (...a: unknown[]) => void)(...args);\r\n }) as typeof hot.send;\r\n\r\n async function close(): Promise<void> {\r\n if (closed) return;\r\n closed = true;\r\n if (restartTimer) {\r\n clearTimeout(restartTimer);\r\n restartTimer = null;\r\n }\r\n // An in-flight restart may be mid clearCache/import; let it settle\r\n // before tearing the runner and server down under it.\r\n try {\r\n await restarting;\r\n } catch {\r\n // importEntry reports its own errors; close() proceeds regardless.\r\n }\r\n exitApp(runner);\r\n await runner.close();\r\n await server.close();\r\n }\r\n\r\n await importEntry();\r\n\r\n return { server, runner, restart, close };\r\n}\r\n"],"mappings":"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"}
package/dist/dev.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { type ViteDevServer } from 'vite';
2
+ import type { ModuleRunner } from 'vite/module-runner';
3
+ import { type TerminalDevPluginOptions } from './plugin.js';
4
+ export interface DevOptions {
5
+ /** App entry module (the file that mounts), relative to `root`. */
6
+ entry: string;
7
+ /** Project root for the dev server. @default process.cwd() */
8
+ root?: string;
9
+ /**
10
+ * Vite config file to load on top of the built-in setup.
11
+ * @default false (no config file — deterministic dev setup)
12
+ */
13
+ configFile?: string | false;
14
+ /** Options forwarded to the terminal dev plugin. */
15
+ plugin?: TerminalDevPluginOptions;
16
+ /** Called after every successful (re)start of the entry. */
17
+ onRestart?: () => void;
18
+ /** Called when (re)starting the entry fails. */
19
+ onError?: (err: unknown) => void;
20
+ }
21
+ export interface DevHandle {
22
+ server: ViteDevServer;
23
+ runner: ModuleRunner;
24
+ /** Tear down the running app and re-import the entry. */
25
+ restart: () => Promise<void>;
26
+ /** Stop everything: unmount the app, close the runner and the server. */
27
+ close: () => Promise<void>;
28
+ }
29
+ export declare function startDev(options: DevOptions): Promise<DevHandle>;
package/dist/hmr.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Mark `moduleId` as the module currently executing. Injected by the dev
3
+ * plugin at the top of each transformed module (and on every hot
4
+ * re-execution, which resets the definition counter so identities line up).
5
+ */
6
+ export declare function registerHMRModule(moduleId: string): void;
7
+ /**
8
+ * Close `moduleId`'s definition scope. Injected by the dev plugin at the
9
+ * bottom of each transformed module, so a `component(...)` executed later
10
+ * from a NON-instrumented module (an externalized package, a runtime
11
+ * callback) can't be misattributed to the last instrumented module's
12
+ * identity counter. Guarded: an inner instrumented import has already
13
+ * shifted the current id, and must not be clobbered. (ESM evaluation order
14
+ * makes the common case safe — imports evaluate before the importer's body —
15
+ * so by the time this module's footer runs it IS the current one.)
16
+ */
17
+ export declare function clearHMRModule(moduleId: string): void;
18
+ /** Hook component definition. Runs once, synchronously, at module load. */
19
+ export declare function installHMRPlugin(): void;
package/dist/hmr.js ADDED
@@ -0,0 +1,52 @@
1
+ import { registerComponentPlugin as e } from "@sigx/runtime-core/internals";
2
+ //#region src/hmr.ts
3
+ var t = Symbol.for("sigx.terminal-dev.hmr-state"), n = globalThis[t] ??= {
4
+ instancesByComponentId: /* @__PURE__ */ new Map(),
5
+ setupByComponentId: /* @__PURE__ */ new Map(),
6
+ moduleComponentIndex: /* @__PURE__ */ new Map(),
7
+ currentModuleId: null,
8
+ installed: !1
9
+ };
10
+ n.setupByComponentId ??= /* @__PURE__ */ new Map();
11
+ function r(e) {
12
+ n.currentModuleId = e, n.moduleComponentIndex.set(e, 0);
13
+ }
14
+ function i(e) {
15
+ n.currentModuleId === e && (n.currentModuleId = null);
16
+ }
17
+ function a() {
18
+ if (!n.currentModuleId) return null;
19
+ let e = n.moduleComponentIndex.get(n.currentModuleId) ?? 0;
20
+ return n.moduleComponentIndex.set(n.currentModuleId, e + 1), `${n.currentModuleId}:${e}`;
21
+ }
22
+ function o() {
23
+ n.installed || (n.installed = !0, e({ onDefine(e, t, r) {
24
+ let i = a();
25
+ if (!i) return;
26
+ t.__hmrId = i;
27
+ let o = n.instancesByComponentId.get(i);
28
+ o && o.size > 0 && o.forEach((t) => {
29
+ try {
30
+ let e = r(t.ctx);
31
+ if (e instanceof Promise) throw Error("async setup is not hot-reloadable");
32
+ t.ctx.renderFn = e, t.ctx.update();
33
+ } catch (t) {
34
+ console.error(`[sigx-terminal-dev] HMR failed for ${e || i}:`, t);
35
+ }
36
+ });
37
+ let s = r, c = (e) => {
38
+ let t = s(e), r = { ctx: e }, a = n.instancesByComponentId.get(i);
39
+ return a || (a = /* @__PURE__ */ new Set(), n.instancesByComponentId.set(i, a)), a.add(r), e.onUnmounted(() => {
40
+ n.instancesByComponentId.get(i)?.delete(r);
41
+ }), t;
42
+ }, l = n.setupByComponentId.get(i);
43
+ l ? l.current = c : (l = { current: c }, n.setupByComponentId.set(i, l));
44
+ let u = l;
45
+ t.__setup = (e) => u.current(e);
46
+ } }));
47
+ }
48
+ o();
49
+ //#endregion
50
+ export { i as clearHMRModule, o as installHMRPlugin, r as registerHMRModule };
51
+
52
+ //# sourceMappingURL=hmr.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hmr.js","names":[],"sources":["../src/hmr.ts"],"sourcesContent":["/**\r\n * HMR runtime for SignalX terminal apps.\r\n *\r\n * The dev plugin injects `registerHMRModule(<id>)` at the top of every\r\n * transformed module that defines components, so each `component(...)` call\r\n * gets a stable identity of `<moduleId>:<definition index>`. When an edited\r\n * module re-executes, its definitions land on the same identities; for every\r\n * live instance of that identity the NEW setup re-runs against the EXISTING\r\n * context, the render function is swapped, and the instance re-renders in\r\n * place — the terminal never tears down.\r\n *\r\n * This is the terminal-side counterpart of `@sigx/vite/hmr`, which hooks the\r\n * `sigx` DOM facade (`sigx/internals`) and therefore can't be used here. Two\r\n * deliberate differences: it hooks `@sigx/runtime-core/internals` directly,\r\n * and it installs synchronously at module load — the async install in the DOM\r\n * runtime leaves a window where definitions evaluated before the dynamic\r\n * import resolves are never tracked.\r\n */\r\nimport { registerComponentPlugin } from '@sigx/runtime-core/internals';\r\nimport type { ComponentSetupContext, SetupFn } from '@sigx/runtime-core/internals';\r\n\r\ninterface InstanceEntry {\r\n ctx: ComponentSetupContext;\r\n}\r\n\r\n/**\r\n * The latest tracked setup for one component identity. Every factory defined\r\n * under the identity gets a trampoline `__setup` reading `current`, so a\r\n * redefine only has to swap this box — importers that captured a factory\r\n * before the edit (tab catalogs, navigation parents) then mount the new code,\r\n * and no old factory is ever retained or iterated.\r\n */\r\ninterface SetupBox {\r\n current: SetupFn;\r\n}\r\n\r\ninterface HmrState {\r\n /** Live instances per component identity (`moduleId:index`). */\r\n instancesByComponentId: Map<string, Set<InstanceEntry>>;\r\n /** Current setup per component identity; see {@link SetupBox}. */\r\n setupByComponentId: Map<string, SetupBox>;\r\n /** Definition order within the module currently executing. */\r\n moduleComponentIndex: Map<string, number>;\r\n currentModuleId: string | null;\r\n installed: boolean;\r\n}\r\n\r\n// This module can be instantiated more than once in a dev session: it loads\r\n// through the module runner, and an in-process restart clears the runner's\r\n// cache. The component plugin hooks a node singleton (runtime-core), so the\r\n// tracking state must be a process singleton too — a second instance with its\r\n// own maps would register a second plugin and skew every identity index.\r\nconst STATE_KEY = Symbol.for('sigx.terminal-dev.hmr-state');\r\nconst state: HmrState = ((globalThis as any)[STATE_KEY] ??= {\r\n instancesByComponentId: new Map(),\r\n setupByComponentId: new Map(),\r\n moduleComponentIndex: new Map(),\r\n currentModuleId: null,\r\n installed: false,\r\n} satisfies HmrState);\r\n// An older runtime instance may have seeded the singleton without this map.\r\n(state as Partial<HmrState>).setupByComponentId ??= new Map();\r\n\r\n/**\r\n * Mark `moduleId` as the module currently executing. Injected by the dev\r\n * plugin at the top of each transformed module (and on every hot\r\n * re-execution, which resets the definition counter so identities line up).\r\n */\r\nexport function registerHMRModule(moduleId: string): void {\r\n state.currentModuleId = moduleId;\r\n state.moduleComponentIndex.set(moduleId, 0);\r\n}\r\n\r\n/**\r\n * Close `moduleId`'s definition scope. Injected by the dev plugin at the\r\n * bottom of each transformed module, so a `component(...)` executed later\r\n * from a NON-instrumented module (an externalized package, a runtime\r\n * callback) can't be misattributed to the last instrumented module's\r\n * identity counter. Guarded: an inner instrumented import has already\r\n * shifted the current id, and must not be clobbered. (ESM evaluation order\r\n * makes the common case safe — imports evaluate before the importer's body —\r\n * so by the time this module's footer runs it IS the current one.)\r\n */\r\nexport function clearHMRModule(moduleId: string): void {\r\n if (state.currentModuleId === moduleId) state.currentModuleId = null;\r\n}\r\n\r\nfunction getNextComponentId(): string | null {\r\n if (!state.currentModuleId) return null;\r\n const index = state.moduleComponentIndex.get(state.currentModuleId) ?? 0;\r\n state.moduleComponentIndex.set(state.currentModuleId, index + 1);\r\n return `${state.currentModuleId}:${index}`;\r\n}\r\n\r\n/** Hook component definition. Runs once, synchronously, at module load. */\r\nexport function installHMRPlugin(): void {\r\n if (state.installed) return;\r\n state.installed = true;\r\n\r\n registerComponentPlugin({\r\n onDefine(name: string | undefined, factory: any, setup: Function) {\r\n const componentId = getNextComponentId();\r\n if (!componentId) return;\r\n\r\n factory.__hmrId = componentId;\r\n\r\n // Re-execution of an edited module: patch every live instance of\r\n // this identity with the new setup's render function.\r\n const existing = state.instancesByComponentId.get(componentId);\r\n if (existing && existing.size > 0) {\r\n existing.forEach((instance) => {\r\n try {\r\n const newRenderFn = (setup as SetupFn)(instance.ctx);\r\n if (newRenderFn instanceof Promise) {\r\n throw new Error('async setup is not hot-reloadable');\r\n }\r\n instance.ctx.renderFn = newRenderFn;\r\n instance.ctx.update();\r\n } catch (e) {\r\n console.error(`[sigx-terminal-dev] HMR failed for ${name || componentId}:`, e);\r\n }\r\n });\r\n }\r\n\r\n // Wrap setup so future mounts of this identity are tracked.\r\n const originalSetup = setup as SetupFn;\r\n const trackedSetup = (ctx: ComponentSetupContext) => {\r\n const renderFn = originalSetup(ctx);\r\n\r\n const instance: InstanceEntry = { ctx };\r\n let instances = state.instancesByComponentId.get(componentId);\r\n if (!instances) {\r\n instances = new Set();\r\n state.instancesByComponentId.set(componentId, instances);\r\n }\r\n instances.add(instance);\r\n ctx.onUnmounted(() => {\r\n state.instancesByComponentId.get(componentId)?.delete(instance);\r\n });\r\n\r\n return renderFn;\r\n };\r\n // All factories of this identity — including stale references\r\n // held by importers since before the edit — mount through a\r\n // trampoline reading the identity's setup box, so swapping\r\n // `box.current` is the whole \"repoint old factories\" story.\r\n // runtime-core reads `__setup` at instantiation.\r\n let box = state.setupByComponentId.get(componentId);\r\n if (!box) {\r\n box = { current: trackedSetup };\r\n state.setupByComponentId.set(componentId, box);\r\n } else {\r\n box.current = trackedSetup;\r\n }\r\n const stableBox = box;\r\n factory.__setup = (ctx: ComponentSetupContext) => stableBox.current(ctx);\r\n },\r\n });\r\n}\r\n\r\ninstallHMRPlugin();\r\n"],"mappings":";;AAoDA,IAAM,IAAY,OAAO,IAAI,8BAA8B,EACrD,IAAmB,WAAoB,OAAe;CACxD,wCAAwB,IAAI,KAAK;CACjC,oCAAoB,IAAI,KAAK;CAC7B,sCAAsB,IAAI,KAAK;CAC/B,iBAAiB;CACjB,WAAW;CACd;AAED,EAA6B,uCAAuB,IAAI,KAAK;AAO7D,SAAgB,EAAkB,GAAwB;CAEtD,AADA,EAAM,kBAAkB,GACxB,EAAM,qBAAqB,IAAI,GAAU,EAAE;;AAa/C,SAAgB,EAAe,GAAwB;CACnD,AAAI,EAAM,oBAAoB,MAAU,EAAM,kBAAkB;;AAGpE,SAAS,IAAoC;CACzC,IAAI,CAAC,EAAM,iBAAiB,OAAO;CACnC,IAAM,IAAQ,EAAM,qBAAqB,IAAI,EAAM,gBAAgB,IAAI;CAEvE,OADA,EAAM,qBAAqB,IAAI,EAAM,iBAAiB,IAAQ,EAAE,EACzD,GAAG,EAAM,gBAAgB,GAAG;;AAIvC,SAAgB,IAAyB;CACjC,EAAM,cACV,EAAM,YAAY,IAElB,EAAwB,EACpB,SAAS,GAA0B,GAAc,GAAiB;EAC9D,IAAM,IAAc,GAAoB;EACxC,IAAI,CAAC,GAAa;EAElB,EAAQ,UAAU;EAIlB,IAAM,IAAW,EAAM,uBAAuB,IAAI,EAAY;EAC9D,AAAI,KAAY,EAAS,OAAO,KAC5B,EAAS,SAAS,MAAa;GAC3B,IAAI;IACA,IAAM,IAAe,EAAkB,EAAS,IAAI;IACpD,IAAI,aAAuB,SACvB,MAAU,MAAM,oCAAoC;IAGxD,AADA,EAAS,IAAI,WAAW,GACxB,EAAS,IAAI,QAAQ;YAChB,GAAG;IACR,QAAQ,MAAM,sCAAsC,KAAQ,EAAY,IAAI,EAAE;;IAEpF;EAIN,IAAM,IAAgB,GAChB,KAAgB,MAA+B;GACjD,IAAM,IAAW,EAAc,EAAI,EAE7B,IAA0B,EAAE,QAAK,EACnC,IAAY,EAAM,uBAAuB,IAAI,EAAY;GAU7D,OATK,MACD,oBAAY,IAAI,KAAK,EACrB,EAAM,uBAAuB,IAAI,GAAa,EAAU,GAE5D,EAAU,IAAI,EAAS,EACvB,EAAI,kBAAkB;IAClB,EAAM,uBAAuB,IAAI,EAAY,EAAE,OAAO,EAAS;KACjE,EAEK;KAOP,IAAM,EAAM,mBAAmB,IAAI,EAAY;EACnD,AAAK,IAID,EAAI,UAAU,KAHd,IAAM,EAAE,SAAS,GAAc,EAC/B,EAAM,mBAAmB,IAAI,GAAa,EAAI;EAIlD,IAAM,IAAY;EAClB,EAAQ,WAAW,MAA+B,EAAU,QAAQ,EAAI;IAE/E,CAAC;;AAGN,GAAkB"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @sigx/terminal-dev — HMR dev runner for SignalX terminal apps.
3
+ *
4
+ * - `startDev()` / the `sigx-terminal-dev` bin: run an app under an
5
+ * in-process Vite dev server with hot component replacement.
6
+ * - `terminalDevPlugin()`: the Vite plugin, for custom setups.
7
+ * - `@sigx/terminal-dev/hmr`: the HMR runtime (injected automatically).
8
+ */
9
+ export { terminalDevPlugin, type TerminalDevPluginOptions } from './plugin.js';
10
+ export { startDev, type DevOptions, type DevHandle } from './dev.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import { n as e, t } from "./dev-CW11nYP4.js";
2
+ export { t as startDev, e as terminalDevPlugin };
@@ -0,0 +1,20 @@
1
+ import type { Plugin } from 'vite';
2
+ export interface TerminalDevPluginOptions {
3
+ /**
4
+ * Inject HMR registration/accept code into component modules.
5
+ * @default true
6
+ */
7
+ hmr?: boolean;
8
+ /**
9
+ * Module specifier injected to import the HMR runtime from. Defaults to
10
+ * the runtime file shipped next to this plugin, so apps don't need
11
+ * `@sigx/terminal-dev` in their own dependency graph.
12
+ */
13
+ hmrRuntime?: string;
14
+ /**
15
+ * Extra SSR externals on top of the `@sigx/*` packages.
16
+ */
17
+ external?: string[];
18
+ }
19
+ export declare function terminalDevPlugin(options?: TerminalDevPluginOptions): Plugin;
20
+ export default terminalDevPlugin;
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@sigx/terminal-dev",
3
+ "version": "0.5.0",
4
+ "description": "HMR dev runner for SignalX terminal apps - edit components, see the running TUI update live",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "bin": {
9
+ "sigx-terminal-dev": "./bin/sigx-terminal-dev.mjs"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/index.js",
14
+ "types": "./dist/index.d.ts"
15
+ },
16
+ "./hmr": {
17
+ "import": "./dist/hmr.js",
18
+ "types": "./dist/hmr.d.ts"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "bin"
24
+ ],
25
+ "keywords": [
26
+ "sigx",
27
+ "signalx",
28
+ "terminal",
29
+ "tui",
30
+ "hmr",
31
+ "vite",
32
+ "dev-server"
33
+ ],
34
+ "author": "Andreas Ekdahl",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/signalxjs/terminal.git",
39
+ "directory": "packages/terminal-dev"
40
+ },
41
+ "homepage": "https://sigx.dev/terminal/",
42
+ "bugs": {
43
+ "url": "https://github.com/signalxjs/terminal/issues"
44
+ },
45
+ "dependencies": {
46
+ "@sigx/runtime-core": "^0.4.9",
47
+ "vite": "^8.0.3"
48
+ },
49
+ "devDependencies": {
50
+ "@sigx/reactivity": "^0.4.9",
51
+ "@sigx/vite": "^0.4.7",
52
+ "@types/node": "^22.0.0",
53
+ "typescript": "^5.9.3",
54
+ "@sigx/runtime-terminal": "^0.5.0"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "scripts": {
60
+ "build": "vite build && tsgo --emitDeclarationOnly",
61
+ "dev": "vite build --watch"
62
+ }
63
+ }