diff --git a/AGENTS.md b/AGENTS.md index b9dc9b9..01df0e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,9 @@ This repo uses `mise` to pin toolchains (see `mise.toml`). - TypeScript (ESM) with strict type-checking (`tsconfig.json`). - Prefer small, composable modules and re-export via `src/**/index.ts`. - Use lowercase filenames; use `kebab-case` for multi-word files (e.g., `render-loop.ts`). +- Run `mise run test && mise run precommit` after any edit. - Run `mise run format` before opening a PR; let `oxfmt` decide whitespace. +- This project targets Bun-native development; optimizing to remove Bun dependencies from the core package is not a goal. ## Testing Guidelines diff --git a/README.ja.md b/README.ja.md index ed47d60..a3a046a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -70,6 +70,8 @@ await app.mount({ inline: true, inlineCleanupOnExit: true }); - [**ドキュメント**](./docs/) (アーキテクチャ, ロードマップ) - [**Inline モード**](./docs/inline-mode.ja.md) +- [**DevTools**](./docs/devtools.ja.md) +- [**ホットリロード**](./docs/hot-reload.ja.md) - [**GitHub**](https://github.com/HALQME/btuin) (ソースコード, Issue) ## 言語 diff --git a/README.md b/README.md index 7b6e1dd..a083d73 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ await app.mount({ inline: true, inlineCleanupOnExit: true }); - [**Documentation**](./docs/) (Architecture, Roadmap) - [**Inline Mode**](./docs/inline-mode.md) +- [**DevTools**](./docs/devtools.md) +- [**Hot Reload**](./docs/hot-reload.md) - [**GitHub**](https://github.com/HALQME/btuin) (Source Code, Issues) ## Language diff --git a/bin/btuin b/bin/btuin new file mode 100755 index 0000000..c5fb964 --- /dev/null +++ b/bin/btuin @@ -0,0 +1,4 @@ +#!/usr/bin/env bun +import { btuinCli } from "../src/cli/main"; + +await btuinCli(Bun.argv.slice(2)); diff --git a/docs/devtools.ja.md b/docs/devtools.ja.md new file mode 100644 index 0000000..f8ebac0 --- /dev/null +++ b/docs/devtools.ja.md @@ -0,0 +1,65 @@ +# DevTools + +btuin には、TUI 開発時にアプリを観測するための軽量なブラウザ UI があります。 + +## ブラウザ DevTools(おすすめ) + +開発用ランナー(`btuin dev ...`)を使う場合、ブラウザ DevTools は自動で有効化されます(無効化は `--no-devtools`)。 +また、DevTools の URL をブラウザで自動で開きます(無効化は `--no-open-browser`)。 + +表示された URL をブラウザで開くと、ログとスナップショットが確認できます。 + +スナップショットは **Preview**(レイアウトのボックス + テキスト)と **JSON**(生の payload)の両方で確認できます。 + +コードを変更したくない場合は、環境変数でも有効化できます: + +- `BTUIN_DEVTOOLS=1`(有効化) +- `BTUIN_DEVTOOLS_HOST` / `BTUIN_DEVTOOLS_PORT`(任意) +- `BTUIN_DEVTOOLS_CONTROLLER`(任意 / controller の module spec/path) + +# ホットリロード(開発用ランナー) + +`btuin` の raw 入力処理はプロセス全体で共有されるシングルトンを使っています。そのため、プロセス内で “remount” を繰り返すような HMR(ホットモジュールリロード)的な実装をすると、キー入力ハンドラが積み上がって入力が二重に届くなどの問題が起きます。 + +そこで現状は、開発時のホットリロードは **プロセスを再起動する方式(dev runner)** を推奨します。変更検知で同じターミナル上で TUI を再実行するだけです。 + +## CLI + +```bash +btuin dev [options] [-- ] +``` + +例: + +```bash +btuin dev examples/devtools.ts +btuin dev src/main.ts --watch src --watch examples +btuin dev src/main.ts -- --foo bar +``` + +主なオプション: + +- `--watch `(複数指定可) +- `--debounce `(デフォルト: `50`) +- `--cwd `(デフォルト: `process.cwd()`) +- `--no-tcp`(TCP リロードトリガー無効化) +- `--tcp-host `(デフォルト: `127.0.0.1`) +- `--tcp-port `(デフォルト: `0`) + +## TCPトリガ(任意) + +`btuin dev` はデフォルトで TCP を有効化(ポートは自動選択)します。 + +別ターミナルからトリガ: + +```bash +printf 'reload\n' | nc 127.0.0.1 +``` + +JSONLでもOK: + +```bash +printf '{"type":"reload"}\n' | nc 127.0.0.1 +``` + +注意: ホットリロードは `btuin dev`(dev runner)が適用します。 diff --git a/docs/devtools.md b/docs/devtools.md new file mode 100644 index 0000000..f2e4b79 --- /dev/null +++ b/docs/devtools.md @@ -0,0 +1,65 @@ +# DevTools + +btuin includes a lightweight browser UI for observing your app during TUI development. + +## Browser DevTools (recommended) + +If you use the dev runner (`btuin dev ...`), browser DevTools is auto-enabled (disable with `--no-devtools`). +The runner also auto-opens the DevTools URL in your browser (disable with `--no-open-browser`). + +Open the printed URL in your browser. It shows logs and a snapshot stream. + +The Snapshot view includes a simple **Preview** (layout boxes + text) and a **JSON** view (raw snapshot payload). + +You can also enable it without code by setting env vars: + +- `BTUIN_DEVTOOLS=1` (enable) +- `BTUIN_DEVTOOLS_HOST` / `BTUIN_DEVTOOLS_PORT` (optional) +- `BTUIN_DEVTOOLS_CONTROLLER` (optional; module spec/path for the controller) + +# Hot Reload (Dev Runner) + +`btuin` treats raw terminal input handling as a process-wide singleton. Because of that, doing a true in-process “remount” loop (HMR-style) would accumulate key handlers and lead to duplicated input events. + +Instead, the recommended development workflow is to **restart the app process** on changes (hot reload as a dev runner). It simply re-runs your TUI in the same terminal. + +## CLI + +```bash +btuin dev [options] [-- ] +``` + +Examples: + +```bash +btuin dev examples/devtools.ts +btuin dev src/main.ts --watch src --watch examples +btuin dev src/main.ts -- --foo bar +``` + +Options: + +- `--watch ` (repeatable) +- `--debounce ` (default: `50`) +- `--cwd ` (default: `process.cwd()`) +- `--no-tcp` (disable TCP reload trigger) +- `--tcp-host ` (default: `127.0.0.1`) +- `--tcp-port ` (default: `0`) + +## TCP Trigger (Optional) + +`btuin dev` enables TCP by default (ephemeral port). + +Trigger reload from another terminal: + +```bash +printf 'reload\n' | nc 127.0.0.1 +``` + +Or JSONL: + +```bash +printf '{"type":"reload"}\n' | nc 127.0.0.1 +``` + +Note: hot reload is applied by `btuin dev` (dev runner). diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md index 390f5ee..9d93807 100644 --- a/docs/roadmap.ja.md +++ b/docs/roadmap.ja.md @@ -1,29 +1,49 @@ # ロードマップ -- [x] 入力 - - [x] 入力パーサーをステートフル化(チャンク分割耐性): `src/terminal/parser/ansi.ts` +- [x] **入力** + - [x] 入力パーサーをステートフル化(チャンク分割耐性) - [x] `ESC` 単体 vs `Alt+Key` の曖昧さを解消 - - [x] ブラケットペーストを「1イベント」に正規化: `src/terminal/parser/ansi.ts` + - [x] ブラケットペーストを「1イベント」に正規化 - [x] ブラケットペーストの on/off をランタイムへ統合 -- [ ] マウス - - [ ] マウス入力(SGR など)をランタイムへ統合(有効化/無効化・イベント形式の確定) +- [ ] **マウス** + - [ ] マウス入力(SGR など)をランタイムへ統合 - [ ] ヒットテスト(`ComputedLayout` と座標の照合、重なり順の決定) - [ ] バブリング/伝播(子→親、キャンセル可能なイベントモデル) -- [ ] Developer Tools - - [ ] シェル統合 - - [x] stdout/stderr capture 基盤(listener/console patch/テストモード): `src/terminal/capture.ts` - - [ ] `useLog`(capture → reactive state)でログUIを作る - - [ ] デバッグ - - [ ] インスペクターモード(境界線/座標/サイズ可視化) - - [ ] ホットリロード +- [ ] **Developer Tools** + - [x] シェル統合(stdout/stderr capture, `useLog`) + - [x] デバッグ基盤(インスペクターモード, ホットリロード) + - [ ] **レイアウトエンジンの詳細可視化** + - [x] Flexbox プロパティのインスペクト(`flex-grow`, `padding` 等の表示) + - [x] 計算済みボックスモデルのカラー表示(Margin/Padding の視覚化) + - [x] Z-Index / 階層構造の 3D 可視化 + - [ ] **パフォーマンス・プロファイリング** + - [x] レンダリング・タイムライン(FFI境界・レイアウト計算・Diff生成の計測) + - [x] リアクティビティ・グラフ(どの Ref がどのコンポーネントを更新したかの可視化) + - [ ] **双方向デバッグ** + - [ ] ブラウザ側からのリアクティブ・ステート(Ref)の直接書き換え + - [ ] リモートキーイベント送信(ブラウザ仮想キーボードからの入力注入) +- [ ] **アーキテクチャ・最適化** + - [ ] **FFI通信の効率化** + - [ ] フルシリアライズの回避(Dirty Checking による部分的なレイアウト更新) + - [ ] **大規模描画サポート** + - [ ] 仮想ウィンドウ化(Virtual Scrolling)による数万行のリスト表示 + - [ ] スクロールリージョン(DECSTBM)を活用した高速スクロール + - [ ] **リアクティビティの高度化** + - [ ] Effect Scope の導入(コンポーネントに紐付いた Effect の自動追跡・破棄) +- [ ] **開発体験 (DX) / 大規模開発サポート** + - [ ] **状態共有パターン** + - [ ] `Provide/Inject` または `Context API` 相当の依存注入機能 +- [ ] **安全性・堅牢性** + - [x] FFI 境界の同期テスト + - [ ] 致命的エラー時のセーフティネット(パニック時の Raw Mode 強制解除) +- [ ] **AI・アクセシビリティ** + - [ ] セマンティック・メタデータのサポート(AIエージェントや将来のA11y支援用) +- [ ] コンポーネント + - [ ] `TextInput` を実用レベルへ(編集・カーソル移動・IME) + - [ ] `ScrollView` / `ListView`(仮想スクロール、マウスホイール連動) - [x] 配布 - - [x] GitHub Release 用 tarball 生成(`src/layout-engine/native/` 同梱): `.github/workflows/release.yml` - - [x] `npm pack` の成果物を展開し、`src/layout-engine/native/` と `src/layout-engine/index.ts` の解決が噛み合うことを自動チェック + - [x] GitHub Release 用 tarball 生成 + - [x] npm pack 成果物の整合性チェック - [x] Inline モード -- [ ] コンポーネント - - [ ] `TextInput` を実用レベルへ(編集・カーソル移動・IME確定後の反映) - - [ ] `ScrollView` / `ListView`(必要に応じて仮想スクロール、マウスホイール連動) -- [x] 安全性 - - [x] FFI 境界の同期テスト(Rust 定数/構造体 ↔ JS 定義)を CI に追加 - [ ] ドキュメント / スターター - [ ] `examples/` の拡充 diff --git a/docs/roadmap.md b/docs/roadmap.md deleted file mode 100644 index f68abdc..0000000 --- a/docs/roadmap.md +++ /dev/null @@ -1,29 +0,0 @@ -# Roadmap - -- [x] Input - - [x] Make input parser stateful (resistant to chunk splitting): `src/terminal/parser/ansi.ts` - - [x] Resolve ambiguity between standalone `ESC` vs `Alt+Key` - - [x] Normalize bracketed paste into a single event: `src/terminal/parser/ansi.ts` - - [x] Integrate bracketed paste on/off into the runtime -- [ ] Mouse - - [ ] Integrate mouse input (e.g., SGR) into the runtime (enable/disable, finalize event format) - - [ ] Hit testing (matching coordinates with `ComputedLayout`, determining z-order) - - [ ] Event bubbling/propagation (child to parent, cancellable event model) -- [ ] Developer Tools - - [ ] Shell Integration - - [x] stdout/stderr capture infrastructure (listener/console patch/test mode): `src/terminal/capture.ts` - - [ ] Create a log UI with `useLog` (capture -> reactive state) - - [ ] Debug - - [ ] Inspector mode (visualize borders/coordinates/sizes) - - [ ] Hot Reload -- [x] Distribution - - [x] Generate tarball for GitHub Release (including `src/layout-engine/native/`): `.github/workflows/release.yml` - - [x] Automated check to ensure `npm pack` artifacts work with `src/layout-engine/native/` and `src/layout-engine/index.ts` resolution -- [x] Inline Mode -- [ ] Components - - [ ] Bring `TextInput` to a practical level (editing, cursor movement, IME finalization) - - [ ] `ScrollView` / `ListView` (virtual scrolling as needed, mouse wheel support) -- [x] Safety - - [x] Add FFI boundary sync tests (Rust constants/structs ↔ JS definitions) to CI -- [ ] Documentation / Starter - - [ ] Expand `examples/` diff --git a/examples/counter.ts b/examples/counter.ts index e108fdd..b6a683c 100644 --- a/examples/counter.ts +++ b/examples/counter.ts @@ -1,19 +1,16 @@ -import { createApp, ref, watchEffect } from "@/index"; +import { createApp, ref } from "@/index"; import { Text, VStack } from "@/view"; const app = createApp({ init({ onKey, setExitOutput, runtime }) { const count = ref(0); onKey((k) => { + setExitOutput(count.value.toString()); if (k.name === "up") count.value++; if (k.name === "down") count.value--; if (k.name === "q") runtime.exit(0); }); - watchEffect(() => { - setExitOutput(count.value.toString()); - }); - return { count }; }, render({ count }) { diff --git a/examples/inspector.ts b/examples/inspector.ts new file mode 100644 index 0000000..7ebd7a6 --- /dev/null +++ b/examples/inspector.ts @@ -0,0 +1,265 @@ +import { createApp, ref } from "@/index"; +import { truncateTextWidth, wrapTextWidth } from "@/renderer"; +import { HStack, Text, VStack, ZStack } from "@/view"; +import { Block } from "@/view/primitives"; + +const BOX_W = 24; +const LOG_LIMIT = 18; + +function OutlineBox( + title: string, + children: Parameters[0], + width?: number | string, +) { + const box = VStack(children).outline({ color: 243, style: "single", title }); + if (width !== undefined) box.width(width); + return box; +} + +function Toggle(label: string, active: boolean) { + const el = Text(` ${label} `); + if (active) { + el.background(236).foreground(231); + } + return Block(el); +} + +function TextBox({ + title, + content, + width, + wrap, + fg, + bg, + outlineStyle, +}: { + title: string; + content: string; + width: number; + wrap: boolean; + fg?: string | number; + bg?: string | number; + outlineStyle?: "single" | "double"; +}) { + const innerW = Math.max(1, width - 2); + const lines = wrap ? wrapTextWidth(content, innerW) : [truncateTextWidth(content, innerW)]; + + const header = Text(title).foreground("cyan"); + const body = VStack(lines.map((line) => Text(line))); + + const box = VStack([header, body]) + .width(width) + .padding(0) + .foreground(fg ?? 253) + .outline({ + color: 244, + style: outlineStyle ?? "single", + title, + }); + if (bg !== undefined) { + box.background(bg); + } + return box; +} + +function Tag(label: string, bg: number, fg = 16) { + const el = Text(` ${label} `).background(bg).foreground(fg); + el.setKey(`tag:${label}`); + return el; +} + +const app = createApp({ + profile: { enabled: true }, + init({ onKey, onTick, runtime }) { + const counter = ref(0, "counter"); + const selected = ref<"layout" | "style" | "text">("layout", "selected page"); + const sidebarWide = ref(true, "sidebar wide"); + const logs = ref([], "logs"); + + const addLog = (line: string) => { + const next = logs.value.slice(-LOG_LIMIT + 1); + next.push(line); + logs.value = next; + }; + + onKey((k) => { + if (k.name === "up") counter.value++; + if (k.name === "down") counter.value--; + if (k.name === "1") selected.value = "layout"; + if (k.name === "2") selected.value = "style"; + if (k.name === "3") selected.value = "text"; + if (k.name === "s") sidebarWide.value = !sidebarWide.value; + if (k.name === "l") { + const msg = `[ログ] counter=${counter.value}`; + addLog(msg); + console.log(msg); + } + if (k.name === "e") { + const msg = `[error] counter=${counter.value}`; + addLog(msg); + console.error(msg); + } + if (k.name === "q") runtime.exit(0); + }); + + onTick(() => { + if (counter.value % 10 === 0) { + addLog(`[tick] カウンター=${counter.value}`); + } + if (counter.value % 15 === 0) console.log(`[tick] counter=${counter.value}`); + }, 1000); + + return { counter, selected, sidebarWide, logs }; + }, + + render({ counter, selected, sidebarWide, logs }) { + const sidebarW = sidebarWide.value ? "22%" : "16%"; + const menu = OutlineBox( + "menu", + [ + Text("インスペクター / Inspector").foreground(81), + Text("Keys: 1/2/3 tab"), + Text("s sidebar"), + Text("↑/↓ counter"), + Text("l log e error q quit"), + Text("Tabs / タブ").foreground(81), + Toggle("1 Layout", selected.value === "layout"), + Toggle("2 Style", selected.value === "style"), + Toggle("3 Text", selected.value === "text"), + Text("Tags").foreground(81), + Tag("bg=17", 17), + Tag("bg=52", 52), + Tag("bg=88", 88), + Tag("bg=124", 124), + ], + sidebarW, + ).height("100%"); + menu.style.margin = [0, 0, 0, 0]; + + const header = HStack([ + Text(`counter ${counter.value}`).foreground(229), + Text("btuin DevTools / Inspector").foreground(81), + Text("状態: 実行中 / running").foreground(247), + Tag("stack=z", 90, 16), + ]) + + .outline({ color: 243, style: "single", title: "status" }); + header.style.margin = [0, 0, 0, 0]; + + const complexLayout = OutlineBox( + "layout panel", + [ + Text("複雑レイアウト / Complex layout").foreground(81), + HStack([ + OutlineBox("fixed", [Text("Fixed 8x3"), Text("固定幅")], 8).height(3), + Block(Text("Grow flex=1")) + .grow(1) + .height(3) + .outline({ color: 244, style: "single", title: "grow" }), + Block(Text("Abs overlay")).width(10).height(3), + ]), + ZStack([ + Block(Text("ZStack base")).width(20).height(5), + Block(Text("Overlay A")) + .width(10) + .height(3) + .outline({ color: 244, style: "double", title: "overlay" }), + Block(Text("Overlay B")) + .width(8) + .height(2) + .outline({ color: 245, style: "single", title: "overlay b" }), + ]) + .width(22) + .height(5) + .outline({ color: 244, style: "single", title: "zstack" }), + ], + undefined, + ); + + const diverseStyle = OutlineBox( + "style panel", + [ + Text("多様なスタイル / Diverse style").foreground(81), + HStack([ + Block(Text("padding=[1,2,1,2]")) + .padding([1, 2, 1, 2]) + .outline({ color: 244, style: "single", title: "pad array" }), + Block(Text("bg=magenta fg=white")) + .padding(1) + .background("magenta") + .foreground("white") + .outline({ color: "cyan", style: "double", title: "named colors" }), + ]), + (() => { + const wrapRow = Block( + Tag("flexWrap=wrap", 24, 231), + Tag("tag", 31, 231), + Tag("long-tag", 32, 231), + Tag("日本語", 33, 231), + Tag("emoji👩🏽‍💻", 34, 231), + Tag("1234567890", 35, 231), + ) + .direction("row") + .width("100%") + .outline({ color: 244, style: "single", title: "flexWrap (visual)" }); + wrapRow.style.flexWrap = "wrap"; + return wrapRow; + })(), + ], + undefined, + ); + + const mixedText = + "ASCII: The quick brown fox jumps over the lazy dog. / " + + "CJK: 日本語の文章と漢字、かな、カナ。 / " + + "Emoji: 👩🏽‍💻🧪✨ / " + + "Wide: WIDE TEXT / " + + "Mixed: hello世界123"; + + const textPanel = OutlineBox("text panel", [ + Text("文字 / Text (ASCII + CJK + emoji)").foreground(81), + VStack([ + TextBox({ + title: "No wrap / truncate", + content: mixedText, + width: BOX_W, + wrap: false, + fg: 253, + outlineStyle: "single", + }).setKey("textbox:nowrap"), + TextBox({ + title: "Wrap / wrapTextWidth", + content: mixedText, + width: BOX_W, + wrap: true, + fg: 253, + outlineStyle: "double", + }).setKey("textbox:wrap"), + ]), + ]); + + const content = + selected.value === "layout" + ? complexLayout + : selected.value === "style" + ? diverseStyle + : textPanel; + + const logsBox = OutlineBox( + "logs", + [ + Text("ログ / Logs").foreground(81), + ...logs.value.map((line, idx) => Text(`${String(idx + 1).padStart(2, "0")} ${line}`)), + ], + "24%", + ).height("100%"); + + const main = VStack([header, content.grow(1)]) + .grow(1) + .height("100%"); + + return HStack([menu, main, logsBox]).height("100%"); + }, +}); + +await app.mount(); diff --git a/mise.toml b/mise.toml index fa29bd0..5e0fce9 100644 --- a/mise.toml +++ b/mise.toml @@ -1,20 +1,21 @@ [tools] -bun="1.3.5" +bun = "1.3.5" "npm:oxfmt" = "latest" "npm:oxlint" = "latest" -rust="1.92.0" +rust = "1.92.0" [tasks] -"start"="bun run" +"start" = "bun run" "build:ffi" = "cd src/layout-engine && cargo build --release" -"lint"="oxlint" -"lint:fix"="oxlint --fix" -"format"="oxfmt" -"check"="bunx tsc --noEmit" -"precommit"="mise run format && mise run lint:fix && mise run check" -"test"="bun test" -"test:watch"="bun test --watch" -"profiler"="bun test ./scripts/profiler*.spec.ts" -"profiler:stress"="bun test ./scripts/profiler-stress.spec.ts" -"profiler:layout"="bun test ./scripts/profiler-layout.spec.ts" -"profiler:limit"="bun test ./scripts/profiler-limit.spec.ts" +"lint" = "oxlint" +"lint:fix" = "oxlint --fix" +"format" = "oxfmt" +"check" = "bunx tsc --noEmit" +"precommit" = "mise run format && mise run lint:fix && mise run check" +"test" = "bun test" +"test:watch" = "bun test --watch" +"profiler" = "bun test ./scripts/profiler*.spec.ts" +"profiler:stress" = "bun test ./scripts/profiler-stress.spec.ts" +"profiler:layout" = "bun test ./scripts/profiler-layout.spec.ts" +"profiler:limit" = "bun test ./scripts/profiler-limit.spec.ts" +"package:dev" = "bun run ./scripts/package-dev.ts" diff --git a/package.json b/package.json index 9f968e2..e9ab798 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,16 @@ { "name": "btuin", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "private": false, "repository": { "type": "git", "url": "git+https://github.com/HALQME/btuin.git" }, + "bin": { + "btuin": "bin/btuin" + }, "files": [ + "bin", "README.md", "src", "tsconfig.json" @@ -15,6 +19,8 @@ "exports": { ".": "./src/index.ts", "./components": "./src/components/index.ts", + "./dev": "./src/dev/index.ts", + "./hooks": "./src/hooks/index.ts", "./layout": "./src/layout/index.ts", "./layout-engine": "./src/layout-engine/index.ts", "./reactivity": "./src/reactivity/index.ts", diff --git a/scripts/package-dev.ts b/scripts/package-dev.ts new file mode 100644 index 0000000..161ee1f --- /dev/null +++ b/scripts/package-dev.ts @@ -0,0 +1,145 @@ +import { cp, mkdir, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +const RELEASE_MATRIX = [ + { os: "linux", arch: "x64", suffix: "so" }, + { os: "darwin", arch: "arm64", suffix: "dylib" }, +]; + +const PLATFORM_SUFFIX: Record = { + darwin: "dylib", + linux: "so", + win32: "dll", +}; + +const PKG_ROOT = path.resolve(import.meta.dir, ".."); +const DEFAULT_OUTDIR = path.join(PKG_ROOT, ".tmp", "package-dev"); + +type Options = { + outdir: string; + currentOnly: boolean; +}; + +function printHelp() { + process.stderr.write( + [ + "Usage:", + " bun run scripts/package-dev.ts [options]", + "", + "Options:", + " --outdir Output directory (default: .tmp/package-dev)", + " --current-only Only include the current platform binary", + " -h, --help Show help", + "", + ].join("\n"), + ); +} + +function parseArgs(argv: string[]): Options { + let outdir = DEFAULT_OUTDIR; + let currentOnly = false; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + + if (a === "--outdir") { + const v = argv[i + 1]; + if (!v || v.startsWith("-")) { + throw new Error("[btuin] missing value for --outdir"); + } + outdir = path.resolve(PKG_ROOT, v); + i++; + continue; + } + + if (a === "--current-only") { + currentOnly = true; + continue; + } + + if (a === "-h" || a === "--help") { + printHelp(); + process.exit(0); + } + + throw new Error(`[btuin] unknown option: ${a}`); + } + + return { outdir, currentOnly }; +} + +async function run(command: string, args: string[], cwd: string) { + const proc = Bun.spawn([command, ...args], { + cwd, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`Command failed (${exitCode}): ${command} ${args.join(" ")}`); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + + if (existsSync(options.outdir)) { + await rm(options.outdir, { recursive: true, force: true }); + } + await mkdir(options.outdir, { recursive: true }); + + console.log("Building FFI library for current platform..."); + await run("mise", ["run", "build:ffi"], PKG_ROOT); + + console.log("Copying package files..."); + const pkgJsonPath = path.join(PKG_ROOT, "package.json"); + const pkgJson = JSON.parse(await Bun.file(pkgJsonPath).text()) as { files?: string[] }; + + await cp(pkgJsonPath, path.join(options.outdir, "package.json")); + const filesToCopy = pkgJson.files ?? []; + for (const file of filesToCopy) { + await cp(path.join(PKG_ROOT, file), path.join(options.outdir, file), { recursive: true }); + } + + const targetDir = path.join(options.outdir, "src/layout-engine/target"); + if (existsSync(targetDir)) { + await rm(targetDir, { recursive: true, force: true }); + } + + console.log("Installing native binaries..."); + const nativeDir = path.join(options.outdir, "src/layout-engine/native"); + await mkdir(nativeDir, { recursive: true }); + + const currentPlatform = process.platform; + const currentArch = process.arch; + const builtBinaryName = `liblayout_engine.${PLATFORM_SUFFIX[currentPlatform] ?? "so"}`; + const builtBinaryPath = path.join(PKG_ROOT, "src/layout-engine/target/release", builtBinaryName); + + if (!existsSync(builtBinaryPath)) { + throw new Error(`Could not find built binary at ${builtBinaryPath}`); + } + + for (const target of RELEASE_MATRIX) { + if (options.currentOnly && (target.os !== currentPlatform || target.arch !== currentArch)) { + continue; + } + + const binaryName = `liblayout_engine-${target.os}-${target.arch}.${target.suffix}`; + const binaryPathInPkg = path.join(nativeDir, binaryName); + + if (target.os === currentPlatform && target.arch === currentArch) { + await cp(builtBinaryPath, binaryPathInPkg); + console.log(`Copied actual binary: ${binaryName}`); + continue; + } + + await Bun.write(binaryPathInPkg, `dummy for ${target.os}-${target.arch}`); + console.log(`Created dummy binary: ${binaryName}`); + } + + console.log(`Package staged at ${options.outdir}`); +} + +await main(); diff --git a/scripts/profiler-limit.spec.ts b/scripts/profiler-limit.spec.ts index bbf10d0..b94f4ba 100644 --- a/scripts/profiler-limit.spec.ts +++ b/scripts/profiler-limit.spec.ts @@ -100,7 +100,7 @@ describe("Scalability Limit Test (Statistical)", async () => { thresholds.forEach((t) => (results[t.fps] = [])); for (let i = 0; i < ITERATIONS; i++) { - Bun.stdout.write(` Run ${i + 1}/${ITERATIONS}... `); + process.stdout.write(` Run ${i + 1}/${ITERATIONS}... `); Bun.gc(true); diff --git a/src/cli/commands/build.ts b/src/cli/commands/build.ts new file mode 100644 index 0000000..a938f39 --- /dev/null +++ b/src/cli/commands/build.ts @@ -0,0 +1,195 @@ +import { isAbsolute, resolve, dirname, join } from "node:path"; +import { suffix } from "bun:ffi"; +import { existsSync } from "node:fs"; +import { copyFile, mkdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import type { Command } from "./command"; + +type SourcemapMode = "none" | "inline" | "external"; + +export interface BuildOptions { + entry: string; + cwd: string; + outdir: string; + minify: boolean; + sourcemap: SourcemapMode; +} + +function resolveLayoutEngineBinary(): { source: string; binName: string } { + const cliDir = dirname(fileURLToPath(import.meta.url)); + const layoutEngineDir = resolve(cliDir, "..", "layout-engine"); + const platform = process.platform; + const arch = process.arch; + const binName = `liblayout_engine-${platform}-${arch}.${suffix}`; + + const packaged = join(layoutEngineDir, "native", binName); + if (existsSync(packaged)) return { source: packaged, binName }; + + const dev = join(layoutEngineDir, "target", "release", `liblayout_engine.${suffix}`); + if (existsSync(dev)) return { source: dev, binName }; + + throw new Error( + `[btuin] layout engine binary not found. Looked for ${packaged} or ${dev}. ` + + `Run "mise run build:ffi" or install a prebuilt package.`, + ); +} + +export async function runBuild(options: BuildOptions) { + const result = await Bun.build({ + entrypoints: [options.entry], + outdir: options.outdir, + target: "bun", + format: "esm", + splitting: false, + minify: options.minify, + sourcemap: options.sourcemap, + }); + + if (!result.success) { + for (const log of result.logs) { + process.stderr.write(`${log}\n`); + } + process.exitCode = 1; + return; + } + + const entryOutput = + result.outputs.find((output) => output.kind === "entry-point") ?? result.outputs[0]; + const outputDir = entryOutput ? dirname(entryOutput.path) : options.outdir; + + const { source, binName } = resolveLayoutEngineBinary(); + const nativeDir = join(outputDir, "native"); + await mkdir(nativeDir, { recursive: true }); + const dest = join(nativeDir, binName); + await copyFile(source, dest); + + process.stderr.write(`[btuin] build output: ${outputDir}\n`); + process.stderr.write(`[btuin] layout engine: ${dest}\n`); +} + +interface BuildParsed { + entry: string; + cwd?: string; + outdir: string; + minify: boolean; + sourcemap: "none" | "inline" | "external"; +} + +function takeFlagValue(argv: string[], idx: number, flagName: string): string { + const value = argv[idx + 1]; + if (!value || value.startsWith("-")) { + throw new Error(`[btuin] missing value for ${flagName}`); + } + return value; +} + +function toAbsolutePath(cwd: string, p: string): string { + return isAbsolute(p) ? p : resolve(cwd, p); +} + +function parseBuildArgs(argv: string[]): BuildParsed { + let entry: string | null = null; + let outdir = "dist"; + let minify = false; + let sourcemap: "none" | "inline" | "external" = "external"; + let cwd: string | undefined; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + + if (a === "--outdir") { + const v = takeFlagValue(argv, i, "--outdir"); + outdir = v; + i++; + continue; + } + + if (a === "--minify") { + minify = true; + continue; + } + + if (a === "--sourcemap") { + const v = takeFlagValue(argv, i, "--sourcemap"); + if (v !== "none" && v !== "inline" && v !== "external") { + throw new Error(`[btuin] invalid --sourcemap: ${v}`); + } + sourcemap = v; + i++; + continue; + } + + if (a === "--cwd") { + const v = takeFlagValue(argv, i, "--cwd"); + cwd = v; + i++; + continue; + } + + if (a.startsWith("-")) { + throw new Error(`[btuin] unknown option: ${a}`); + } + + if (!entry) { + entry = a; + continue; + } + + throw new Error(`[btuin] unexpected extra argument: ${a}`); + } + + if (!entry) throw new Error("[btuin] missing entry path (e.g. btuin build src/main.ts)"); + + return { + entry, + cwd, + outdir, + minify, + sourcemap, + }; +} + +export const buildCommand: Command = { + name: "build", + summary: "Bundle an app", + help: { + usage: " [options]", + examples: ["btuin build src/main.ts --outdir dist"], + options: [ + { + flags: ["--outdir"], + value: "", + description: "Build output directory", + defaultValue: "dist", + }, + { flags: ["--minify"], description: "Minify bundled output" }, + { + flags: ["--sourcemap"], + value: "", + description: "Sourcemap: none | inline | external", + defaultValue: "external", + }, + { + flags: ["--cwd"], + value: "", + description: "Child working directory", + defaultValue: "process.cwd()", + }, + { flags: ["-h", "--help"], description: "Show help" }, + ], + }, + parse: parseBuildArgs, + run: async (parsed: BuildParsed) => { + const cwd = parsed.cwd ? resolve(process.cwd(), parsed.cwd) : process.cwd(); + const entryAbs = toAbsolutePath(cwd, parsed.entry); + const outdirAbs = toAbsolutePath(cwd, parsed.outdir); + + await runBuild({ + cwd, + entry: entryAbs, + outdir: outdirAbs, + minify: parsed.minify, + sourcemap: parsed.sourcemap, + }); + }, +}; diff --git a/src/cli/commands/command.ts b/src/cli/commands/command.ts new file mode 100644 index 0000000..d40db8e --- /dev/null +++ b/src/cli/commands/command.ts @@ -0,0 +1,68 @@ +export type OptionSpec = { + flags: string[]; + value?: string; + description: string; + defaultValue?: string; +}; + +export type HelpInfo = { + usage: string; + examples?: string[]; + options?: OptionSpec[]; + notes?: string[]; +}; + +export type Command = { + name: string; + summary: string; + help: HelpInfo; + parse: (argv: string[]) => TParsed; + run: (parsed: TParsed) => Promise | void; +}; + +function formatOptions(options: OptionSpec[]): string[] { + const formatted = options.map((option) => { + const flags = option.flags.join(", ") + (option.value ? ` ${option.value}` : ""); + const description = option.defaultValue + ? `${option.description} (default: ${option.defaultValue})` + : option.description; + return { flags, description }; + }); + + const maxWidth = formatted.reduce((width, option) => Math.max(width, option.flags.length), 0); + return formatted.map((option) => ` ${option.flags.padEnd(maxWidth + 2)}${option.description}`); +} + +export function formatCommandHelp(commandName: string, help: HelpInfo): string[] { + const lines: string[] = []; + lines.push("Usage:"); + lines.push(` btuin ${commandName} ${help.usage}`); + + if (help.examples && help.examples.length > 0) { + lines.push(""); + lines.push("Examples:"); + for (const example of help.examples) { + lines.push(` ${example}`); + } + } + + if (help.options && help.options.length > 0) { + lines.push(""); + lines.push("Options:"); + lines.push(...formatOptions(help.options)); + } + + if (help.notes && help.notes.length > 0) { + lines.push(""); + for (const note of help.notes) { + lines.push(note); + } + } + + return lines; +} + +export function formatCommandList(commands: Array<{ name: string; summary: string }>): string[] { + const maxWidth = commands.reduce((width, cmd) => Math.max(width, cmd.name.length), 0); + return commands.map((cmd) => ` ${cmd.name.padEnd(maxWidth + 2)}${cmd.summary}`); +} diff --git a/src/cli/commands/dev.ts b/src/cli/commands/dev.ts new file mode 100644 index 0000000..2c514fb --- /dev/null +++ b/src/cli/commands/dev.ts @@ -0,0 +1,323 @@ +import { isAbsolute, resolve, dirname, join } from "node:path"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { runHotReloadProcess } from "../hot-reload"; +import type { Command } from "./command"; + +type DevtoolsEnv = Record; + +export interface DevCommandOptions { + entry: string; + cwd: string; + watch: string[]; + debounceMs?: number; + devtools: { enabled: boolean }; + openBrowser: boolean; + tcp: { enabled: false } | { enabled: true; host?: string; port?: number }; + childArgs: string[]; +} + +export function runDev(options: DevCommandOptions) { + const entryAbs = options.entry; + const watchPaths = (() => { + const out: string[] = []; + const add = (p: string) => { + const abs = resolve(options.cwd, p); + if (!out.includes(abs)) out.push(abs); + }; + + if (options.watch.length > 0) { + for (const p of options.watch) add(p); + return out; + } + + const srcDir = join(options.cwd, "src"); + if (existsSync(srcDir)) add(srcDir); + + const entryDir = dirname(entryAbs); + if (existsSync(entryDir)) out.push(entryDir); + + if (out.length === 0) out.push(options.cwd); + return out; + })(); + + let tcp: + | undefined + | { + host?: string; + port?: number; + onListen: (info: { host: string; port: number }) => void; + } = undefined; + + if (options.tcp.enabled) { + tcp = { + host: options.tcp.host ?? "127.0.0.1", + port: options.tcp.port ?? 0, + onListen: ({ host, port }) => { + process.stderr.write(`[btuin] hot-reload tcp: ${host}:${port}\n`); + process.stderr.write(`[btuin] trigger: printf 'reload\\n' | nc ${host} ${port}\n`); + }, + }; + } + + const devtoolsEnv: DevtoolsEnv = (() => { + if (!options.devtools.enabled) { + return { + BTUIN_DEVTOOLS: undefined, + BTUIN_DEVTOOLS_HOST: undefined, + BTUIN_DEVTOOLS_PORT: undefined, + BTUIN_DEVTOOLS_CONTROLLER: undefined, + }; + } + + const env: DevtoolsEnv = { BTUIN_DEVTOOLS: "1" }; + + try { + env.BTUIN_DEVTOOLS_CONTROLLER = fileURLToPath( + new URL("../devtools/controller.ts", import.meta.url), + ); + } catch { + // ignore + } + + const host = process.env.BTUIN_DEVTOOLS_HOST ?? "127.0.0.1"; + const portFromEnv = process.env.BTUIN_DEVTOOLS_PORT; + if (!process.env.BTUIN_DEVTOOLS_HOST) env.BTUIN_DEVTOOLS_HOST = host; + + if (!portFromEnv) { + try { + const listener = Bun.listen({ + hostname: host, + port: 0, + socket: { + open() {}, + data() {}, + close() {}, + error() {}, + }, + }); + const port = listener.port; + try { + listener.stop(true); + } catch { + // ignore + } + env.BTUIN_DEVTOOLS_PORT = String(port); + env.BTUIN_DEVTOOLS_HOST = host; + } catch { + // ignore + } + } + + return env; + })(); + + runHotReloadProcess({ + command: "bun", + args: [entryAbs, ...options.childArgs], + cwd: options.cwd, + watch: { paths: watchPaths, debounceMs: options.debounceMs }, + env: devtoolsEnv, + openDevtoolsBrowser: options.openBrowser && options.devtools.enabled, + tcp: options.tcp.enabled + ? { + host: tcp!.host, + port: tcp!.port, + onListen: tcp!.onListen, + } + : undefined, + }); +} + +interface DevParsed { + entry: string; + childArgs: string[]; + cwd?: string; + watch: string[]; + debounceMs?: number; + devtools: { enabled: boolean }; + openBrowser: boolean; + tcp: { enabled: false } | { enabled: true; host?: string; port?: number }; +} + +function takeFlagValue(argv: string[], idx: number, flagName: string): string { + const value = argv[idx + 1]; + if (!value || value.startsWith("-")) { + throw new Error(`[btuin] missing value for ${flagName}`); + } + return value; +} + +function toAbsolutePath(cwd: string, p: string): string { + return isAbsolute(p) ? p : resolve(cwd, p); +} + +function parseDevArgs(argv: string[]): DevParsed { + let entry: string | null = null; + const childArgs: string[] = []; + const watch: string[] = []; + let debounceMs: number | undefined; + let cwd: string | undefined; + let tcpEnabled = true; + let tcpHost: string | undefined; + let tcpPort: number | undefined; + let devtoolsEnabled = true; + let openBrowser = true; + + let passthrough = false; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + if (a === "--") { + passthrough = true; + continue; + } + + if (passthrough) { + childArgs.push(a); + continue; + } + + if (a === "--watch") { + const v = takeFlagValue(argv, i, "--watch"); + watch.push(v); + i++; + continue; + } + + if (a === "--debounce") { + const v = takeFlagValue(argv, i, "--debounce"); + const n = Number(v); + if (!Number.isFinite(n) || n < 0) throw new Error(`[btuin] invalid --debounce: ${v}`); + debounceMs = n; + i++; + continue; + } + + if (a === "--cwd") { + const v = takeFlagValue(argv, i, "--cwd"); + cwd = v; + i++; + continue; + } + + if (a === "--no-tcp") { + tcpEnabled = false; + continue; + } + + if (a === "--no-devtools") { + devtoolsEnabled = false; + continue; + } + + if (a === "--no-open-browser") { + openBrowser = false; + continue; + } + + if (a === "--tcp-host") { + const v = takeFlagValue(argv, i, "--tcp-host"); + tcpHost = v; + i++; + continue; + } + + if (a === "--tcp-port") { + const v = takeFlagValue(argv, i, "--tcp-port"); + const n = Number(v); + if (!Number.isInteger(n) || n < 0 || n > 65535) + throw new Error(`[btuin] invalid --tcp-port: ${v}`); + tcpPort = n; + i++; + continue; + } + + if (a.startsWith("-")) { + throw new Error(`[btuin] unknown option: ${a}`); + } + + if (!entry) { + entry = a; + continue; + } + + // Treat extra args as passthrough to the child. + childArgs.push(a); + } + + if (!entry) throw new Error("[btuin] missing entry path (e.g. btuin dev examples/devtools.ts)"); + + return { + entry, + childArgs, + cwd, + watch, + debounceMs, + devtools: { enabled: devtoolsEnabled }, + openBrowser, + tcp: tcpEnabled ? { enabled: true, host: tcpHost, port: tcpPort } : { enabled: false }, + }; +} + +export const devCommand: Command = { + name: "dev", + summary: "Run with hot reload", + help: { + usage: " [options] [-- ]", + examples: [ + "btuin dev examples/devtools.ts", + "btuin dev src/main.ts --watch src --watch examples", + "btuin dev src/main.ts -- --foo bar", + ], + options: [ + { flags: ["--watch"], value: "", description: "Add watch path (repeatable)" }, + { + flags: ["--debounce"], + value: "", + description: "Debounce fs events", + defaultValue: "50", + }, + { + flags: ["--cwd"], + value: "", + description: "Child working directory", + defaultValue: "process.cwd()", + }, + { flags: ["--no-devtools"], description: "Disable browser DevTools auto-enable" }, + { flags: ["--no-open-browser"], description: "Do not auto-open DevTools URL in browser" }, + { flags: ["--no-tcp"], description: "Disable TCP reload trigger" }, + { + flags: ["--tcp-host"], + value: "", + description: "TCP bind host", + defaultValue: "127.0.0.1", + }, + { + flags: ["--tcp-port"], + value: "", + description: "TCP bind port", + defaultValue: "0", + }, + { flags: ["-h", "--help"], description: "Show help" }, + ], + }, + parse: parseDevArgs, + run: (parsed: DevParsed) => { + const cwd = parsed.cwd ? resolve(process.cwd(), parsed.cwd) : process.cwd(); + const entryAbs = toAbsolutePath(cwd, parsed.entry); + + runDev({ + entry: entryAbs, + cwd, + watch: parsed.watch, + debounceMs: parsed.debounceMs, + devtools: parsed.devtools, + openBrowser: parsed.openBrowser, + tcp: parsed.tcp, + childArgs: parsed.childArgs, + }); + + // Keep the CLI alive while the child runs. + return new Promise(() => {}); + }, +}; diff --git a/src/cli/hot-reload.ts b/src/cli/hot-reload.ts new file mode 100644 index 0000000..a42bf72 --- /dev/null +++ b/src/cli/hot-reload.ts @@ -0,0 +1,455 @@ +import { watch, type FSWatcher } from "node:fs"; + +export interface HotReloadWatchOptions { + /** + * Paths to watch. Can be files or directories. + */ + paths: string[]; + + /** + * Debounce file change events. + * @default 50 + */ + debounceMs?: number; +} + +export interface HotReloadTcpOptions { + /** + * Bind host. + * @default "127.0.0.1" + */ + host?: string; + + /** + * Bind port. Use 0 to pick an ephemeral port. + * @default 0 + */ + port?: number; + + /** + * Called after the server starts listening (useful when `port: 0`). + */ + onListen?: (info: { host: string; port: number }) => void; +} + +export interface RunHotReloadProcessOptions { + /** + * Command to run your TUI entry (typically "bun"). + */ + command: string; + + /** + * Command arguments (e.g. ["run", "examples/devtools.ts"]). + */ + args?: string[]; + + /** + * Watch paths that trigger restarts. + */ + watch: HotReloadWatchOptions; + + /** + * Optional TCP reload server: send "reload\\n" (or JSONL {"type":"reload"}). + */ + tcp?: HotReloadTcpOptions; + + /** + * Working directory for the child process. + * @default process.cwd() + */ + cwd?: string; + + /** + * Extra env vars for the child process. + */ + env?: Record; + + /** + * Auto-open the DevTools browser URL when the child reports it over IPC. + * @default true + */ + openDevtoolsBrowser?: boolean; + + /** + * Preserve state across restarts (opt-in from the app via `enableHotReloadState`). + * @default true + */ + preserveState?: boolean; + + /** + * Signal used when restarting the child. + * @default "SIGTERM" + */ + restartSignal?: NodeJS.Signals; + + /** + * Force-kill timeout when restarting. + * @default 1500 + */ + restartTimeoutMs?: number; +} + +export interface HotReloadProcessHandle { + /** + * Restart the child process. + */ + restart(): Promise; + /** + * Stop watchers, stop TCP server, and terminate the child process. + */ + close(): Promise; + /** + * Get whether a child is currently running. + */ + isRunning(): boolean; +} + +type TcpReloadMessage = "reload" | { type: "reload" }; +type HotReloadIpcMessage = + | { type: "btuin:hot-reload:request-snapshot" } + | { type: "btuin:hot-reload:snapshot"; snapshot: unknown } + | { type: "btuin:devtools:listen"; info: { host: string; port: number; url: string } }; + +const SNAPSHOT_ENV_KEY = "BTUIN_HOT_RELOAD_SNAPSHOT"; + +function parseTcpReloadMessage(line: string): TcpReloadMessage | null { + const trimmed = line.trim(); + if (trimmed === "reload") return "reload"; + + try { + const parsed: unknown = JSON.parse(trimmed); + if ( + typeof parsed === "object" && + parsed !== null && + "type" in parsed && + (parsed as any).type === "reload" + ) { + return { type: "reload" }; + } + } catch { + // ignore + } + return null; +} + +function encodeSnapshot(snapshot: unknown): string | null { + try { + return Buffer.from(JSON.stringify(snapshot), "utf8").toString("base64"); + } catch { + return null; + } +} + +function createDebouncedCallback(cb: () => void, debounceMs: number) { + let timer: ReturnType | null = null; + return () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + cb(); + }, debounceMs); + }; +} + +function createWatchers(watchOptions: HotReloadWatchOptions, onChange: () => void): () => void { + const debounceMs = watchOptions.debounceMs ?? 50; + const trigger = createDebouncedCallback(onChange, debounceMs); + + const watchers: FSWatcher[] = []; + for (const p of watchOptions.paths) { + try { + watchers.push(watch(p, { recursive: true }, trigger)); + } catch { + try { + watchers.push(watch(p, trigger)); + } catch { + // ignore + } + } + } + + return () => { + for (const w of watchers.splice(0)) { + try { + w.close(); + } catch { + // ignore + } + } + }; +} + +export interface TcpReloadServerHandle { + close(): Promise; +} + +export function createTcpReloadServer( + options: HotReloadTcpOptions, + onReload: () => void, +): TcpReloadServerHandle { + const host = options.host ?? "127.0.0.1"; + const port = options.port ?? 0; + + let listener: Bun.TCPSocketListener<{ buf: string }> | null = null; + try { + listener = Bun.listen({ + hostname: host, + port, + data: { buf: "" }, + socket: { + open(socket) { + socket.data = { buf: "" }; + }, + data(socket, data) { + const chunk = Buffer.isBuffer(data) ? data.toString("utf8") : String(data); + socket.data.buf += chunk; + for (;;) { + const idx = socket.data.buf.indexOf("\n"); + if (idx < 0) break; + const line = socket.data.buf.slice(0, idx); + socket.data.buf = socket.data.buf.slice(idx + 1); + const msg = parseTcpReloadMessage(line); + if (msg) onReload(); + } + }, + error() { + // ignore + }, + close() { + // ignore + }, + }, + }); + + try { + options.onListen?.({ host: listener.hostname, port: listener.port }); + } catch { + // ignore + } + } catch { + // ignore + } + + return { + close: () => + new Promise((resolve) => { + try { + listener?.stop(true); + resolve(); + } catch { + resolve(); + } + }), + }; +} + +async function stopChild( + child: ReturnType | null, + signal: NodeJS.Signals, + timeoutMs: number, +): Promise { + if (!child) return; + + try { + child.kill(signal); + } catch { + // ignore + } + + const didExit = await Promise.race([ + child.exited.then(() => true).catch(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]); + + if (!didExit) { + try { + child.kill("SIGKILL"); + } catch { + // ignore + } + await child.exited.catch(() => {}); + } +} + +export function runHotReloadProcess(options: RunHotReloadProcessOptions): HotReloadProcessHandle { + const cmd = [options.command, ...(options.args ?? [])]; + const restartSignal = options.restartSignal ?? "SIGTERM"; + const restartTimeoutMs = options.restartTimeoutMs ?? 1500; + const preserveState = options.preserveState ?? true; + const openDevtoolsBrowser = options.openDevtoolsBrowser ?? true; + + let closing = false; + let restarting = false; + let child: ReturnType | null = null; + let disposeWatchers: (() => void) | null = null; + let tcpServer: TcpReloadServerHandle | null = null; + let lastSnapshot: unknown = null; + let snapshotWaiter: ((snapshot: unknown) => void) | null = null; + let lastOpenedDevtoolsUrl: string | null = null; + + const close = async () => { + if (closing) return; + closing = true; + + process.off("SIGINT", onSigint); + + disposeWatchers?.(); + disposeWatchers = null; + + await tcpServer?.close(); + tcpServer = null; + + await stopChild(child, restartSignal, restartTimeoutMs); + child = null; + }; + + const restart = async () => { + if (closing) return; + if (restarting) return; + restarting = true; + + if (preserveState && child && typeof (child as any).send === "function") { + try { + const p = new Promise((resolve) => { + snapshotWaiter = resolve; + }); + (child as any).send({ + type: "btuin:hot-reload:request-snapshot", + } satisfies HotReloadIpcMessage); + const snapshot = await Promise.race([ + p, + new Promise((resolve) => setTimeout(() => resolve(null), 200)), + ]); + if (snapshot !== null) lastSnapshot = snapshot; + } catch { + // ignore + } finally { + snapshotWaiter = null; + } + } + + const prev = child; + child = null; + await stopChild(prev, restartSignal, restartTimeoutMs); + restarting = false; + start(); + }; + + const onSigint = () => void close().finally(() => process.exit(0)); + process.once("SIGINT", onSigint); + + const start = () => { + if (closing) return; + + const env: Record = { ...process.env } as Record; + if (options.env) { + for (const [key, value] of Object.entries(options.env)) { + if (value === undefined) { + delete env[key]; + } else { + env[key] = value; + } + } + } + + if (preserveState && lastSnapshot !== null) { + const encoded = encodeSnapshot(lastSnapshot); + if (encoded) env[SNAPSHOT_ENV_KEY] = encoded; + } + + child = Bun.spawn({ + cmd, + cwd: options.cwd ?? process.cwd(), + env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + serialization: "json", + ipc: (message) => { + const m = message as HotReloadIpcMessage; + if ( + m && + typeof m === "object" && + "type" in m && + (m as any).type === "btuin:devtools:listen" + ) { + const info = (m as any).info as { host?: unknown; port?: unknown; url?: unknown }; + if ( + info && + typeof info === "object" && + typeof info.url === "string" && + typeof info.host === "string" && + typeof info.port === "number" + ) { + try { + Bun.stderr.write(`[btuin] devtools: ${info.url}\n`); + } catch { + // ignore + } + if (openDevtoolsBrowser && lastOpenedDevtoolsUrl !== info.url) { + lastOpenedDevtoolsUrl = info.url; + try { + openUrlInBrowser(info.url); + } catch { + // ignore + } + } + } + return; + } + if ( + m && + typeof m === "object" && + "type" in m && + (m as any).type === "btuin:hot-reload:snapshot" + ) { + snapshotWaiter?.((m as any).snapshot); + } + }, + }); + + child.exited + .then((code) => { + if (closing) return; + if (restarting) return; + void close().finally(() => process.exit(code ?? 0)); + }) + .catch(() => { + if (closing) return; + if (restarting) return; + void close().finally(() => process.exit(1)); + }); + }; + + disposeWatchers = createWatchers(options.watch, () => void restart()); + tcpServer = options.tcp ? createTcpReloadServer(options.tcp, () => void restart()) : null; + + start(); + + return { + restart, + close, + isRunning: () => child !== null, + }; +} + +function openUrlInBrowser(url: string) { + const platform = process.platform; + const cmd = + platform === "darwin" + ? ["open", url] + : platform === "win32" + ? ["cmd", "/c", "start", "", url] + : ["xdg-open", url]; + + try { + Bun.spawn({ + cmd, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + } catch { + // ignore + } +} diff --git a/src/cli/main.ts b/src/cli/main.ts new file mode 100644 index 0000000..cfca059 --- /dev/null +++ b/src/cli/main.ts @@ -0,0 +1,104 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { buildCommand } from "./commands/build"; +import { devCommand } from "./commands/dev"; +import { + formatCommandHelp, + formatCommandList, + type Command, + type HelpInfo, +} from "./commands/command"; + +type CommandDefinition = { + name: string; + summary: string; + help: HelpInfo; + parse: (argv: string[]) => unknown; + run: (parsed: unknown) => Promise | void; +}; + +function toCommand(command: Command): CommandDefinition { + return { + name: command.name, + summary: command.summary, + help: command.help, + parse: command.parse, + run: (parsed) => command.run(parsed as TParsed), + }; +} + +const commands: CommandDefinition[] = [toCommand(devCommand), toCommand(buildCommand)]; + +function printHelp() { + const list = formatCommandList(commands); + process.stderr.write( + [ + "btuin", + "", + "Usage:", + " btuin [options]", + "", + "Commands:", + ...list, + "", + "Run `btuin --help` for command options.", + "", + ].join("\n"), + ); +} + +function printCommandHelp(commandName: string, help: HelpInfo) { + const lines = formatCommandHelp(commandName, help); + process.stderr.write([`btuin ${commandName}`, "", ...lines, ""].join("\n")); +} + +function printVersion() { + try { + const pkgPath = fileURLToPath(new URL("../../package.json", import.meta.url)); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { name?: string; version?: string }; + process.stdout.write(`${pkg.name ?? "btuin"} ${pkg.version ?? ""}`.trimEnd() + "\n"); + } catch { + process.stdout.write("btuin\n"); + } +} + +export async function btuinCli(argv: string[]) { + if (argv.length === 0) { + printHelp(); + return; + } + + if (argv.includes("-h") || argv.includes("--help")) { + const commandName = argv[0]; + const command = commands.find((cmd) => cmd.name === commandName); + if (command) { + printCommandHelp(command.name, command.help); + } else { + printHelp(); + } + return; + } + + if (argv.includes("-v") || argv.includes("--version")) { + printVersion(); + return; + } + + const [commandName, ...rest] = argv; + const command = commands.find((cmd) => cmd.name === commandName); + if (!command) { + process.stderr.write(`[btuin] unknown command: ${commandName}\n`); + printHelp(); + process.exitCode = 1; + return; + } + + try { + await command.run(command.parse(rest)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`${message}\n`); + printCommandHelp(command.name, command.help); + process.exitCode = 1; + } +} diff --git a/src/components/component.ts b/src/components/component.ts index 178bd6c..9a6570e 100644 --- a/src/components/component.ts +++ b/src/components/component.ts @@ -148,6 +148,9 @@ export function mountComponent( if (existing) return existing; const instance = createComponentInstance(); + if (maybeOptions?.name) { + instance.name = maybeOptions.name; + } const safeRuntime = runtime ?? ({ diff --git a/src/components/lifecycle.ts b/src/components/lifecycle.ts index ccc651e..56c37ee 100644 --- a/src/components/lifecycle.ts +++ b/src/components/lifecycle.ts @@ -20,6 +20,7 @@ let currentInstance: ComponentInstance | null = null; export interface ComponentInstance { uid: number; isMounted: boolean; + name?: string; mountedHooks: LifecycleHook[]; unmountedHooks: LifecycleHook[]; diff --git a/src/devtools/controller.ts b/src/devtools/controller.ts new file mode 100644 index 0000000..e559e6e --- /dev/null +++ b/src/devtools/controller.ts @@ -0,0 +1,61 @@ +import type { KeyEvent } from "../terminal/types/key-event"; +import type { ConsoleCaptureHandle } from "../terminal/capture"; +import { setupDevtoolsLogStreaming } from "./log-stream"; +import type { DevtoolsOptions } from "./types"; +import { setupDevtoolsServer, type DevtoolsSnapshot } from "./server"; +import type { FrameMetrics } from "../runtime/profiler"; +import { subscribeReactivity } from "../reactivity/devtools"; + +export interface DevtoolsController { + handleKey(event: KeyEvent): boolean; + wrapView( + root: import("../view/types/elements").ViewElement, + ): import("../view/types/elements").ViewElement; + onLayout?(snapshot: DevtoolsSnapshot): void; + onProfileFrame?(frame: FrameMetrics): void; + dispose(): void; +} + +export function createDevtoolsController(options: DevtoolsOptions | undefined): DevtoolsController { + const enabled = options?.enabled ?? false; + + const streaming = setupDevtoolsLogStreaming(options); + + const capture: ConsoleCaptureHandle | null = enabled ? streaming.capture : null; + const server = enabled ? setupDevtoolsServer(options, () => capture) : null; + const cleanupReactivity = enabled + ? subscribeReactivity((event) => { + server?.setReactivityEvent(event); + }) + : null; + + return { + handleKey: (event) => { + void event; + return false; + }, + + wrapView: (root) => root, + + onLayout: (snapshot) => { + server?.setSnapshot(snapshot); + }, + onProfileFrame: (frame) => { + server?.setProfileFrame(frame); + }, + + dispose: () => { + try { + cleanupReactivity?.(); + } catch { + // ignore + } + try { + server?.dispose(); + } catch { + // ignore + } + streaming.dispose(); + }, + }; +} diff --git a/src/devtools/index.ts b/src/devtools/index.ts new file mode 100644 index 0000000..ad0102e --- /dev/null +++ b/src/devtools/index.ts @@ -0,0 +1 @@ +export type { DevtoolsOptions } from "./types"; diff --git a/src/devtools/inspector.html b/src/devtools/inspector.html new file mode 100644 index 0000000..c9422a4 --- /dev/null +++ b/src/devtools/inspector.html @@ -0,0 +1,1881 @@ + + + + + + btuin DevTools + + + + +
+
+
btuin DevTools
+ {{ status }} + + WS: {{ wsUrl }} +
+
+
+
+
+

Logs

+ {{ logs.length }} lines +
+
+
+ {{ line.display }} +
+
+
+
+
+

Profiler

+ {{ profileStatus }} +
+
+ Avg {{ formatMs(profileAvgMs) }} + Max {{ formatMs(profileMaxMs) }} + FPS {{ profileFps }} + Nodes {{ profileLast?.nodeCount ?? "-" }} + Bytes {{ profileLast?.outputBytes ?? "-" }} +
+
+ Layout + Render + Diff + Write +
+
+
+ #{{ frame.id }} +
+ + + + +
+ {{ formatMs(frame.frameMs) }} +
+
+
+
+
+
+

Snapshot

+
+ + + +
+
+
+
+ + + + + + + Mid/Right Drag + {{ previewSize }} +
+
+
+
+
+
+ Selected: {{ selectedKey || "(none)" }} + Type: {{ selectedType }} +
+
+ Styles + + + + + +
{{ row.key }}{{ row.section ? "" : row.value }}
+
+
+
+
{{ snapshotText }}
+
+
+ {{ reactivityStatus }} + Refs {{ reactivityRefs.length }} + Edges {{ reactivityEdges.length }} + +
+
+ + + {{ edge.label }} + + + + + {{ node.label }} + + + + + + {{ node.label }} + + + +
+
+
{{ edge.label }}
+
+
+
+
+
+ + + diff --git a/src/devtools/log-stream.ts b/src/devtools/log-stream.ts new file mode 100644 index 0000000..3132961 --- /dev/null +++ b/src/devtools/log-stream.ts @@ -0,0 +1,72 @@ +import { createConsoleCapture, type ConsoleCaptureHandle } from "../terminal/capture"; +import { createJsonlFileLogStreamer, createJsonlTcpLogStreamer, type LogStreamer } from "./stream"; +import type { DevtoolsOptions } from "./types"; + +export interface DevtoolsLogStreaming { + capture: ConsoleCaptureHandle | null; + dispose(): void; +} + +export function setupDevtoolsLogStreaming( + options: DevtoolsOptions | undefined, + onLine?: () => void, +): DevtoolsLogStreaming { + const enabled = options?.enabled ?? false; + if (!enabled) { + return { capture: null, dispose: () => {} }; + } + + const capture = createConsoleCapture({ maxLines: options?.maxLogLines ?? 1000 }); + + const streamers: LogStreamer[] = []; + const filePath = options?.stream?.file; + if (filePath) streamers.push(createJsonlFileLogStreamer(filePath)); + + const tcp = options?.stream?.tcp; + if (tcp) { + streamers.push( + createJsonlTcpLogStreamer({ + host: tcp.host, + port: tcp.port, + onListen: tcp.onListen, + backlog: tcp.backlog, + }), + ); + } + + const cleanupSubscribe = capture.subscribe((line) => { + onLine?.(); + for (const streamer of streamers) { + try { + streamer.writeLine(line); + } catch { + // ignore + } + } + }); + + return { + capture, + dispose: () => { + try { + cleanupSubscribe(); + } catch { + // ignore + } + + for (const streamer of streamers.splice(0)) { + try { + streamer.dispose(); + } catch { + // ignore + } + } + + try { + capture.dispose(); + } catch { + // ignore + } + }, + }; +} diff --git a/src/devtools/server.ts b/src/devtools/server.ts new file mode 100644 index 0000000..3217a7f --- /dev/null +++ b/src/devtools/server.ts @@ -0,0 +1,371 @@ +import type { ServerWebSocket } from "bun"; +import type { ComputedLayout, LayoutStyle } from "../layout-engine/types"; +import type { OutlineOptions } from "../renderer/types"; +import type { ConsoleCaptureHandle, ConsoleLine } from "../terminal/capture"; +import type { FrameMetrics } from "../runtime/profiler"; +import type { ReactivityEvent } from "../reactivity/devtools"; +import { isBlock, isText, type ViewElement } from "../view/types/elements"; +import type { DevtoolsOptions } from "./types"; +import htmlDocument from "./inspector.html" with { type: "text" }; + +export interface DevtoolsSnapshot { + size: { rows: number; cols: number }; + rootElement: ViewElement; + layoutMap: ComputedLayout; +} + +type LayoutBox = { x: number; y: number; width: number; height: number }; + +type LayoutStyleKey = + | "display" + | "position" + | "width" + | "height" + | "minWidth" + | "minHeight" + | "maxWidth" + | "maxHeight" + | "layoutBoundary" + | "padding" + | "margin" + | "flexDirection" + | "flexWrap" + | "flexGrow" + | "flexShrink" + | "flexBasis" + | "justifyContent" + | "alignItems" + | "alignSelf" + | "gap"; + +const layoutStyleKeys: readonly LayoutStyleKey[] = [ + "display", + "position", + "width", + "height", + "minWidth", + "minHeight", + "maxWidth", + "maxHeight", + "layoutBoundary", + "padding", + "margin", + "flexDirection", + "flexWrap", + "flexGrow", + "flexShrink", + "flexBasis", + "justifyContent", + "alignItems", + "alignSelf", + "gap", +] as const; +type LayoutStyleInfo = Partial>; + +type ViewStyleInfo = { + foreground?: string | number; + background?: string | number; + outline?: Pick; + stack?: "z"; +}; + +type ViewNode = { + key: string; + type: string; + text?: string; + children?: ViewNode[]; + layoutStyle?: LayoutStyleInfo; + viewStyle?: ViewStyleInfo; +}; + +type BrowserSnapshot = { + timestamp: number; + size: { rows: number; cols: number }; + tree: ViewNode; + layout: Record; +}; + +export interface DevtoolsServerHandle { + getInfo(): { host: string; port: number; url: string } | null; + setSnapshot(snapshot: DevtoolsSnapshot): void; + setProfileFrame(frame: FrameMetrics): void; + setReactivityEvent(event: ReactivityEvent): void; + dispose(): void; +} + +function getKey(el: ViewElement): string { + return (el.key ?? el.identifier ?? "") as string; +} + +function pickLayoutStyle(style: ViewElement["style"] | undefined): LayoutStyleInfo | undefined { + if (!style) return undefined; + const out: LayoutStyleInfo = {}; + for (const key of layoutStyleKeys) { + if (style[key] !== undefined) { + (out as any)[key] = style[key]; + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function pickViewStyle(style: ViewElement["style"] | undefined): ViewStyleInfo | undefined { + if (!style) return undefined; + const out: ViewStyleInfo = {}; + + if (style.foreground !== undefined) out.foreground = style.foreground; + if (style.background !== undefined) out.background = style.background; + if (style.stack !== undefined) out.stack = style.stack; + + if (style.outline) { + const { color, style: outlineStyle, title } = style.outline; + out.outline = { color, style: outlineStyle, title }; + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +function buildBrowserSnapshot(snapshot: DevtoolsSnapshot): BrowserSnapshot { + const layout: Record = {}; + + const walk = (el: ViewElement, parentX: number, parentY: number): ViewNode => { + const key = getKey(el); + const entry = snapshot.layoutMap[key]; + const absX = (entry?.x ?? 0) + parentX; + const absY = (entry?.y ?? 0) + parentY; + const width = entry?.width ?? 0; + const height = entry?.height ?? 0; + if (entry) { + layout[key] = { x: absX, y: absY, width, height }; + } + + const layoutStyle = pickLayoutStyle(el.style); + const viewStyle = pickViewStyle(el.style); + + if (isText(el)) { + return { key, type: el.type, text: el.content, layoutStyle, viewStyle }; + } + + if (isBlock(el)) { + return { + key, + type: el.type, + layoutStyle, + viewStyle, + children: el.children.map((child) => walk(child, absX, absY)), + }; + } + + return { key, type: el.type, layoutStyle, viewStyle }; + }; + + const tree = walk(snapshot.rootElement, 0, 0); + + return { + timestamp: Date.now(), + size: snapshot.size, + tree, + layout, + }; +} + +function safeJson(payload: any): string { + try { + return JSON.stringify(payload); + } catch { + return JSON.stringify({ type: "error", message: "failed to serialize payload" }); + } +} + +export function setupDevtoolsServer( + options: DevtoolsOptions | undefined, + getCapture: () => ConsoleCaptureHandle | null, +): DevtoolsServerHandle | null { + const cfg = options?.server; + if (!cfg) return null; + + const host = cfg.host ?? "127.0.0.1"; + const port = cfg.port ?? 0; + + const clients = new Set>(); + let nextClientId = 1; + let cleanupSubscribe: (() => void) | null = null; + let snapshot: BrowserSnapshot | null = null; + let profileFrames: FrameMetrics[] = []; + let reactivityEvents: ReactivityEvent[] = []; + let info: { host: string; port: number; url: string } | null = null; + const maxProfileFrames = 240; + const maxReactivityEvents = 600; + + let server: ReturnType | null = null; + try { + server = Bun.serve<{ id: number }>({ + hostname: host, + port, + fetch(req, s) { + const url = new URL(req.url); + if (url.pathname === "/ws") { + const ok = s.upgrade(req, { data: { id: nextClientId++ } }); + return ok ? undefined : new Response("upgrade failed", { status: 400 }); + } + if (url.pathname === "/" || url.pathname === "/index.html") { + return new Response(`${htmlDocument}`, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + return new Response("not found", { status: 404 }); + }, + websocket: { + open(ws) { + clients.add(ws); + + const capture = getCapture(); + if (capture) { + try { + ws.send( + safeJson({ + type: "logs", + lines: capture.getLines(), + }), + ); + } catch { + // ignore + } + } + if (snapshot) { + try { + ws.send(safeJson({ type: "snapshot", snapshot })); + } catch { + // ignore + } + } + if (profileFrames.length > 0) { + try { + ws.send(safeJson({ type: "profile", frames: profileFrames })); + } catch { + // ignore + } + } + if (reactivityEvents.length > 0) { + try { + ws.send(safeJson({ type: "reactivity", events: reactivityEvents })); + } catch { + // ignore + } + } + }, + close(ws) { + clients.delete(ws); + }, + message(ws, message) { + const text = typeof message === "string" ? message : new TextDecoder().decode(message); + let msg: any = null; + try { + msg = JSON.parse(text); + } catch { + return; + } + if (!msg || typeof msg !== "object") return; + if (msg.type === "requestSnapshot" && snapshot) { + try { + ws.send(safeJson({ type: "snapshot", snapshot })); + } catch { + // ignore + } + } + }, + }, + }); + + const resolvedHost = server.hostname ?? host; + const resolvedPort = server.port ?? port; + const url = `http://${resolvedHost}:${resolvedPort}`; + info = { host: resolvedHost, port: resolvedPort, url }; + try { + cfg.onListen?.(info); + } catch { + // ignore + } + } catch { + // ignore server errors to avoid crashing the app + server = null; + } + + const broadcast = (payload: any) => { + if (clients.size === 0) return; + const text = safeJson(payload); + for (const ws of clients) { + try { + ws.send(text); + } catch { + try { + ws.close(); + } catch { + // ignore + } + clients.delete(ws); + } + } + }; + + const ensureSubscribed = () => { + if (cleanupSubscribe) return; + const capture = getCapture(); + if (!capture) return; + cleanupSubscribe = capture.subscribe((line: ConsoleLine) => { + broadcast({ type: "log", line }); + }); + }; + + ensureSubscribed(); + + return { + getInfo: () => info, + setSnapshot: (s) => { + snapshot = buildBrowserSnapshot(s); + broadcast({ type: "snapshot", snapshot }); + ensureSubscribed(); + }, + setProfileFrame: (frame) => { + profileFrames.push(frame); + if (profileFrames.length > maxProfileFrames) { + profileFrames.splice(0, profileFrames.length - maxProfileFrames); + } + broadcast({ type: "profile", frame }); + }, + setReactivityEvent: (event) => { + reactivityEvents.push(event); + if (reactivityEvents.length > maxReactivityEvents) { + reactivityEvents.splice(0, reactivityEvents.length - maxReactivityEvents); + } + broadcast({ type: "reactivity", event }); + }, + dispose: () => { + try { + cleanupSubscribe?.(); + } catch { + // ignore + } + cleanupSubscribe = null; + + for (const ws of clients) { + try { + ws.close(); + } catch { + // ignore + } + } + clients.clear(); + + try { + server?.stop(true); + } catch { + // ignore + } + server = null; + snapshot = null; + profileFrames = []; + reactivityEvents = []; + info = null; + }, + }; +} diff --git a/src/devtools/stream.ts b/src/devtools/stream.ts new file mode 100644 index 0000000..836daa3 --- /dev/null +++ b/src/devtools/stream.ts @@ -0,0 +1,130 @@ +import { mkdirSync, appendFileSync } from "node:fs"; +import path from "node:path"; +import type { ConsoleLine } from "../terminal/capture"; + +export interface LogStreamer { + writeLine(line: ConsoleLine): void; + dispose(): void; +} + +function ensureParentDir(filePath: string) { + const dir = path.dirname(filePath); + mkdirSync(dir, { recursive: true }); +} + +/** + * Streams console lines to a file as JSONL (newline-delimited JSON). + */ +export function createJsonlFileLogStreamer(filePath: string): LogStreamer { + ensureParentDir(filePath); + + return { + writeLine: (line) => { + const payload = JSON.stringify(line); + appendFileSync(filePath, payload + "\n", "utf8"); + }, + dispose: () => {}, + }; +} + +/** + * Streams console lines to connected TCP clients as JSONL. + */ +export function createJsonlTcpLogStreamer(options?: { + host?: string; + port?: number; + onListen?: (info: { host: string; port: number }) => void; + backlog?: number; +}): LogStreamer { + const host = options?.host ?? "127.0.0.1"; + const port = options?.port ?? 0; + const backlogLimit = Math.max(0, options?.backlog ?? 200); + + const sockets = new Set>(); + const backlog: string[] = []; + + let listener: Bun.TCPSocketListener | null = null; + try { + listener = Bun.listen({ + hostname: host, + port, + data: undefined, + socket: { + open(socket) { + sockets.add(socket); + + if (backlogLimit > 0 && backlog.length > 0) { + for (const payload of backlog) { + try { + socket.write(payload); + } catch { + // ignore + } + } + } + }, + close(socket) { + sockets.delete(socket); + }, + error(socket) { + sockets.delete(socket); + try { + socket.end(); + } catch { + // ignore + } + }, + }, + }); + + try { + options?.onListen?.({ host: listener.hostname, port: listener.port }); + } catch { + // ignore + } + } catch { + // ignore server errors to avoid crashing the app + } + + return { + writeLine: (line) => { + const payload = JSON.stringify(line) + "\n"; + if (backlogLimit > 0) { + backlog.push(payload); + if (backlog.length > backlogLimit) { + backlog.splice(0, backlog.length - backlogLimit); + } + } + + if (sockets.size === 0) return; + for (const socket of sockets) { + try { + socket.write(payload); + } catch { + sockets.delete(socket); + try { + socket.end(); + } catch { + // ignore + } + } + } + }, + dispose: () => { + for (const socket of sockets) { + try { + socket.end(); + } catch { + // ignore + } + } + sockets.clear(); + backlog.length = 0; + try { + listener?.stop(true); + } catch { + // ignore + } + }, + }; +} diff --git a/src/devtools/types.ts b/src/devtools/types.ts new file mode 100644 index 0000000..2fb1c2e --- /dev/null +++ b/src/devtools/types.ts @@ -0,0 +1,80 @@ +export interface DevtoolsOptions { + /** + * Enable built-in DevTools. + * + * Note: DevTools does not render an in-TUI panel. + * @default false + */ + enabled?: boolean; + + /** + * Maximum number of log lines kept in memory. + * @default 1000 + */ + maxLogLines?: number; + + /** + * Stream logs outside the TUI (no external window opened). + * Useful for `tail -f` or piping into other tools. + */ + stream?: { + /** + * Append console lines as JSONL (one JSON object per line). + * Example: `tail -f /tmp/btuin-devtools.log | jq -r .text` + */ + file?: string; + + /** + * Stream console lines as JSONL over TCP. + * Designed for local debugging: connect with `nc 127.0.0.1 `. + */ + tcp?: { + /** + * Bind host. + * @default "127.0.0.1" + */ + host?: string; + + /** + * Bind port. Use 0 to pick an ephemeral port. + * @default 0 + */ + port?: number; + + /** + * Called after the server starts listening (useful when `port: 0`). + */ + onListen?: (info: { host: string; port: number }) => void; + + /** + * Number of most recent log lines to keep and flush to newly connected clients. + * Helps avoid missing logs around connect timing. + * @default 200 + */ + backlog?: number; + }; + }; + + /** + * Start a local browser DevTools server. + * Serves a tiny UI + WebSocket event stream (logs + snapshots). + */ + server?: { + /** + * Bind host. + * @default "127.0.0.1" + */ + host?: string; + + /** + * Bind port. Use 0 to pick an ephemeral port. + * @default 0 + */ + port?: number; + + /** + * Called after the server starts listening (useful when `port: 0`). + */ + onListen?: (info: { host: string; port: number; url: string }) => void; + }; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..9506e09 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export { useLog } from "./use-log"; diff --git a/src/hooks/types.ts b/src/hooks/types.ts new file mode 100644 index 0000000..71acdbd --- /dev/null +++ b/src/hooks/types.ts @@ -0,0 +1 @@ +export type { UseLogOptions, UseLogResult } from "./use-log"; diff --git a/src/hooks/use-log.ts b/src/hooks/use-log.ts new file mode 100644 index 0000000..6ccc133 --- /dev/null +++ b/src/hooks/use-log.ts @@ -0,0 +1,94 @@ +import { shallowRef } from "../reactivity"; +import type { Ref } from "../reactivity/ref"; +import { getCurrentInstance } from "../components/lifecycle"; +import { + createConsoleCapture, + type ConsoleCaptureHandle, + type ConsoleLine, +} from "../terminal/capture"; + +export interface UseLogOptions { + /** + * Maximum number of lines stored in the console buffer. + */ + maxLines?: number; + + /** + * Include stdout lines. + * @default true + */ + stdout?: boolean; + + /** + * Include stderr lines. + * @default true + */ + stderr?: boolean; +} + +export interface UseLogResult { + lines: Ref; + clear: () => void; + dispose: () => void; + capture: ConsoleCaptureHandle; +} + +function filterLines( + all: ConsoleLine[], + options: { stdout: boolean; stderr: boolean }, +): ConsoleLine[] { + if (options.stdout && options.stderr) return all; + if (options.stdout) return all.filter((l) => l.type === "stdout"); + if (options.stderr) return all.filter((l) => l.type === "stderr"); + return []; +} + +/** + * Reactive access to captured console output. + * + * Intended usage: call inside a component `init()` (auto-disposes on unmount). + * If called outside a component init, call `dispose()` manually. + */ +export function useLog(options: UseLogOptions = {}): UseLogResult { + const stdout = options.stdout ?? true; + const stderr = options.stderr ?? true; + + const capture = createConsoleCapture({ maxLines: options.maxLines }); + const lines = shallowRef(filterLines(capture.getLines(), { stdout, stderr })); + + const refresh = () => { + lines.value = filterLines(capture.getLines(), { stdout, stderr }); + }; + + const unsubscribe = capture.subscribe(() => { + refresh(); + }); + + const dispose = () => { + try { + unsubscribe(); + } catch { + // ignore + } + try { + capture.dispose(); + } catch { + // ignore + } + }; + + const instance = getCurrentInstance(); + if (instance) { + instance.effects.push(dispose); + } + + return { + lines, + clear: () => { + capture.clear(); + refresh(); + }, + dispose, + capture, + }; +} diff --git a/src/index.ts b/src/index.ts index 5ead392..64365e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export { createApp, App } from "./runtime"; export { defineComponent } from "./components"; export * from "./view"; +export * from "./hooks/"; export { onBeforeUpdate, onKey, onMounted, onTick, onUnmounted, onUpdated } from "./components"; diff --git a/src/reactivity/devtools.ts b/src/reactivity/devtools.ts new file mode 100644 index 0000000..565eb91 --- /dev/null +++ b/src/reactivity/devtools.ts @@ -0,0 +1,82 @@ +import { getCurrentEffect } from "./effect"; + +export type ReactivityEvent = { + kind: "track" | "trigger"; + targetId: number; + targetLabel: string; + key: string; + componentId: number; + componentName: string; + time: number; +}; + +export type ReactivityListener = (event: ReactivityEvent) => void; + +const listeners = new Set(); +const targetIds = new WeakMap(); +const refLabels = new WeakMap(); +let nextTargetId = 1; + +function getTargetId(target: object): number { + const existing = targetIds.get(target); + if (existing) return existing; + const id = nextTargetId++; + targetIds.set(target, id); + return id; +} + +function emit(kind: ReactivityEvent["kind"], target: object, key: string) { + if (listeners.size === 0) return; + const effect = getCurrentEffect(); + const meta = (effect as any)?.meta as + | { type?: string; componentId?: number; componentName?: string } + | undefined; + if (!meta || meta.type !== "render" || meta.componentId === undefined) return; + + const targetId = getTargetId(target); + const targetLabel = refLabels.get(target) || `ref#${targetId}`; + const componentName = + meta.componentName && String(meta.componentName).trim().length > 0 ? meta.componentName : "App"; + + const event: ReactivityEvent = { + kind, + targetId, + targetLabel, + key, + componentId: meta.componentId, + componentName, + time: Date.now(), + }; + + for (const listener of listeners) { + try { + listener(event); + } catch { + // ignore listener errors + } + } +} + +export function subscribeReactivity(listener: ReactivityListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function setRefLabel(target: object, label: string) { + if (!label) return; + refLabels.set(target, label); +} + +export function getRefLabel(target: object): string | undefined { + return refLabels.get(target); +} + +export function trackRef(target: object, key = "value") { + emit("track", target, String(key)); +} + +export function triggerRef(target: object, key = "value") { + emit("trigger", target, String(key)); +} diff --git a/src/reactivity/effect.ts b/src/reactivity/effect.ts index 521b701..c8d9e3c 100644 --- a/src/reactivity/effect.ts +++ b/src/reactivity/effect.ts @@ -77,6 +77,7 @@ export class ReactiveEffect { active = true; deps: Set[] = []; onStop?: () => void; + meta?: { type?: string; componentId?: number; componentName?: string }; constructor( public fn: EffectFn, diff --git a/src/reactivity/index.ts b/src/reactivity/index.ts index d4c8297..a216e54 100644 --- a/src/reactivity/index.ts +++ b/src/reactivity/index.ts @@ -26,7 +26,7 @@ export { reactive, isReactive, toRaw, shallowReactive } from "./reactive"; -export { ref, shallowRef, isRef, unref, toRef, toRefs, customRef } from "./ref"; +export { ref, shallowRef, isRef, unref, toRef, toRefs, customRef, labelRef } from "./ref"; export { computed } from "./computed"; diff --git a/src/reactivity/ref.ts b/src/reactivity/ref.ts index 38b32a3..0890d8e 100644 --- a/src/reactivity/ref.ts +++ b/src/reactivity/ref.ts @@ -7,6 +7,7 @@ import { reactive, isReactive, toRaw } from "./reactive"; import { track, trigger } from "./effect"; +import { setRefLabel, trackRef, triggerRef } from "./devtools"; const IS_REF_KEY = Symbol("__v_isRef"); @@ -33,8 +34,8 @@ export interface Ref { * @param value - Initial value * @returns Ref object */ -export function ref(value: T): Ref { - return createRef(value, false); +export function ref(value: T, label?: string): Ref { + return createRef(value, false, label); } /** @@ -52,8 +53,8 @@ export function ref(value: T): Ref { * @param value - Initial value * @returns Shallow ref object */ -export function shallowRef(value: T): Ref { - return createRef(value, true); +export function shallowRef(value: T, label?: string): Ref { + return createRef(value, true, label); } class RefImpl { @@ -71,6 +72,7 @@ class RefImpl { get value() { track(this, "value"); + trackRef(this, "value"); return this._value; } @@ -85,14 +87,22 @@ class RefImpl { this._rawValue = newVal; this._value = useDirectValue ? newVal : convert(newVal); trigger(this, "value"); + triggerRef(this, "value"); } } -function createRef(rawValue: T, shallow: boolean): Ref { +function createRef(rawValue: T, shallow: boolean, label?: string): Ref { if (isRef(rawValue)) { + if (label) { + setRefLabel(rawValue as object, label); + } return rawValue as Ref; } - return new RefImpl(rawValue, shallow) as unknown as Ref; + const created = new RefImpl(rawValue, shallow) as unknown as Ref; + if (label) { + setRefLabel(created as object, label); + } + return created; } /** @@ -208,6 +218,11 @@ export function toRefs( return ret; } +export function labelRef(refValue: Ref, label: string): Ref { + setRefLabel(refValue as object, label); + return refValue; +} + /** * Creates a custom ref with explicit control over dependency tracking and trigger timing. * @@ -261,8 +276,14 @@ class CustomRefImpl { }, ) { const { get, set } = factory( - () => track(this, "value"), - () => trigger(this, "value"), + () => { + track(this, "value"); + trackRef(this, "value"); + }, + () => { + trigger(this, "value"); + triggerRef(this, "value"); + }, ); this._get = get; this._set = set; diff --git a/src/runtime/app.ts b/src/runtime/app.ts index 1f67f70..15f52d5 100644 --- a/src/runtime/app.ts +++ b/src/runtime/app.ts @@ -96,6 +96,8 @@ export function App(root: Component, options: CreateAppOptions = { options.errorLog, ); + context.options.devtools = resolveDevtoolsOptions(context.options.devtools); + context.loopManager = new LoopManager(context, handleError); const rows = mountOptions.rows ?? 0; @@ -104,6 +106,10 @@ export function App(root: Component, options: CreateAppOptions = { updaters.renderMode(inline ? "inline" : "fullscreen"); updaters.inlineCleanupOnExit(mountOptions.inlineCleanupOnExit ?? false); + // Initialize devtools (e.g. start the browser server) before the TUI begins rendering. + // This matches the "hot-reload tcp" logs style: it prints once and doesn't overlay the UI. + await context.loopManager.prepare?.(); + updaters.unpatchConsole(terminal.patchConsole()); terminal.startCapture(); terminal.setupRawMode(); @@ -215,7 +221,46 @@ export function app any>( onExit: config.onExit, profile: config.profile, inputParser: config.inputParser, + devtools: config.devtools, }); } export const createApp = app; + +function resolveDevtoolsOptions( + explicit: CreateAppOptions["devtools"], +): CreateAppOptions["devtools"] { + if (explicit) return explicit; + + const env = process.env.BTUIN_DEVTOOLS; + const enabled = + env === "1" || env === "true" || env === "yes" || env === "on" || env === "enabled"; + if (!enabled) return undefined; + + const host = process.env.BTUIN_DEVTOOLS_HOST; + const portRaw = process.env.BTUIN_DEVTOOLS_PORT; + const port = portRaw ? Number(portRaw) : undefined; + + return { + enabled: true, + server: { + host: host && host.length > 0 ? host : undefined, + port: Number.isInteger(port) && (port as number) >= 0 ? (port as number) : undefined, + onListen: (info: { host: string; port: number; url: string }) => { + try { + process.stderr.write(`[btuin] devtools: ${info.url}\n`); + } catch { + // ignore + } + try { + const send = (process as any).send as undefined | ((message: unknown) => void); + if (typeof send === "function") { + send({ type: "btuin:devtools:listen", info }); + } + } catch { + // ignore + } + }, + }, + }; +} diff --git a/src/runtime/loop.ts b/src/runtime/loop.ts index 1b4ac17..3241e77 100644 --- a/src/runtime/loop.ts +++ b/src/runtime/loop.ts @@ -9,18 +9,71 @@ import { createRenderer } from "./render-loop"; import { createErrorContext, createErrorHandler } from "./error-boundary"; import type { AppContext } from "./context"; import type { ILoopManager } from "./types"; +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; + +type DevtoolsControllerLike = { + handleKey(event: KeyEvent): boolean; + wrapView(root: ViewElement): ViewElement; + onLayout?(snapshot: { + size: { rows: number; cols: number }; + rootElement: ViewElement; + layoutMap: any; + }): void; + onProfileFrame?(frame: import("./profiler").FrameMetrics): void; + dispose(): void; +}; export class LoopManager implements ILoopManager { private ctx: AppContext; private handleError: ReturnType; private cleanupTerminalFn: (() => void) | null = null; private cleanupOutputListeners: (() => void)[] = []; + private cleanupProfilerListeners: (() => void)[] = []; + private devtools: DevtoolsControllerLike | null = null; + private devtoolsInit: Promise | null = null; + private stopped = false; constructor(context: AppContext, handleError: ReturnType) { this.ctx = context; this.handleError = handleError; } + private initDevtools() { + if (this.devtoolsInit) return; + + const options = this.ctx.options.devtools; + if (!options) return; + + this.devtoolsInit = (async () => { + try { + const mod: any = await import(resolveDevtoolsControllerModule()); + const factory: undefined | ((opts: unknown) => DevtoolsControllerLike) = + mod?.createDevtoolsController; + if (!factory) return; + const controller = factory(options); + if (this.stopped) { + try { + controller.dispose(); + } catch { + // ignore + } + return; + } + + this.devtools = controller; + this.cleanupOutputListeners.push(() => controller.dispose()); + } catch { + // devtools is optional + } + })(); + } + + prepare(): Promise { + this.initDevtools(); + return this.devtoolsInit ?? Promise.resolve(); + } + start(rows: number, cols: number) { const { state, updaters, terminal, platform, profiler, app } = this.ctx; @@ -34,6 +87,8 @@ export class LoopManager implements ILoopManager { const pendingKeyEvents: KeyEvent[] = []; + this.initDevtools(); + terminal.onKey((event: KeyEvent) => { if (!state.mounted) { pendingKeyEvents.push(event); @@ -41,6 +96,8 @@ export class LoopManager implements ILoopManager { } try { + if (this.devtools?.handleKey(event)) return; + const handled = handleComponentKey(state.mounted, event); if (!handled && (event.sequence === "\x03" || (event.ctrl && event.name === "c"))) { app.exit(0, "sigint"); @@ -67,9 +124,21 @@ export class LoopManager implements ILoopManager { write: terminal.write, view: (): ViewElement => { if (!state.mounted) return Block(); - return renderComponent(state.mounted); + const root = renderComponent(state.mounted); + return this.devtools?.wrapView(root) ?? root; }, getState: () => ({}), + onLayout: ({ size, rootElement, layoutMap }) => { + try { + this.devtools?.onLayout?.({ + size, + rootElement, + layoutMap, + }); + } catch { + // ignore + } + }, handleError: this.handleError, profiler: profiler.isEnabled() ? profiler : undefined, deps: inline @@ -129,6 +198,24 @@ export class LoopManager implements ILoopManager { renderer.renderOnce(true); updaters.renderEffect(renderer.render()); + if (state.renderEffect && state.mounted) { + state.renderEffect.meta = { + type: "render", + componentId: state.mounted.instance.uid, + componentName: state.mounted.instance.name, + }; + } + + if (profiler.isEnabled()) { + const cleanup = profiler.subscribeFrames((frame) => { + try { + this.devtools?.onProfileFrame?.(frame); + } catch { + // ignore + } + }); + this.cleanupProfilerListeners.push(cleanup); + } if (pendingKeyEvents.length && state.mounted) { for (const event of pendingKeyEvents.splice(0)) { @@ -158,6 +245,7 @@ export class LoopManager implements ILoopManager { stop() { const { state, updaters } = this.ctx; + this.stopped = true; if (state.renderEffect) { stop(state.renderEffect); updaters.renderEffect(null); @@ -171,10 +259,21 @@ export class LoopManager implements ILoopManager { } } } + if (this.cleanupProfilerListeners.length > 0) { + for (const dispose of this.cleanupProfilerListeners.splice(0)) { + try { + dispose(); + } catch { + // ignore + } + } + } if (state.disposeResize) { state.disposeResize(); updaters.disposeResize(null); } + + this.devtools = null; } cleanupTerminal() { @@ -182,3 +281,12 @@ export class LoopManager implements ILoopManager { this.cleanupTerminalFn = null; } } + +function resolveDevtoolsControllerModule(): string { + const spec = process.env.BTUIN_DEVTOOLS_CONTROLLER; + if (!spec) return "../" + "devtools/controller"; + if (spec.startsWith("file:")) return spec; + if (spec.startsWith("/")) return pathToFileURL(spec).href; + if (spec.startsWith(".")) return pathToFileURL(resolve(process.cwd(), spec)).href; + return spec; +} diff --git a/src/runtime/profiler.ts b/src/runtime/profiler.ts index 47e24f6..4f043a6 100644 --- a/src/runtime/profiler.ts +++ b/src/runtime/profiler.ts @@ -114,6 +114,7 @@ export class Profiler { private frames: FrameMetrics[] = []; private frameSeq = 0; private lastFrame: FrameMetrics | null = null; + private subscribers = new Set<(frame: FrameMetrics) => void>(); constructor(options: ProfileOptions) { this.options = { @@ -137,6 +138,13 @@ export class Profiler { return this.lastFrame; } + subscribeFrames(handler: (frame: FrameMetrics) => void): () => void { + this.subscribers.add(handler); + return () => { + this.subscribers.delete(handler); + }; + } + beginFrame(size: { rows: number; cols: number }, extra?: { nodeCount?: number }) { if (!this.options.enabled) return null; if (this.options.maxFrames !== undefined && this.frames.length >= this.options.maxFrames) @@ -208,6 +216,15 @@ export class Profiler { }; this.frames.push(metrics); this.lastFrame = metrics; + if (this.subscribers.size > 0) { + for (const handler of this.subscribers) { + try { + handler(metrics); + } catch { + // ignore subscriber errors + } + } + } } measure(frame: any, key: "layoutMs" | "renderMs" | "diffMs" | "writeMs", fn: () => T): T { diff --git a/src/runtime/render-loop.ts b/src/runtime/render-loop.ts index 8866699..36a39ed 100644 --- a/src/runtime/render-loop.ts +++ b/src/runtime/render-loop.ts @@ -55,6 +55,12 @@ export interface RenderLoopConfig { view: (state: State) => ViewElement; /** Function to get current state */ getState: () => State; + /** Optional hook after layout is computed (before render) */ + onLayout?: (args: { + size: TerminalSize; + rootElement: ViewElement; + layoutMap: ComputedLayout; + }) => void; /** Error handler */ handleError: (context: import("./error-boundary").ErrorContext) => void; /** Optional profiler */ @@ -147,6 +153,12 @@ export function createRenderer(config: RenderLoopConfig) { prevLayoutResult = layoutResult; prevLayoutSizeKey = layoutSizeKey; + try { + config.onLayout?.({ size: state.currentSize, rootElement, layoutMap: layoutResult }); + } catch { + // ignore devtools hook failures + } + let buf = pool.acquire(); if (buf === state.prevBuffer) { // Ensure prev/next buffers differ; diffing the same instance yields no output. diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 74a8fe6..a8f4d58 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -10,6 +10,11 @@ export interface ILoopManager { start(rows: number, cols: number): void; stop(): void; cleanupTerminal?(): void; + /** + * Optional async preparation step (used by dev tooling to initialize + * sidecar servers before the TUI begins rendering). + */ + prepare?(): Promise; } export type RenderMode = "fullscreen" | "inline"; @@ -22,6 +27,11 @@ export type AppConfig = { onExit?: () => void; profile?: ProfileOptions; inputParser?: InputParser; + /** + * Internal/optional: dev runners may set this (e.g. via env) to enable extra tooling. + * The core runtime treats it as opaque. + */ + devtools?: unknown; init: (ctx: ComponentInitContext) => State; render: (state: State) => ViewElement; }; @@ -53,4 +63,9 @@ export type CreateAppOptions = { platform?: PlatformAdapter; profile?: ProfileOptions; inputParser?: InputParser; + /** + * Internal/optional: dev runners may set this (e.g. via env) to enable extra tooling. + * The core runtime treats it as opaque. + */ + devtools?: unknown; }; diff --git a/src/terminal/capture.ts b/src/terminal/capture.ts index efe19d8..30adf13 100644 --- a/src/terminal/capture.ts +++ b/src/terminal/capture.ts @@ -341,6 +341,12 @@ export interface ConsoleCaptureHandle { * Stop capturing and clean up listeners. */ dispose(): void; + + /** + * Subscribe to newly captured console lines. + * Returns a cleanup function. + */ + subscribe(listener: (line: ConsoleLine) => void): () => void; } /** @@ -358,22 +364,36 @@ export interface ConsoleCaptureHandle { export function createConsoleCapture(options?: { maxLines?: number }): ConsoleCaptureHandle { const maxLines = options?.maxLines ?? 1000; const lines: ConsoleLine[] = []; + const subscribers = new Set<(line: ConsoleLine) => void>(); + + const notify = (line: ConsoleLine) => { + for (const listener of subscribers) { + try { + listener(line); + } catch { + // ignore subscriber errors + } + } + }; // Capture stdout const cleanupStdout = onStdout((text) => { const textLines = text.split("\n"); for (const line of textLines) { if (line) { - lines.push({ + const entry: ConsoleLine = { text: line, type: "stdout", timestamp: Date.now(), - }); + }; + lines.push(entry); // Limit buffer size if (lines.length > maxLines) { lines.shift(); } + + notify(entry); } } }); @@ -383,16 +403,19 @@ export function createConsoleCapture(options?: { maxLines?: number }): ConsoleCa const textLines = text.split("\n"); for (const line of textLines) { if (line) { - lines.push({ + const entry: ConsoleLine = { text: line, type: "stderr", timestamp: Date.now(), - }); + }; + lines.push(entry); // Limit buffer size if (lines.length > maxLines) { lines.shift(); } + + notify(entry); } } }); @@ -411,8 +434,14 @@ export function createConsoleCapture(options?: { maxLines?: number }): ConsoleCa dispose: () => { cleanupStdout(); cleanupStderr(); + subscribers.clear(); lines.length = 0; }, + + subscribe: (listener) => { + subscribers.add(listener); + return () => subscribers.delete(listener); + }, }; } diff --git a/src/types/index.ts b/src/types/index.ts index 1503ceb..466bacc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -28,11 +28,13 @@ export type { export type { PlatformAdapter } from "../runtime/platform-adapter"; export type { TerminalAdapter } from "../runtime/terminal-adapter"; export type { FrameMetrics, ProfileOptions, ProfileOutput } from "../runtime/profiler"; +export type * from "../hooks/types"; export type { Buffer2D, ColorValue, OutlineOptions } from "../renderer/types"; export type { InputParser } from "../terminal/parser/types"; export type { KeyEvent, KeyHandler as TerminalKeyHandler } from "../terminal/types/key-event"; +export type { ConsoleLine } from "../terminal/capture"; export type { BaseView, ViewProps } from "../view/base"; export type { FocusContext, FocusHandler, FocusTarget } from "../view/types/focus"; diff --git a/tests/units/runtime/app.test.ts b/tests/units/runtime/app.test.ts index eed115a..a77aa01 100644 --- a/tests/units/runtime/app.test.ts +++ b/tests/units/runtime/app.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, beforeAll } from "bun:test"; import { ref } from "@/reactivity"; import { Block, Text } from "@/view/primitives"; import type { AppType as App, KeyEvent, TerminalAdapter } from "@/types"; +import { disposeSingletonCapture } from "@/terminal/capture"; const keyHandlers: any[] = []; @@ -26,7 +27,7 @@ describe("createApp", () => { }; }, getTerminalSize: () => ({ rows: 24, cols: 80 }), - disposeSingletonCapture: () => {}, + disposeSingletonCapture, write: (_output: string) => {}, }; const platform = { @@ -123,6 +124,31 @@ describe("createApp", () => { expect(keyValue).toBe("a"); }); + it("should not intercept F12 even when devtools is enabled", async () => { + let keyValue = ""; + appInstance = app({ + terminal, + platform, + devtools: { enabled: true }, + init({ onKey }) { + onKey((k: KeyEvent) => { + keyValue = k.name; + }); + return {}; + }, + render: () => Block(Text("test")), + }); + + await appInstance.mount(); + + const onKeyCallback = (global as any).__btuin_onKeyCallback; + onKeyCallback?.({ name: "f12", sequence: "\x1b[24~", ctrl: false, meta: false, shift: false }); + expect(keyValue).toBe("f12"); + + onKeyCallback?.({ name: "a", sequence: "a", ctrl: false, meta: false, shift: false }); + expect(keyValue).toBe("a"); + }); + it("should mount in inline mode without clearing the screen", async () => { const calls: { clearScreen: number; writes: string[] } = { clearScreen: 0, writes: [] }; diff --git a/tests/units/runtime/devtools-server.test.ts b/tests/units/runtime/devtools-server.test.ts new file mode 100644 index 0000000..d3fa261 --- /dev/null +++ b/tests/units/runtime/devtools-server.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { createConsoleCapture } from "@/terminal/capture"; +import { setupDevtoolsServer } from "@/devtools/server"; +import { Block, Text } from "@/view/primitives"; +import type { ComputedLayout } from "@/layout-engine/types"; + +describe("DevTools browser server", () => { + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + + beforeEach(() => { + process.stdout.write = (() => true) as any; + process.stderr.write = (() => true) as any; + }); + + afterEach(() => { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + }); + + it("should serve HTML and stream logs over WebSocket", async () => { + const capture = createConsoleCapture({ maxLines: 50 }); + + const server = setupDevtoolsServer( + { + enabled: true, + server: { + host: "127.0.0.1", + port: 0, + }, + }, + () => capture, + ); + + try { + // Some sandboxed environments prohibit binding to ports (EPERM). + const listenInfo = server?.getInfo(); + if (!listenInfo) return; + + const res = await fetch(listenInfo.url); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("btuin DevTools"); + + const wsUrl = listenInfo.url.replace(/^http/, "ws") + "/ws"; + + const received = await new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + const timer = setTimeout(() => reject(new Error("timeout")), 1000); + + ws.addEventListener("open", () => { + process.stdout.write("hello\n"); + }); + ws.addEventListener("message", (ev) => { + try { + const msg = JSON.parse(String(ev.data)); + if (msg?.type === "log" && msg?.line?.text === "hello") { + clearTimeout(timer); + try { + ws.close(); + } catch {} + resolve(msg); + } + } catch { + // ignore + } + }); + ws.addEventListener("error", (e) => { + clearTimeout(timer); + reject(e); + }); + }); + + expect(received.type).toBe("log"); + expect(received.line.text).toBe("hello"); + expect(received.line.type).toBe("stdout"); + + const root = Block(Text("X").setKey("child")).setKey("root"); + const layoutMap: ComputedLayout = { + root: { x: 0, y: 0, width: 10, height: 3 }, + child: { x: 1, y: 1, width: 1, height: 1 }, + }; + server?.setSnapshot({ size: { rows: 10, cols: 40 }, rootElement: root, layoutMap }); + } finally { + try { + server?.dispose(); + } catch {} + capture.dispose(); + } + }); +}); diff --git a/tests/units/runtime/devtools-stream.test.ts b/tests/units/runtime/devtools-stream.test.ts new file mode 100644 index 0000000..e963545 --- /dev/null +++ b/tests/units/runtime/devtools-stream.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { app } from "@/runtime/app"; +import { Block, Text } from "@/view/primitives"; +import { disposeSingletonCapture, patchConsole, stopCapture } from "@/terminal/capture"; +import type { TerminalAdapter } from "@/types"; +import net from "node:net"; + +describe("devtools stream", () => { + const filePath = join(tmpdir(), `btuin-devtools-${Date.now()}-${Math.random()}.log`); + + const keyHandlers: any[] = []; + const terminal: TerminalAdapter = { + setBracketedPaste: () => {}, + setupRawMode: () => {}, + clearScreen: () => {}, + moveCursor: () => {}, + cleanupWithoutClear: () => {}, + patchConsole: () => () => {}, + startCapture: () => {}, + stopCapture: () => {}, + onKey: (callback: any) => { + keyHandlers.push(callback); + }, + getTerminalSize: () => ({ rows: 10, cols: 40 }), + disposeSingletonCapture, + write: () => {}, + }; + const platform = { + onStdoutResize: () => () => {}, + onExit: () => {}, + onSigint: () => {}, + onSigterm: () => {}, + exit: () => {}, + }; + + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + + beforeEach(() => { + keyHandlers.length = 0; + process.stdout.write = (() => true) as any; + process.stderr.write = (() => true) as any; + try { + rmSync(filePath, { force: true }); + } catch {} + }); + + afterEach(() => { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + try { + disposeSingletonCapture(); + } catch {} + try { + stopCapture(); + } catch {} + try { + rmSync(filePath, { force: true }); + } catch {} + }); + + it("should write captured console logs as JSONL to file", async () => { + const unpatch = patchConsole(); + const inst = app({ + terminal, + platform, + devtools: { enabled: true, stream: { file: filePath } }, + init: () => ({}), + render: () => Block(Text("X")), + }); + + await inst.mount({ rows: 10, cols: 40 }); + + // DevTools is lazily loaded; yield once to let the controller initialize. + await new Promise((r) => setTimeout(r, 0)); + console.log("hello"); + inst.unmount(); + unpatch(); + + const content = await (async () => { + for (let i = 0; i < 50; i++) { + try { + return readFileSync(filePath, "utf8"); + } catch { + await new Promise((r) => setTimeout(r, 5)); + } + } + return readFileSync(filePath, "utf8"); + })(); + const first = content.split("\n").find(Boolean); + expect(first).toBeTruthy(); + const parsed = JSON.parse(first!); + expect(parsed.text).toBe("hello"); + expect(parsed.type).toBe("stdout"); + expect(typeof parsed.timestamp).toBe("number"); + }); + + it("should stream captured console logs as JSONL over TCP", async () => { + const unpatch = patchConsole(); + + let listenInfo: { host: string; port: number } | null = null; + const inst = app({ + terminal, + platform, + devtools: { + enabled: true, + stream: { + tcp: { + host: "127.0.0.1", + port: 0, + onListen: (info: { host: string; port: number }) => { + listenInfo = info; + }, + }, + }, + }, + init: () => ({}), + render: () => Block(Text("X")), + }); + + try { + await inst.mount({ rows: 10, cols: 40 }); + + for (let i = 0; i < 50 && !listenInfo; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + + // Some sandboxed environments prohibit binding to TCP ports (EPERM). + // In that case, treat as a no-op and skip assertions. + if (!listenInfo) return; + + const received = await new Promise((resolve, reject) => { + const socket = net.connect({ host: listenInfo!.host, port: listenInfo!.port }, () => { + socket.setEncoding("utf8"); + console.log("hello"); + }); + + let buf = ""; + const timer = setTimeout(() => { + try { + socket.destroy(); + } catch {} + reject(new Error("timeout waiting for TCP log line")); + }, 500); + + socket.on("data", (chunk) => { + buf += chunk; + const line = buf.split("\n").find(Boolean); + if (line) { + clearTimeout(timer); + try { + socket.destroy(); + } catch {} + resolve(line); + } + }); + socket.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); + + const parsed = JSON.parse(received); + expect(parsed.text).toBe("hello"); + expect(parsed.type).toBe("stdout"); + expect(typeof parsed.timestamp).toBe("number"); + } finally { + inst.unmount(); + unpatch(); + } + }); +}); diff --git a/tests/units/runtime/use-log.test.ts b/tests/units/runtime/use-log.test.ts new file mode 100644 index 0000000..b5b2741 --- /dev/null +++ b/tests/units/runtime/use-log.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { createComponent, mountComponent, unmountComponent } from "@/components"; +import { Block } from "@/view/primitives"; +import { + patchConsole, + startCapture, + stopCapture, + disposeSingletonCapture, +} from "@/terminal/capture"; +import { useLog, type UseLogResult } from "@/hooks/use-log"; + +describe("useLog", () => { + let originalStdoutWrite: typeof process.stdout.write; + let originalStderrWrite: typeof process.stderr.write; + + beforeEach(() => { + originalStdoutWrite = process.stdout.write; + originalStderrWrite = process.stderr.write; + process.stdout.write = (() => true) as any; + process.stderr.write = (() => true) as any; + }); + + afterEach(() => { + try { + disposeSingletonCapture(); + } catch {} + try { + stopCapture(); + } catch {} + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + }); + + it("should update lines when console output is captured", () => { + const unpatch = patchConsole(); + startCapture(); + + let log: UseLogResult | null = null; + const Comp = createComponent({ + init: () => { + log = useLog({ maxLines: 50 }); + }, + render: () => Block(), + }); + + const mounted = mountComponent(Comp); + + console.log("hello"); + + expect(log).not.toBeNull(); + expect(log!.lines.value.at(-1)?.text).toBe("hello"); + expect(log!.lines.value.at(-1)?.type).toBe("stdout"); + + unmountComponent(mounted); + unpatch(); + }); +}); diff --git a/tests/units/terminal/capture.test.ts b/tests/units/terminal/capture.test.ts index 7136efa..dd84c7b 100644 --- a/tests/units/terminal/capture.test.ts +++ b/tests/units/terminal/capture.test.ts @@ -17,12 +17,14 @@ import { expect, describe, it, beforeEach, afterEach } from "bun:test"; describe("Output Capture", () => { // Mock process.stdout.write and process.stderr.write - const originalStdoutWrite = process.stdout.write; - const originalStderrWrite = process.stderr.write; + let originalStdoutWrite: typeof process.stdout.write; + let originalStderrWrite: typeof process.stderr.write; let stdoutOutput = ""; let stderrOutput = ""; beforeEach(() => { + originalStdoutWrite = process.stdout.write; + originalStderrWrite = process.stderr.write; stdoutOutput = ""; stderrOutput = ""; process.stdout.write = ((str: string) => { @@ -146,6 +148,19 @@ describe("Output Capture", () => { capture.dispose(); }); + + it("should notify subscribers for new lines", (done) => { + const capture = createConsoleCapture({ maxLines: 10 }); + const dispose = capture.subscribe((line) => { + expect(line.text).toBe("hello"); + expect(line.type).toBe("stdout"); + dispose(); + capture.dispose(); + done(); + }); + + process.stdout.write("hello\n"); + }); }); describe("getConsoleCaptureInstance", () => {