From 150250320d2a8fcdefea43ad6d0e0d783c52abed Mon Sep 17 00:00:00 2001 From: Ben Dodson Date: Mon, 24 Aug 2026 11:26:49 -0700 Subject: [PATCH 1/2] Add browser-based Valdi debugger --- .github/workflows/test-cli-linux.yml | 2 + docs/docs/command-line-references.md | 15 + docs/docs/workflow-inspector.md | 8 +- npm_modules/cli/README.md | 8 + npm_modules/cli/debugger/README.md | 103 ++ npm_modules/cli/debugger/debugger-actions.js | 320 ++++ npm_modules/cli/debugger/debugger-api.js | 191 ++ .../cli/debugger/debugger-bootstrap.js | 196 ++ npm_modules/cli/debugger/debugger-model.js | 631 +++++++ .../cli/debugger/debugger-performance.js | 139 ++ .../cli/debugger/debugger-preview-html.js | 311 ++++ npm_modules/cli/debugger/debugger-render.js | 507 ++++++ npm_modules/cli/debugger/debugger-runtime.js | 371 ++++ npm_modules/cli/debugger/debugger-session.js | 140 ++ npm_modules/cli/debugger/debugger-state.js | 171 ++ npm_modules/cli/debugger/debugger.css | 1574 +++++++++++++++++ npm_modules/cli/debugger/index.html | 219 +++ npm_modules/cli/package.json | 1 + npm_modules/cli/src/commands/debugger.ts | 89 + .../src/commands/inspect_commands/snapshot.ts | 2 +- .../cli/src/commands/inspect_commands/tree.ts | 2 +- npm_modules/cli/src/core/packageFiles.spec.ts | 194 +- npm_modules/cli/src/debugger/server.spec.ts | 589 ++++++ npm_modules/cli/src/debugger/server.ts | 1565 ++++++++++++++++ .../cli/src/utils/daemonClient.spec.ts | 63 + npm_modules/cli/src/utils/daemonClient.ts | 163 +- .../src/IRenderedVirtualNodeData.ts | 399 ++++- .../valdi_core/src/RootComponentsManager.ts | 8 +- .../valdi_core/src/debugging/Messages.ts | 1 + .../test/IRenderedVirtualNodeData.spec.ts | 246 +++ .../test/RootComponentsManager.spec.ts | 77 + 31 files changed, 8233 insertions(+), 72 deletions(-) create mode 100644 npm_modules/cli/debugger/README.md create mode 100644 npm_modules/cli/debugger/debugger-actions.js create mode 100644 npm_modules/cli/debugger/debugger-api.js create mode 100644 npm_modules/cli/debugger/debugger-bootstrap.js create mode 100644 npm_modules/cli/debugger/debugger-model.js create mode 100644 npm_modules/cli/debugger/debugger-performance.js create mode 100644 npm_modules/cli/debugger/debugger-preview-html.js create mode 100644 npm_modules/cli/debugger/debugger-render.js create mode 100644 npm_modules/cli/debugger/debugger-runtime.js create mode 100644 npm_modules/cli/debugger/debugger-session.js create mode 100644 npm_modules/cli/debugger/debugger-state.js create mode 100644 npm_modules/cli/debugger/debugger.css create mode 100644 npm_modules/cli/debugger/index.html create mode 100644 npm_modules/cli/src/commands/debugger.ts create mode 100644 npm_modules/cli/src/debugger/server.spec.ts create mode 100644 npm_modules/cli/src/debugger/server.ts create mode 100644 npm_modules/cli/src/utils/daemonClient.spec.ts create mode 100644 src/valdi_modules/src/valdi/valdi_core/test/IRenderedVirtualNodeData.spec.ts create mode 100644 src/valdi_modules/src/valdi/valdi_core/test/RootComponentsManager.spec.ts diff --git a/.github/workflows/test-cli-linux.yml b/.github/workflows/test-cli-linux.yml index c1198fce6..feedaac02 100644 --- a/.github/workflows/test-cli-linux.yml +++ b/.github/workflows/test-cli-linux.yml @@ -5,6 +5,7 @@ on: paths: - 'npm_modules/cli/src/**' - 'npm_modules/cli/test/**' + - 'npm_modules/cli/debugger/**' - 'npm_modules/cli/package.json' - '.github/workflows/test-cli-linux.yml' push: @@ -13,6 +14,7 @@ on: paths: - 'npm_modules/cli/src/**' - 'npm_modules/cli/test/**' + - 'npm_modules/cli/debugger/**' - 'npm_modules/cli/package.json' - '.github/workflows/test-cli-linux.yml' workflow_dispatch: diff --git a/docs/docs/command-line-references.md b/docs/docs/command-line-references.md index ccf3245a2..1d52d216d 100644 --- a/docs/docs/command-line-references.md +++ b/docs/docs/command-line-references.md @@ -40,6 +40,7 @@ Commands: valdi export Build and export a Valdi library valdi hotreload [--module module_name] [--target Starts the hotreloader for the application target_name] + valdi debugger Starts the Valdi debugger web interface valdi test [--module module_name] [--target target_name] Runs tests for given module(s) or target(s). Runs all tests if no module or target is specified. valdi lint Checks and formats the code @@ -151,6 +152,20 @@ Starts the Valdi [hotreloader](./start-about.md#prototype-quickly-with-hot-reloa - The `--target` option should be a valid Bazel target (ex: `//:hello_world_hotreload`). - The `--module` option will query and run targets in the current workspace which match the module_name.

+`valdi debugger [--host host] [--port port] [--strict-port] [--json]`\ +Starts a local browser-based Valdi debugger web interface. The debugger attaches +to running Valdi daemon targets and exposes live view hierarchy, preview, +inspector data, element snapshots, heap dumps, and runtime logs. CPU profiling +uses a separate Hermes debugger connection. + +- The default host is `127.0.0.1`; the debugger rejects non-loopback bind + addresses because snapshots can contain application data. +- The preferred port is `8765`; if it is busy, the command selects the next + available port so multiple local debugger sessions can run at once. +- Use `--strict-port` to fail instead of auto-selecting another port. +- Use `--json` to print one machine-readable startup object with the selected + `url`, `port`, `requestedPort`, and `portWasAutoSelected` fields.

+ `valdi test [--module module_name] [--target target_name]`\ Executes the test(s) for the provided targets. Note that multiple modules OR targets can be provided to execute all tests simultaneously. If no modules or targets are provided, ALL tests within the current workspace will be ran.

diff --git a/docs/docs/workflow-inspector.md b/docs/docs/workflow-inspector.md index 5a5911e70..2ee968acf 100644 --- a/docs/docs/workflow-inspector.md +++ b/docs/docs/workflow-inspector.md @@ -24,6 +24,13 @@ Valdi Inspector is a desktop application, written in Valdi itself, which can be ``` valdi hotreload ``` +* To launch the browser-based debugger: + ``` + valdi debugger + ``` + Open the printed `VALDI_DEBUGGER_URL` to inspect running Valdi targets. The + command auto-selects a free local port when `8765` is already in use, and + `--json` prints startup details for automation. * To just launch the inspector: ``` ./scripts/start_inspector.sh @@ -63,4 +70,3 @@ The hot reloader establishes a TCP connection between the device/simulator and t - diff --git a/npm_modules/cli/README.md b/npm_modules/cli/README.md index d57cfd8dd..4d5cf964b 100644 --- a/npm_modules/cli/README.md +++ b/npm_modules/cli/README.md @@ -82,6 +82,14 @@ For complete documentation, see: - Enables instant hot reload during development - Watches for file changes and updates app in milliseconds +**`valdi debugger`** - Local debugger web interface +- Starts the browser-based Valdi debugger at `127.0.0.1` +- Uses live daemon data from running Valdi targets for view hierarchy, preview, inspector state, snapshots, heap, and runtime logs +- Captures CPU profiles through a separate Hermes debugger connection +- Restricts the server to loopback addresses because debugger snapshots can contain application data +- Prefers port `8765` and automatically selects the next available port so multiple local sessions can run at once +- Supports `--json` for automation-friendly startup output + **`valdi skills`** - AI assistant skills - Installs Valdi context files into Claude Code, Cursor, or GitHub Copilot so AI tools generate correct Valdi code instead of React patterns - `valdi skills install` — auto-detects installed AI tools and installs all skills diff --git a/npm_modules/cli/debugger/README.md b/npm_modules/cli/debugger/README.md new file mode 100644 index 000000000..c1889fac6 --- /dev/null +++ b/npm_modules/cli/debugger/README.md @@ -0,0 +1,103 @@ +# Valdi Debugger Frontend + +This directory is the packaged browser UI served by `valdi debugger`. +It is intentionally local-only, dependency-free, and small enough to ship with +the CLI package. + +## File Map + +- `index.html`: static shell and DOM anchors for the debugger UI. +- `debugger.css`: themes, layout, controls, preview, inspector, and responsive styles. +- `debugger-state.js`: shared state, DOM references, constants, and action parameter helpers. +- `debugger-api.js`: fetch helpers, action stream, and development reload stream. +- `debugger-model.js`: snapshot normalization, tree traversal, bounds, issues, and selection helpers. +- `debugger-preview-html.js`: inert HTML projection of the hot-reloaded snapshot tree. +- `debugger-render.js`: header, target list, tree, preview overlay, inspector, and export rendering. +- `debugger-runtime.js`: target discovery, snapshots, runtime log streaming, heap, and copy/export helpers. +- `debugger-performance.js`: Hermes CPU profile controls. +- `debugger-actions.js`: UI actions, command prompt handling, auto-refresh, and externally driven debugger actions. +- `debugger-session.js`: `sessionStorage` restore/persist for reload-friendly debugger state. +- `debugger-bootstrap.js`: DOM event wiring and boot sequence. + +Scripts are loaded as classic browser scripts in the order listed in +`index.html`. There is no module loader or bundler for this frontend; shared +functions and variables are intentionally global within the page. + +## Server Contract + +The local HTTP server lives in `../src/debugger/server.ts`. It serves these +assets and proxies debugger requests to Valdi daemon or Hermes endpoints. + +Important routes: + +- `/api/status`: probes daemon targets and hot reload proxy state. +- `/api/snapshot`: fetches the selected target's view tree and preview data. +- `/api/runtime-logs` and `/api/runtime-logs/stream`: read and stream target logs. +- `/api/debugger/state`, `/api/debugger/events`, and `/api/debugger/actions`: keep the browser UI and external agents in sync. +- `/api/performance/profile/*`: list Hermes contexts and capture CPU profiles. + +Renderer tracing is intentionally not part of this foundation. It requires the +separate runtime and native renderer-instrumentation stack; land that stack +before adding renderer trace routes or controls to this debugger. Hermes CPU +profiling uses the existing inspector transport and has no such prerequisite. +Target input forwarding and data/network provider tabs should likewise land +with their runtime-side contracts and end-to-end tests rather than as inactive +browser-only surfaces. +Web-renderer inspection should land together with its first-party bridge rather +than expose an inert preview flag from this foundation. + +Detailed debugger snapshots explicitly opt in to component ViewModel and state +serialization. That data can be sensitive, is bounded by a per-field and +whole-tree character budget, and is never included in ordinary `valdi inspect +tree` requests. Auto-refresh starts disabled so serialization remains a +deliberate local debugging action. The server rejects non-loopback Host, +Origin, and cross-site browser API requests. + +## Development Loop + +For an installed CLI: + +```bash +valdi debugger +``` + +For local CLI development, build the CLI, then run the built entrypoint: + +```bash +cd npm_modules/cli +npm run build +node dist/index.js debugger --host 127.0.0.1 --port 8765 +``` + +The synthetic native-tree preview never auto-loads projected HTTP(S) image, +video, CSS background, or WebView resources. Only `data:` and `blob:` media are +assigned; WebView contents are represented by an inert placeholder. + +The debugger server watches `.html`, `.css`, and `.js` files in this directory +and emits `/api/dev-events`; the browser reloads itself when these files change. +If you add a new static asset type, update the server MIME map and watcher. + +Session state is persisted in `sessionStorage` under +`valdi.debugger.session.v1`, so normal debugger refreshes should preserve the +active section, selected target/node, filters, expanded tree nodes, and capture +settings. + +## Validation + +For frontend-only changes: + +```bash +for file in npm_modules/cli/debugger/debugger*.js; do node -c "$file" || exit 1; done +git diff --check +``` + +For changes that touch the server or CLI TypeScript: + +```bash +cd npm_modules/cli +npm run build +node_modules/.bin/tsc --noEmit --project tsconfig.dist.json +``` + +When validating the full ordered browser bundle, concatenate the scripts in the +same order as `index.html` and parse the result with `new Function(...)`. diff --git a/npm_modules/cli/debugger/debugger-actions.js b/npm_modules/cli/debugger/debugger-actions.js new file mode 100644 index 000000000..20b531fb1 --- /dev/null +++ b/npm_modules/cli/debugger/debugger-actions.js @@ -0,0 +1,320 @@ +// Section switching, auto-refresh, action dispatch, and console commands. +function normalizeSection(section) { + const normalized = String(section || '') + .trim() + .toLowerCase(); + if (normalized === 'logger' || normalized === 'loger') return 'logs'; + return normalized; +} + +function setActiveSection(section) { + const normalized = normalizeSection(section); + if (!elements.sectionPanels.some(panel => panel.dataset.sectionPanel === normalized)) { + addLog('warn', 'debugger', `Unknown debugger section: ${section}.`); + return; + } + + state.activeSection = normalized; + elements.sectionButtons.forEach(button => { + button.classList.toggle('active', button.dataset.sectionButton === normalized); + }); + elements.sectionPanels.forEach(panel => { + panel.classList.toggle('active', panel.dataset.sectionPanel === normalized); + }); + if (normalized === 'logs') { + renderLogs(); + startRuntimeLogStream({ silent: true }); + } else { + stopRuntimeLogStream(); + } + if (normalized === UI_SECTION && state.autoRefresh && state.source === 'daemon' && isDebuggerAttached()) { + window.setTimeout(() => refreshLiveSnapshot(), 0); + } +} + +function shouldAutoRefresh() { + return !document.hidden && !state.performance.profileActive; +} + +function shouldAutoRefreshLiveSnapshot() { + return ( + shouldAutoRefresh() && state.activeSection === UI_SECTION && !document.querySelector('.performance-panel:hover') + ); +} + +function autoRefreshLiveDebugger() { + if (!shouldAutoRefresh()) { + return; + } + + if (state.followLatestTarget && !state.manualDetach) { + void refreshTargets({ + silent: true, + autoAttach: state.activeSection === UI_SECTION, + skipSnapshotIfSame: state.activeSection !== UI_SECTION, + queueIfBusy: true, + }); + return; + } + + if (state.source !== 'daemon' || !isDebuggerAttached()) { + return; + } + + if (shouldAutoRefreshLiveSnapshot()) { + void loadRealSnapshot(null, { silent: true, preserveSelection: true, queueIfBusy: true }); + } +} + +function setAutoRefresh(enabled, options = {}) { + state.autoRefresh = enabled; + if (state.autoRefreshTimer) { + clearInterval(state.autoRefreshTimer); + state.autoRefreshTimer = null; + } + + if (enabled) { + state.autoRefreshTimer = window.setInterval(() => { + autoRefreshLiveDebugger(); + }, AUTO_REFRESH_INTERVAL_MS); + if (!options.silent) addLog('info', 'debugger', 'Auto-refresh enabled.'); + } else { + if (!options.silent) addLog('info', 'debugger', 'Auto-refresh disabled.'); + } + renderHeader(); +} + +function refreshLiveSnapshot() { + const target = state.snapshot.target || {}; + if (state.followLatestTarget) { + state.manualDetach = false; + refreshTargets(); + return; + } + if (state.source === 'daemon' || (target.clientId && target.contextId)) { + state.manualDetach = false; + loadRealSnapshot(null, { preserveSelection: true, queueIfBusy: true }); + } else { + addLog('info', 'daemon', 'No live Valdi daemon target is selected yet. Refreshing targets first.'); + refreshTargets(); + } +} + +function scrollSelectedTreeNodeIntoView() { + const selectedRow = Array.from(elements.tree.querySelectorAll('.tree-node')).find( + row => row.dataset.nodeId === state.selectedNodeId, + ); + selectedRow?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); +} + +function selectNode(id, options = {}) { + const node = findNode(id); + if (!node) { + addLog('warn', 'console', `No node found for id ${id}.`); + return; + } + state.selectedNodeId = getNodeId(node); + state.selectedSnapshotImage = null; + expandPathToNode(state.selectedNodeId); + renderTree(); + if (options.scrollTree !== false) { + window.requestAnimationFrame(scrollSelectedTreeNodeIntoView); + } + renderOverlay(); + renderInspector(); +} + +function setActiveTab(tab) { + if (!['overview', 'props', 'state', 'issues', 'raw'].includes(tab)) { + addLog('warn', 'debugger', `Unknown inspector tab: ${tab}.`); + return; + } + state.activeTab = tab; + document + .querySelectorAll('.tabs button') + .forEach(button => button.classList.toggle('active', button.dataset.tab === tab)); + renderInspector(); +} + +function attachDebuggerView() { + state.manualDetach = false; + state.followLatestTarget = true; + refreshTargets(); +} + +function detachDebuggerView() { + state.attached = false; + state.manualDetach = true; + state.rootSnapshotImage = null; + state.rootSnapshotRequestId++; + stopRuntimeLogStream(); + addLog('warn', 'proxy', 'Detached debugger view from live daemon data.'); + render(); +} + +function setTargetPort(port) { + elements.portSelect.value = String(port); + state.snapshot.target = { + ...state.snapshot.target, + proxyPort: port, + port, + }; + state.attached = false; + state.manualDetach = false; + state.followLatestTarget = true; + state.rootSnapshotImage = null; + state.rootSnapshotRequestId++; + stopRuntimeLogStream(); + render(); +} + +function clearDebuggerLogs() { + state.snapshot.logs = []; + renderLogs(); +} + +function applyDebuggerAction(action, params = {}) { + if (action === 'selectNode') { + const nodeId = actionString(params, 'id', 'nodeId'); + if (nodeId) selectNode(nodeId); + return; + } + + if (action === 'selectTarget') { + const targetId = actionString(params, 'id', 'targetId'); + if (targetId) selectTarget(targetId); + return; + } + + if (action === 'setActiveTab') { + setActiveTab(actionString(params, 'tab')); + return; + } + + if (action === 'setActiveSection') { + setActiveSection(actionString(params, 'section')); + return; + } + + if (action === 'setOverlayMode') { + setOverlayMode(actionString(params, 'mode')); + return; + } + + if (action === 'setAutoRefresh') { + setAutoRefresh(actionBoolean(params, 'enabled', state.autoRefresh)); + return; + } + + if (action === 'setPort') { + const port = actionNumber(params, 'port'); + if (port !== null) setTargetPort(port); + return; + } + + if (action === 'attach') { + attachDebuggerView(); + return; + } + + if (action === 'detach') { + detachDebuggerView(); + return; + } + + if (action === 'refreshTargets') { + refreshTargets(); + return; + } + + if (action === 'refreshSnapshot') { + refreshLiveSnapshot(); + return; + } + + if (action === 'clearLogs') { + clearDebuggerLogs(); + return; + } + + if (action === 'captureElementSnapshot') { + void captureSelectedElementSnapshot(); + return; + } + + if (action === 'dumpHeap') { + void dumpHeap(); + return; + } + + if (action === 'refreshHermesContexts') { + void refreshProfileContexts({ silent: false }); + return; + } + + if (action === 'startCpuProfile') { + void startCpuProfile(); + return; + } + + if (action === 'stopCpuProfile') { + void stopCpuProfile(); + return; + } + + if (action === 'captureCpuProfile') { + void captureCpuProfile(); + return; + } + + addLog('warn', 'debugger', `Unknown debugger action: ${action}.`); +} + +function runCommand(rawCommand) { + const command = rawCommand.trim(); + if (!command) return; + addLog('info', 'console', `> ${command}`); + + if (command === 'help') { + addLog( + 'info', + 'console', + 'Commands: help, section , path, issues, select , connect, refresh, reload, status, snapshot, heap, profile, auto on, auto off, clear.', + ); + } else if (command.startsWith('section ')) { + void requestDebuggerAction('setActiveSection', { section: command.slice('section '.length).trim() }); + } else if (command === 'path') { + const path = getPathToNode(state.selectedNodeId) + .map(node => `${node.tag}#${getNodeId(node)}`) + .join(' > '); + addLog('info', 'inspector', path || 'No node selected.'); + } else if (command === 'issues') { + addLog( + 'info', + 'inspector', + `${state.snapshot.issues.length} issue(s): ${state.snapshot.issues.map(issue => issue.title).join(', ')}`, + ); + } else if (command.startsWith('select ')) { + void requestDebuggerAction('selectNode', { id: command.slice('select '.length).trim() }); + } else if (command === 'connect') { + void requestDebuggerAction('attach'); + } else if (command === 'status') { + void requestDebuggerAction('refreshTargets'); + } else if (command === 'refresh' || command === 'reload') { + void requestDebuggerAction('refreshSnapshot'); + } else if (command === 'snapshot') { + void requestDebuggerAction('captureElementSnapshot'); + } else if (command === 'heap') { + void requestDebuggerAction('dumpHeap'); + } else if (command === 'profile') { + void requestDebuggerAction('captureCpuProfile'); + } else if (command === 'auto on') { + void requestDebuggerAction('setAutoRefresh', { enabled: true }); + } else if (command === 'auto off') { + void requestDebuggerAction('setAutoRefresh', { enabled: false }); + } else if (command === 'clear') { + void requestDebuggerAction('clearLogs'); + } else { + addLog('warn', 'console', `Unknown command: ${command}. Try help.`); + } +} diff --git a/npm_modules/cli/debugger/debugger-api.js b/npm_modules/cli/debugger/debugger-api.js new file mode 100644 index 000000000..e9c83bc70 --- /dev/null +++ b/npm_modules/cli/debugger/debugger-api.js @@ -0,0 +1,191 @@ +// HTTP helpers and the external debugger action/event bridge. +async function apiGet(path, params = {}, options = {}) { + const url = new URL(path, window.location.origin); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.set(key, String(value)); + } + } + + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs || 8000); + try { + const response = await fetch(url, { cache: 'no-store', signal: controller.signal }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || `Request failed: ${response.status}`); + } + return data; + } catch (error) { + if (error.name === 'AbortError') { + throw new Error(`Timed out fetching ${url.pathname}.`); + } + throw error; + } finally { + window.clearTimeout(timeout); + } +} + +async function apiPost(path, params = {}, body = {}, options = {}) { + const url = new URL(path, window.location.origin); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== '') { + url.searchParams.set(key, String(value)); + } + } + + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs || 8000); + try { + const response = await fetch(url, { + method: 'POST', + cache: 'no-store', + signal: controller.signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || `Request failed: ${response.status}`); + } + return data; + } catch (error) { + if (error.name === 'AbortError') { + throw new Error(`Timed out posting ${url.pathname}.`); + } + throw error; + } finally { + window.clearTimeout(timeout); + } +} + +function startDebuggerDevReload() { + if (!window.EventSource || window.location.search.includes('noDevReload=1')) return; + const source = new EventSource('/api/dev-events'); + let loadedRevision = null; + + source.addEventListener('ready', event => { + const payload = JSON.parse(event.data); + loadedRevision = payload.revision; + }); + + source.addEventListener('reload', event => { + const payload = JSON.parse(event.data); + if (loadedRevision === null) { + loadedRevision = payload.revision; + return; + } + if (payload.revision > loadedRevision) { + persistDebuggerSessionState(); + window.location.reload(); + } + }); +} + +function startDebuggerActionStream() { + if (!window.EventSource) return; + const source = new EventSource('/api/debugger/events'); + state.debuggerEventStream = source; + + source.addEventListener('ready', event => { + state.debuggerEventsConnected = true; + applyDebuggerStatePayload(JSON.parse(event.data)); + }); + + source.addEventListener('debugger-action', event => { + state.debuggerEventsConnected = true; + applyDebuggerActionPayload(JSON.parse(event.data)); + }); + + source.addEventListener('error', () => { + state.debuggerEventsConnected = false; + }); +} + +function applyDebuggerStatePayload(payload) { + const debuggerState = payload?.state; + if (!debuggerState) return; + const revision = Number(debuggerState.revision) || 0; + if (revision && revision <= state.lastDebuggerRevision) return; + syncDebuggerSessionState(debuggerState); + + if (revision) state.lastDebuggerRevision = revision; +} + +function syncDebuggerSessionState(debuggerState) { + if (debuggerState.activeSection && debuggerState.activeSection !== state.activeSection) { + setActiveSection(debuggerState.activeSection); + } + + if (debuggerState.overlayMode && debuggerState.overlayMode !== state.overlayMode) { + setOverlayMode(debuggerState.overlayMode); + } + + if (debuggerState.activeTab && debuggerState.activeTab !== state.activeTab) { + setActiveTab(debuggerState.activeTab); + } + + if (typeof debuggerState.autoRefresh === 'boolean' && debuggerState.autoRefresh !== state.autoRefresh) { + setAutoRefresh(debuggerState.autoRefresh, { silent: true }); + } + + if ( + debuggerState.selectedNodeId && + debuggerState.selectedNodeId !== state.selectedNodeId && + findNode(debuggerState.selectedNodeId) + ) { + selectNode(debuggerState.selectedNodeId, { scrollTree: false }); + } +} + +function applyDebuggerActionPayload(payload) { + const revision = Number(payload?.state?.revision) || 0; + if (revision && revision <= state.lastDebuggerRevision) return; + if (payload?.action) { + applyDebuggerAction(payload.action, payload.params || {}); + } + if (revision) state.lastDebuggerRevision = revision; +} + +async function requestDebuggerAction(action, params = {}) { + const shouldApplyLocally = !state.debuggerEventsConnected; + if (shouldApplyLocally) { + applyDebuggerAction(action, params); + } + + try { + await apiPost( + '/api/debugger/actions', + {}, + { + action, + params, + source: 'ui', + }, + { timeoutMs: 5000 }, + ); + } catch (error) { + if (!shouldApplyLocally) { + applyDebuggerAction(action, params); + } + addLog('warn', 'debugger', `Debugger action bus unavailable; applied ${action} locally. ${error.message}`); + } +} + +function getSelectedTargetParams() { + const target = state.snapshot.target || {}; + return { + port: target.proxyPort || target.port || selectedDaemonPort(), + clientId: target.clientId, + contextId: target.contextId, + }; +} + +function hasSelectedLiveTarget() { + const params = getSelectedTargetParams(); + return Boolean(params.clientId && params.contextId && Number.isFinite(params.port)); +} + +function isDebuggerAttached() { + return state.attached || (state.source === 'daemon' && !state.manualDetach && hasSelectedLiveTarget()); +} diff --git a/npm_modules/cli/debugger/debugger-bootstrap.js b/npm_modules/cli/debugger/debugger-bootstrap.js new file mode 100644 index 000000000..90a7ae974 --- /dev/null +++ b/npm_modules/cli/debugger/debugger-bootstrap.js @@ -0,0 +1,196 @@ +// DOM event wiring and initial debugger boot sequence. +elements.screen.addEventListener('click', event => { + selectPreviewNodeAtEvent(event); +}); + +elements.screen.addEventListener('mousedown', event => { + const overlayNode = event.target.closest('.overlay-node'); + if (overlayNode && elements.screen.contains(overlayNode)) { + event.preventDefault(); + } +}); + +elements.previewStage.addEventListener('wheel', forwardPreviewWheelToPage, { passive: false }); + +document.addEventListener('click', event => { + const toggle = event.target.closest('.tree-toggle'); + if (toggle && !toggle.classList.contains('empty')) { + event.stopPropagation(); + toggleTreeNode(toggle.dataset.toggleNodeId); + return; + } + + const treeNode = event.target.closest('.tree-node'); + if (treeNode) void requestDebuggerAction('selectNode', { id: treeNode.dataset.nodeId }); + + const tab = event.target.closest('[data-tab]'); + if (tab) void requestDebuggerAction('setActiveTab', { tab: tab.dataset.tab }); + + const issue = event.target.closest('[data-issue-node]'); + if (issue && issue.dataset.issueNode) void requestDebuggerAction('selectNode', { id: issue.dataset.issueNode }); + + const target = event.target.closest('.target'); + if (target) void requestDebuggerAction('selectTarget', { id: target.dataset.targetId }); +}); + +document.addEventListener('keydown', event => { + if (event.key !== 'Enter' && event.key !== ' ') return; + const actionable = event.target.closest('.tree-node, .target, [data-issue-node]'); + if (!actionable) return; + event.preventDefault(); + actionable.click(); +}); + +function selectTarget(id) { + const target = debuggerTargets().find(candidate => candidate.id === id); + if (!target) return; + if (target.daemonPort && target.port) { + setTargetPort(target.port); + state.snapshot.target = { + ...state.snapshot.target, + ...target, + }; + addLog( + 'info', + 'daemon', + `Waiting for a Valdi context on ${target.name} :${target.port}; Auto will attach when one appears.`, + ); + render(); + refreshTargets({ autoAttach: true }); + return; + } + state.followLatestTarget = false; + if (target.clientId && target.contextId) { + loadRealSnapshot(target); + return; + } + state.snapshot.target = { + ...state.snapshot.target, + id: target.id, + name: target.name, + platform: target.platform, + state: 'attached', + }; + state.snapshot.targets = state.snapshot.targets.map(candidate => ({ + ...candidate, + state: candidate.id === id ? 'attached' : candidate.state === 'attached' ? 'available' : candidate.state, + })); + addLog('info', 'daemon', `Selected target ${target.name}.`); + render(); +} + +elements.treeSearch.addEventListener('input', renderTree); +elements.logSearch.addEventListener('input', renderLogs); + +elements.htmlPreviewRoot.addEventListener('click', event => { + selectHtmlPreviewNodeAtEvent(event); +}); + +document + .getElementById('modeLive') + .addEventListener('click', () => void requestDebuggerAction('setOverlayMode', { mode: 'live' })); +document + .getElementById('modeIssues') + .addEventListener('click', () => void requestDebuggerAction('setOverlayMode', { mode: 'issues' })); + +function setOverlayMode(mode) { + const normalizedMode = mode === 'views' || mode === 'components' ? 'live' : mode; + if (!['live', 'issues'].includes(normalizedMode)) { + addLog('warn', 'debugger', `Unknown preview mode: ${mode}.`); + return; + } + state.overlayMode = normalizedMode; + document.querySelectorAll('.segmented button').forEach(button => button.classList.remove('active')); + document.getElementById(`mode${normalizedMode[0].toUpperCase()}${normalizedMode.slice(1)}`).classList.add('active'); + renderOverlay(); +} + +elements.closeExportButton.addEventListener('click', () => { + elements.exportPanel.classList.remove('open'); +}); + +elements.copyExportButton.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(elements.exportText.value); + addLog('info', 'debugger', 'Copied JSON export to clipboard.'); + } catch (error) { + elements.exportText.focus(); + elements.exportText.select(); + addLog('warn', 'debugger', `Clipboard write failed: ${error.message}`); + } +}); + +document.getElementById('attachButton').addEventListener('click', () => { + void requestDebuggerAction(isDebuggerAttached() ? 'detach' : 'attach'); +}); + +document.getElementById('reloadButton').addEventListener('click', () => { + void requestDebuggerAction('refreshSnapshot'); +}); + +document.getElementById('refreshTargets').addEventListener('click', () => { + void requestDebuggerAction('refreshTargets'); +}); + +elements.portSelect.addEventListener('change', () => { + const value = elements.portSelect.value; + void requestDebuggerAction('setPort', { port: Number.parseInt(value, 10) }); +}); + +elements.sectionButtons.forEach(button => { + button.addEventListener('click', () => { + void requestDebuggerAction('setActiveSection', { section: button.dataset.sectionButton }); + }); +}); + +elements.snapshotButton.addEventListener('click', () => void requestDebuggerAction('captureElementSnapshot')); +elements.copyPreviewButton.addEventListener('click', () => void copyPreview()); +elements.autoRefreshToggle.addEventListener( + 'change', + () => void requestDebuggerAction('setAutoRefresh', { enabled: elements.autoRefreshToggle.checked }), +); +elements.profileRefreshButton.addEventListener('click', () => void requestDebuggerAction('refreshHermesContexts')); +elements.profileStartButton.addEventListener('click', () => void requestDebuggerAction('startCpuProfile')); +elements.profileStopButton.addEventListener('click', () => void requestDebuggerAction('stopCpuProfile')); +elements.profileCaptureButton.addEventListener('click', () => void requestDebuggerAction('captureCpuProfile')); +elements.profileExportButton.addEventListener('click', exportCpuProfile); + +document.getElementById('copyPathButton').addEventListener('click', async () => { + const path = getPathToNode(state.selectedNodeId) + .map(node => `${node.tag}#${getNodeId(node)}`) + .join(' > '); + if (!path) { + addLog('warn', 'inspector', 'No selected node path to copy.'); + return; + } + try { + await navigator.clipboard.writeText(path); + addLog('info', 'inspector', 'Copied selected node path.'); + } catch (error) { + addLog('warn', 'inspector', `Copy failed: ${error.message}`); + } +}); + +document.getElementById('clearLogsButton').addEventListener('click', () => { + void requestDebuggerAction('clearLogs'); +}); + +document.getElementById('commandForm').addEventListener('submit', event => { + event.preventDefault(); + const input = document.getElementById('commandInput'); + runCommand(input.value); + input.value = ''; +}); + +window.addEventListener('resize', renderOverlay); + +startDebuggerDevReload(); +startDebuggerActionStream(); +const restoredDebuggerSession = restoreDebuggerSessionState(); +state.snapshot = decorateSnapshot(state.snapshot); +render(); +applyDebuggerSessionDomState(); +setAutoRefresh(state.autoRefresh, { silent: true }); +installDebuggerSessionPersistence(); +void refreshProfileContexts({ silent: true }); +refreshTargets({ silent: restoredDebuggerSession, autoAttach: !state.manualDetach }); diff --git a/npm_modules/cli/debugger/debugger-model.js b/npm_modules/cli/debugger/debugger-model.js new file mode 100644 index 000000000..25f3d4955 --- /dev/null +++ b/npm_modules/cli/debugger/debugger-model.js @@ -0,0 +1,631 @@ +// Target selection, tree decoration, geometry, search, and analysis helpers. +function lastItem(items) { + return items[items.length - 1]; +} + +function matchingTargetForCurrentSelection(targets) { + const current = state.snapshot.target || {}; + if (!current.id && !current.clientId && !current.contextId) return null; + + return ( + targets.find(target => target.id === current.id) || + targets.find(target => target.clientId === current.clientId && target.contextId === current.contextId) || + targets.find( + target => + target.clientId === current.clientId && + target.applicationId === current.applicationId && + target.name === current.name, + ) || + null + ); +} + +function chooseLiveTarget(targets) { + const selectedPort = selectedDaemonPort(null); + const portTargets = selectedPort === null ? [] : targets.filter(target => target.port === selectedPort); + const candidates = portTargets.length ? portTargets : targets; + if (!state.followLatestTarget) { + return ( + matchingTargetForCurrentSelection(candidates) || + matchingTargetForCurrentSelection(targets) || + lastItem(candidates) + ); + } + + const current = state.snapshot.target || {}; + const appTargets = candidates.filter( + target => + current.clientId && target.clientId === current.clientId && target.applicationId === current.applicationId, + ); + return lastItem(appTargets.length ? appTargets : candidates); +} + +function markSelectedTarget(targets, selectedTarget) { + return targets.map(target => ({ + ...target, + state: target.id === selectedTarget.id ? 'attached' : 'available', + })); +} + +function decorateSnapshot(snapshot) { + if (!snapshot.tree) { + snapshot.target = snapshot.target || { ...emptyTarget }; + snapshot.targets = snapshot.targets || []; + snapshot.issues = snapshot.issues || []; + snapshot.logs = snapshot.logs || []; + state.geometry = null; + return snapshot; + } + decorateNode(snapshot.tree); + if (!hasAnyRenderableBounds(snapshot.tree)) { + ensureBounds(snapshot.tree); + } + snapshot.issues = mergeIssues(snapshot.issues || [], analyzeTree(snapshot.tree)); + state.geometry = computeGeometry(snapshot.tree); + return snapshot; +} + +function decorateNode(node, path = '0') { + if (node.element && node.element.frame) { + node.bounds = normalizeBounds(node.element.frame); + } + + if (!node.id) { + if (node.element && node.element.id !== undefined) { + node.id = String(node.element.id); + } else if (node.key !== undefined) { + node.id = `${node.tag}:${node.key}:${path}`; + } else { + node.id = `${node.tag}:${path}`; + } + } + + (node.children || []).forEach((child, index) => decorateNode(child, `${path}.${index}`)); + + if (!node.bounds && node.children && node.children.length) { + const childBounds = node.children.map(child => child.bounds).filter(Boolean); + if (childBounds.length) { + const minX = Math.min(...childBounds.map(bounds => bounds.x)); + const minY = Math.min(...childBounds.map(bounds => bounds.y)); + const maxX = Math.max(...childBounds.map(bounds => bounds.x + bounds.width)); + const maxY = Math.max(...childBounds.map(bounds => bounds.y + bounds.height)); + node.bounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + } +} + +function normalizeBounds(bounds) { + return { + x: Number.parseFloat(bounds.x) || 0, + y: Number.parseFloat(bounds.y) || 0, + width: Math.max(0, Number.parseFloat(bounds.width) || 0), + height: Math.max(0, Number.parseFloat(bounds.height) || 0), + }; +} + +function getNodeId(node) { + if (node.id !== undefined) return String(node.id); + if (node.element && node.element.id !== undefined) return String(node.element.id); + if (node.key !== undefined) return `${node.tag}:${node.key}`; + return node.tag; +} + +function getNodeKind(node) { + return node.component ? 'component' : 'element'; +} + +function normalizeLabelValue(value) { + if (value === undefined || value === null || value === '') return ''; + if (typeof value === 'string') return value.replace(/^"|"$/g, '').trim(); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) { + return value.map(normalizeLabelValue).filter(isReadableLabelToken).join(' ').trim(); + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function isReadableLabelToken(value) { + if (!value) return false; + if (/^-?\d+(\.\d+)?$/.test(value)) return false; + if (/^#[0-9a-f]{3,8}$/i.test(value)) return false; + if (/^rgba?\(/i.test(value)) return false; + if (/^system(-[a-z]+)?\s+\d+$/i.test(value)) return false; + if (/^(prose|caption|heading|body|title|subtitle|primary|secondary|emphasis)$/i.test(value)) return false; + return true; +} + +function firstReadableValue(sources, keys) { + for (const source of sources) { + if (!source || typeof source !== 'object') continue; + for (const key of keys) { + const value = normalizeLabelValue(source[key]); + if (value) return value; + } + } + return ''; +} + +function truncateLabel(value, maxLength = 58) { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}...` : value; +} + +function overlayNodeLabel(node) { + return `${node.tag} #${getNodeId(node)}`; +} + +function overlayNodeDetail(node) { + const attrs = getNodeAttributes(node); + const text = firstReadableValue( + [attrs, node.viewModel, node], + [ + 'accessibilityLabel', + 'accessibilityValue', + 'accessibilityHint', + 'accessible', + 'ariaLabel', + 'label', + 'value', + 'title', + 'text', + 'placeholder', + 'name', + 'accessibilityId', + 'id', + ], + ); + const style = firstReadableValue( + [attrs, node], + ['style', 'font', 'color', 'backgroundColor', 'iosClass', 'androidClass', 'macosClass', 'webClass'], + ); + return truncateLabel(text || style || '', 72); +} + +function describeOverlayNode(node) { + const label = overlayNodeLabel(node); + const detail = overlayNodeDetail(node); + return detail ? `${label}\n${detail}` : label; +} + +function getNodeAttributes(node) { + return (node.element && node.element.attributes) || {}; +} + +function getNumericAttribute(node, name) { + const value = getNodeAttributes(node)[name]; + if (value === undefined || value === null) return 0; + const parsed = Number.parseFloat(String(value).replace(/^"|"$/g, '')); + return Number.isFinite(parsed) ? parsed : 0; +} + +function getScrollOffset(node) { + return { + x: getNumericAttribute(node, 'contentOffsetX'), + y: getNumericAttribute(node, 'contentOffsetY'), + }; +} + +function getTranslation(node) { + return { + x: getNumericAttribute(node, 'translationX'), + y: getNumericAttribute(node, 'translationY'), + }; +} + +function hasScrollState(node) { + const attrs = getNodeAttributes(node); + return attrs.contentOffsetX !== undefined || attrs.contentOffsetY !== undefined; +} + +function hasAnyRenderableBounds(root) { + let found = false; + walk(root, node => { + if (node.bounds || (node.element && node.element.frame)) found = true; + }); + return found; +} + +function hasLocalFrames(root) { + let found = false; + walk(root, node => { + if (node.element && node.element.frame) found = true; + }); + return found; +} + +function unionBounds(boundsList) { + const bounds = boundsList.filter(Boolean); + if (!bounds.length) return null; + const minX = Math.min(...bounds.map(item => item.x)); + const minY = Math.min(...bounds.map(item => item.y)); + const maxX = Math.max(...bounds.map(item => item.x + item.width)); + const maxY = Math.max(...bounds.map(item => item.y + item.height)); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + +function firstElementWithFrame(root) { + let result = null; + walk(root, node => { + if (!result && node.element && node.element.frame) { + const bounds = normalizeBounds(node.element.frame); + if (bounds.width > 0 && bounds.height > 0) result = node; + } + }); + return result; +} + +function computeGeometry(root) { + const localFrames = hasLocalFrames(root); + const map = new Map(); + + function visit(node, offset) { + const frame = node.element && node.element.frame ? normalizeBounds(node.element.frame) : null; + const absoluteCandidate = !localFrames && node.bounds ? normalizeBounds(node.bounds) : null; + let absolute = null; + let childOffset = offset; + + if (frame) { + absolute = { + x: offset.x + frame.x, + y: offset.y + frame.y, + width: frame.width, + height: frame.height, + }; + const scrollOffset = getScrollOffset(node); + const translation = getTranslation(node); + childOffset = { + x: absolute.x - scrollOffset.x + translation.x, + y: absolute.y - scrollOffset.y + translation.y, + }; + } else if (absoluteCandidate) { + absolute = absoluteCandidate; + } + + const childBounds = (node.children || []).map(child => visit(child, childOffset)).filter(Boolean); + if (!absolute || node.component) { + absolute = unionBounds(childBounds) || absolute; + } + + if (absolute) { + map.set(getNodeId(node), { + local: node.bounds ? normalizeBounds(node.bounds) : frame, + absolute, + }); + } + return absolute; + } + + const rootBounds = visit(root, { x: 0, y: 0 }); + const viewportNode = localFrames ? firstElementWithFrame(root) : root; + const viewportGeometry = viewportNode ? map.get(getNodeId(viewportNode)) : null; + const viewport = viewportGeometry?.absolute || rootBounds || { x: 0, y: 0, width: 390, height: 760 }; + return { + localFrames, + map, + viewport: { + x: viewport.x, + y: viewport.y, + width: Math.max(1, viewport.width), + height: Math.max(1, viewport.height), + }, + }; +} + +function getNodeGeometry(node) { + return state.geometry?.map.get(getNodeId(node)) || null; +} + +function getViewportBounds() { + return state.geometry?.viewport || { x: 0, y: 0, width: 390, height: 760 }; +} + +function getElementIdForNode(node) { + if (!node) return null; + if (node.element && node.element.id !== undefined) return Number(node.element.id); + const parsed = Number.parseInt(getNodeId(node), 10); + return Number.isNaN(parsed) ? null : parsed; +} + +function pointFromScreenEvent(event) { + const viewport = getViewportBounds(); + const screenBounds = elements.screen.getBoundingClientRect(); + const ratioX = Math.min(1, Math.max(0, (event.clientX - screenBounds.left) / Math.max(1, screenBounds.width))); + const ratioY = Math.min(1, Math.max(0, (event.clientY - screenBounds.top) / Math.max(1, screenBounds.height))); + return { + x: viewport.x + ratioX * viewport.width, + y: viewport.y + ratioY * viewport.height, + scaleX: viewport.width / Math.max(1, screenBounds.width), + scaleY: viewport.height / Math.max(1, screenBounds.height), + }; +} + +function boundsContainPoint(bounds, point) { + return ( + bounds && + point.x >= bounds.x && + point.y >= bounds.y && + point.x <= bounds.x + bounds.width && + point.y <= bounds.y + bounds.height + ); +} + +function findNodeAtPoint(point, predicate = () => true) { + const hits = []; + if (!hasSnapshotTree()) return null; + walk(state.snapshot.tree, (node, _parent, depth) => { + if (!predicate(node)) return; + const geometry = getNodeGeometry(node); + if (!boundsContainPoint(geometry?.absolute, point)) return; + const bounds = geometry.absolute; + hits.push({ + node, + depth, + area: Math.max(1, bounds.width * bounds.height), + }); + }); + + hits.sort((a, b) => b.depth - a.depth || a.area - b.area); + return hits[0]?.node || null; +} + +function findPreviewNodeAtEvent(event) { + const point = pointFromScreenEvent(event); + const overlayNode = event.target.closest('.overlay-node'); + const overlayTreeNode = + overlayNode && elements.screen.contains(overlayNode) ? findNode(overlayNode.dataset.nodeId) : null; + return overlayTreeNode && getElementIdForNode(overlayTreeNode) !== null + ? overlayTreeNode + : findNodeAtPoint(point, node => getElementIdForNode(node) !== null); +} + +function findOverlayNodeAtEvent(event) { + const overlayNode = event.target.closest('.overlay-node'); + if (!overlayNode || !elements.screen.contains(overlayNode)) return null; + return findNode(overlayNode.dataset.nodeId); +} + +function getPageScrollTarget() { + return document.scrollingElement || document.documentElement || document.body; +} + +function getPageScrollPosition() { + return { + x: window.scrollX || getPageScrollTarget().scrollLeft || 0, + y: window.scrollY || getPageScrollTarget().scrollTop || 0, + }; +} + +function restorePageScrollPosition(position) { + window.scrollTo(position.x, position.y); + const scrollTarget = getPageScrollTarget(); + scrollTarget.scrollLeft = position.x; + scrollTarget.scrollTop = position.y; +} + +function filterHierarchyToNode(id) { + elements.treeSearch.value = `#${id}`; +} + +function selectPreviewNode(node) { + const scrollPosition = getPageScrollPosition(); + const id = getNodeId(node); + filterHierarchyToNode(id); + selectNode(id, { scrollTree: false }); + restorePageScrollPosition(scrollPosition); + window.requestAnimationFrame(() => restorePageScrollPosition(scrollPosition)); +} + +function canScrollBy(element, deltaY) { + if (!element || !deltaY) return false; + const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight); + if (maxScrollTop <= 0) return false; + if (deltaY < 0) return element.scrollTop > 0; + return element.scrollTop < maxScrollTop; +} + +function forwardPreviewWheelToPage(event) { + if (event.target.closest('.html-preview-root')) return; + const scrollTarget = getPageScrollTarget(); + if (!canScrollBy(scrollTarget, event.deltaY)) return; + + event.preventDefault(); + scrollTarget.scrollBy({ + top: event.deltaY, + left: event.deltaX, + behavior: 'auto', + }); +} + +function selectPreviewNodeAtEvent(event) { + if (event.target.closest('.html-preview-root')) return; + const overlaySelection = findOverlayNodeAtEvent(event); + const selectedPreviewNode = overlaySelection || findPreviewNodeAtEvent(event); + + if (selectedPreviewNode) { + event.preventDefault(); + event.stopPropagation(); + selectPreviewNode(selectedPreviewNode); + } +} + +function isInteractiveNode(node) { + const attrs = getNodeAttributes(node); + return Boolean(attrs.onTap || attrs.onPress || attrs.onClick || attrs.title || node.tag === 'button'); +} + +function analyzeTree(root) { + const issues = []; + const seen = new Set(); + + function addIssue(issue) { + if (seen.has(issue.id)) return; + seen.add(issue.id); + issues.push(issue); + } + + walk(root, node => { + const id = getNodeId(node); + const attrs = getNodeAttributes(node); + const bounds = node.bounds ? normalizeBounds(node.bounds) : null; + if (!bounds) return; + + if (bounds.width <= 0 || bounds.height <= 0) { + addIssue({ + id: `bounds-${id}`, + severity: 'error', + nodeId: id, + title: 'Invalid bounds', + message: `${node.tag} #${id} has non-positive layout bounds.`, + }); + } + + if (isInteractiveNode(node) && (bounds.width < 44 || bounds.height < 44)) { + addIssue({ + id: `touch-${id}`, + severity: 'warn', + nodeId: id, + title: 'Small touch target', + message: `${node.tag} #${id} is ${bounds.width}x${bounds.height}. Native targets usually need at least 44x44.`, + }); + } + + if ( + node.tag === 'label' && + attrs.numberOfLines === '1' && + attrs.value && + String(attrs.value).length * 6 > bounds.width + ) { + addIssue({ + id: `label-${id}`, + severity: 'warn', + nodeId: id, + title: 'Possible label truncation', + message: `Label #${id} has numberOfLines=1 and text that may exceed its measured width.`, + }); + } + }); + + return issues.slice(0, 80); +} + +function mergeIssues(existing, generated) { + const byId = new Map(); + for (const issue of [...existing, ...generated]) { + const id = issue.id || `${issue.title}:${issue.nodeId || ''}:${issue.message}`; + byId.set(id, { ...issue, id }); + } + return Array.from(byId.values()); +} + +function walk(node, visitor, parent = null, depth = 0) { + visitor(node, parent, depth); + for (const child of node.children || []) { + walk(child, visitor, node, depth + 1); + } +} + +function walkVisible(node, visitor, parent = null, depth = 0) { + visitor(node, parent, depth); + if (!state.expandedNodeIds.has(getNodeId(node))) return; + for (const child of node.children || []) { + walkVisible(child, visitor, node, depth + 1); + } +} + +function findNodeInTree(root, id) { + let result = null; + walk(root, node => { + if (getNodeId(node) === String(id)) result = node; + }); + return result; +} + +function findNode(id) { + if (!hasSnapshotTree() || id === null || id === undefined) return null; + return findNodeInTree(state.snapshot.tree, id); +} + +function getParentMap() { + const parents = new Map(); + if (!hasSnapshotTree()) return parents; + walk(state.snapshot.tree, (node, parent) => { + if (parent) parents.set(getNodeId(node), parent); + }); + return parents; +} + +function getPathToNode(id) { + const parents = getParentMap(); + const path = []; + let current = findNode(id); + while (current) { + path.unshift(current); + current = parents.get(getNodeId(current)); + } + return path; +} + +function expandPathToNode(id) { + const path = getPathToNode(id); + for (const node of path.slice(0, -1)) { + state.expandedNodeIds.add(getNodeId(node)); + } +} + +function collapseTree() { + state.expandedNodeIds.clear(); +} + +function collapseTreeNode(node) { + walk(node, descendant => { + state.expandedNodeIds.delete(getNodeId(descendant)); + }); +} + +function toggleTreeNode(id) { + const node = findNode(id); + if (!node || !(node.children || []).length) return; + if (state.expandedNodeIds.has(id)) { + collapseTreeNode(node); + } else { + state.expandedNodeIds.add(id); + } + renderTree(); +} + +function ensureBounds(node, depth = 0, index = 0) { + if (!node.bounds) { + node.bounds = { + x: 12 + depth * 16, + y: 24 + index * 54 + depth * 14, + width: Math.max(80, 360 - depth * 28), + height: node.children && node.children.length ? 110 : 42, + }; + } + (node.children || []).forEach((child, childIndex) => ensureBounds(child, depth + 1, childIndex)); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function isMacDesktopTarget(target = state.snapshot.target) { + return target?.applicationId === 'ValdiDesktop' && String(target.platform).toLowerCase() === 'ios'; +} + +function displayPlatform(target = state.snapshot.target) { + return isMacDesktopTarget(target) ? 'macOS desktop (iOS class bridge)' : target.platform; +} + +function hierarchySourceLabel() { + if (state.source === 'daemon') return 'Live'; + return 'Empty'; +} diff --git a/npm_modules/cli/debugger/debugger-performance.js b/npm_modules/cli/debugger/debugger-performance.js new file mode 100644 index 000000000..426be5121 --- /dev/null +++ b/npm_modules/cli/debugger/debugger-performance.js @@ -0,0 +1,139 @@ +// Hermes CPU profile UI behavior. +function readSecondsInput(input) { + const value = Number.parseFloat(input.value); + if (!Number.isFinite(value)) return 5; + return Math.max(0.1, Math.min(60, value)); +} + +function formatMs(value) { + if (!Number.isFinite(value)) return 'n/a'; + return `${value.toFixed(value >= 100 ? 0 : 1)} ms`; +} + +function profileSummaryText(result) { + const summary = result?.summary; + if (!summary || !result.profile) return 'No CPU profile captured.'; + const topFunction = summary.topFunctions?.[0]; + const parts = [ + `${summary.sampleCount || 0} samples`, + `${summary.nodeCount || 0} nodes`, + ]; + if (result.elapsedMs !== undefined) parts.push(`window ${formatMs(result.elapsedMs)}`); + if (topFunction) parts.push(`top function ${escapeHtml(topFunction.name)} x${topFunction.sampleCount}`); + return parts.join(' · '); +} + +function syncActiveProfileState(activeProfile) { + const contextId = activeProfile?.contextId; + state.performance.profileActive = Boolean(activeProfile?.profiling); + state.performance.activeProfileContextId = + state.performance.profileActive && contextId !== undefined && contextId !== null ? String(contextId) : null; +} + +function renderPerformance() { + const perf = state.performance; + elements.profileStatusPill.textContent = perf.profileActive ? 'Recording' : 'Idle'; + elements.profileStatusPill.className = `source-pill ${perf.profileActive ? 'live' : ''}`; + elements.profileStartButton.disabled = perf.profileActive; + elements.profileStopButton.disabled = !perf.profileActive; + elements.profileCaptureButton.disabled = perf.profileActive; + elements.profileExportButton.disabled = !perf.lastProfile?.profile; + elements.profileSummary.innerHTML = profileSummaryText(perf.lastProfile); + + elements.profileContextSelect.disabled = perf.profileActive; + const selectedContext = perf.activeProfileContextId || elements.profileContextSelect.value; + elements.profileContextSelect.innerHTML = [ + ``, + ...perf.profileContexts.map(context => { + const selected = context.id === selectedContext ? ' selected' : ''; + return ``; + }), + ].join(''); +} + +async function refreshProfileContexts(options) { + try { + const result = await apiGet('/api/performance/profile/contexts', {}, { timeoutMs: 5000 }); + state.performance.profileContexts = result.contexts || []; + syncActiveProfileState(result.active); + if (!options.silent) { + addLog('info', 'profile', `Found ${state.performance.profileContexts.length} Hermes context(s).`); + } + } catch (error) { + if (!options.silent) addLog('error', 'profile', error.message); + } finally { + renderPerformance(); + } +} + +function selectedProfileContextId() { + return elements.profileContextSelect.value || undefined; +} + +async function startCpuProfile() { + try { + const result = await apiPost( + '/api/performance/profile/start', + {}, + { + contextId: selectedProfileContextId(), + }, + ); + syncActiveProfileState(result); + addLog( + 'info', + 'profile', + `Started CPU profile for ${result.contextTitle || result.contextId || 'Hermes context'}.`, + ); + } catch (error) { + addLog('error', 'profile', error.message); + } finally { + renderPerformance(); + } +} + +async function stopCpuProfile() { + try { + const result = await apiPost('/api/performance/profile/stop', {}, {}, { timeoutMs: 65000 }); + syncActiveProfileState(null); + state.performance.lastProfile = result; + addLog('info', 'profile', `Captured CPU profile with ${result.summary?.sampleCount || 0} sample(s).`); + } catch (error) { + addLog('error', 'profile', error.message); + } finally { + renderPerformance(); + } +} + +async function captureCpuProfile() { + const durationMs = Math.round(readSecondsInput(elements.profileDurationInput) * 1000); + const contextId = selectedProfileContextId(); + syncActiveProfileState({ profiling: true, contextId }); + renderPerformance(); + try { + addLog('info', 'profile', `Capturing CPU profile for ${(durationMs / 1000).toFixed(1)}s.`); + const result = await apiPost( + '/api/performance/profile/capture', + {}, + { + contextId, + durationMs, + }, + { timeoutMs: durationMs + 15000 }, + ); + syncActiveProfileState(null); + state.performance.lastProfile = result; + addLog('info', 'profile', `Captured CPU profile with ${result.summary?.sampleCount || 0} sample(s).`); + } catch (error) { + addLog('error', 'profile', error.message); + await refreshProfileContexts({ silent: true }); + } finally { + renderPerformance(); + } +} + +function exportCpuProfile() { + const result = state.performance.lastProfile; + if (!result?.profile) return; + downloadJson(result.profile, `${targetFilePrefix()}-hermes.cpuprofile`, 'Exported Hermes CPU profile.'); +} diff --git a/npm_modules/cli/debugger/debugger-preview-html.js b/npm_modules/cli/debugger/debugger-preview-html.js new file mode 100644 index 000000000..0bea06dc9 --- /dev/null +++ b/npm_modules/cli/debugger/debugger-preview-html.js @@ -0,0 +1,311 @@ +// Live HTML projection for the debugger preview frame. +function previewClassName(value) { + return String(value || 'unknown') + .replace(/[^a-z0-9_-]+/gi, '-') + .toLowerCase(); +} + +function previewValue(value) { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return value.replace(/^"|"$/g, ''); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (typeof value === 'object' && value.path) return String(value.path); + return normalizeLabelValue(value); +} + +function previewBoolean(value, fallback = false) { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + const normalized = String(value).toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +function previewNumber(value, fallback = 0) { + if (value === undefined || value === null || value === '') return fallback; + const parsed = Number.parseFloat(String(value).replace(/^"|"$/g, '')); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function previewCssLength(value) { + if (value === undefined || value === null || value === '') return ''; + if (typeof value === 'number') return `${value}px`; + const normalized = String(value).replace(/^"|"$/g, '').trim(); + if (!normalized) return ''; + if (/^-?\d+(\.\d+)?$/.test(normalized)) return `${normalized}px`; + return normalized; +} + +function previewColor(value) { + const normalized = previewValue(value).trim(); + return normalized || ''; +} + +function previewText(node, attrs) { + for (const source of [attrs, node.viewModel, node]) { + if (!source || typeof source !== 'object') continue; + for (const key of ['value', 'title', 'text', 'placeholder', 'accessibilityLabel', 'label', 'name']) { + const value = previewValue(source[key]); + if (value) return value; + } + } + return ''; +} + +function previewImageSource(attrs) { + const source = attrs.src || attrs.source || attrs.url; + if (!source) return ''; + if (typeof source === 'string') return source.replace(/^"|"$/g, ''); + if (typeof source === 'object') { + return previewValue(source.src || source.url || source.path || source.default || ''); + } + return previewValue(source); +} + +function previewSafeMediaSource(source) { + const normalized = String(source || '').trim(); + return /^(data|blob):/i.test(normalized) ? normalized : ''; +} + +function setStyleIfPresent(style, property, value) { + if (value === '') return; + style[property] = value; +} + +function applyPreviewFrame(element, node) { + const frame = node.element?.frame + ? normalizeBounds(node.element.frame) + : node.bounds + ? normalizeBounds(node.bounds) + : null; + element.style.position = 'absolute'; + if (!frame) { + element.style.left = '0'; + element.style.top = '0'; + element.style.width = '100%'; + element.style.height = '100%'; + return; + } + element.style.left = `${frame.x}px`; + element.style.top = `${frame.y}px`; + element.style.width = `${frame.width}px`; + element.style.height = `${frame.height}px`; +} + +function applyPreviewBoxStyles(element, node) { + const attrs = getNodeAttributes(node); + setStyleIfPresent(element.style, 'backgroundColor', previewColor(attrs.backgroundColor || attrs.background)); + setStyleIfPresent(element.style, 'color', previewColor(attrs.color || attrs.textColor)); + setStyleIfPresent(element.style, 'opacity', previewValue(attrs.opacity)); + setStyleIfPresent(element.style, 'borderRadius', previewCssLength(attrs.cornerRadius || attrs.borderRadius)); + setStyleIfPresent(element.style, 'borderColor', previewColor(attrs.borderColor)); + setStyleIfPresent(element.style, 'borderWidth', previewCssLength(attrs.borderWidth)); + if (attrs.borderWidth !== undefined || attrs.borderColor !== undefined) { + element.style.borderStyle = previewValue(attrs.borderStyle) || 'solid'; + } + setStyleIfPresent(element.style, 'padding', previewCssLength(attrs.padding)); + setStyleIfPresent(element.style, 'paddingLeft', previewCssLength(attrs.paddingLeft)); + setStyleIfPresent(element.style, 'paddingRight', previewCssLength(attrs.paddingRight)); + setStyleIfPresent(element.style, 'paddingTop', previewCssLength(attrs.paddingTop)); + setStyleIfPresent(element.style, 'paddingBottom', previewCssLength(attrs.paddingBottom)); + const transform = []; + const translationX = previewNumber(attrs.translationX, 0); + const translationY = previewNumber(attrs.translationY, 0); + if (translationX || translationY) transform.push(`translate(${translationX}px, ${translationY}px)`); + if (attrs.scale !== undefined || attrs.scaleX !== undefined || attrs.scaleY !== undefined) { + const scale = previewNumber(attrs.scale, 1); + transform.push(`scale(${previewNumber(attrs.scaleX, scale)}, ${previewNumber(attrs.scaleY, scale)})`); + } + if (attrs.rotation !== undefined) transform.push(`rotate(${previewNumber(attrs.rotation, 0)}rad)`); + if (transform.length) element.style.transform = transform.join(' '); + if (attrs.hidden !== undefined && previewBoolean(attrs.hidden)) element.style.visibility = 'hidden'; +} + +function applyPreviewTextStyles(element, attrs) { + setStyleIfPresent(element.style, 'fontSize', previewCssLength(attrs.fontSize)); + setStyleIfPresent(element.style, 'fontWeight', previewValue(attrs.fontWeight)); + setStyleIfPresent(element.style, 'textAlign', previewValue(attrs.textAlign)); + setStyleIfPresent(element.style, 'lineHeight', previewCssLength(attrs.lineHeight)); + setStyleIfPresent(element.style, 'letterSpacing', previewCssLength(attrs.letterSpacing)); + const font = previewValue(attrs.font); + const fontSize = font.match(/\b(\d+(?:\.\d+)?)\b/); + if (fontSize && !element.style.fontSize) element.style.fontSize = `${fontSize[1]}px`; + if (/bold|semibold|demibold|medium/i.test(font)) element.style.fontWeight = '700'; + if (/mono/i.test(font)) { + element.style.fontFamily = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace'; + } + if (attrs.textDecoration !== undefined) { + const decoration = previewValue(attrs.textDecoration); + element.style.textDecoration = decoration.includes('underline') ? 'underline' : decoration; + } + const lineCount = previewNumber(attrs.numberOfLines, 0); + if (lineCount > 0) { + element.style.display = '-webkit-box'; + element.style.webkitLineClamp = String(lineCount); + element.style.webkitBoxOrient = 'vertical'; + } +} + +function createPreviewElement(node) { + const tag = String(node.tag || 'view').toLowerCase(); + const attrs = getNodeAttributes(node); + if (tag === 'textfield') { + const input = document.createElement('input'); + input.type = previewBoolean(attrs.secureTextEntry) ? 'password' : 'text'; + input.value = previewValue(attrs.value); + input.placeholder = previewValue(attrs.placeholder); + input.disabled = previewBoolean(attrs.enabled, true) === false; + input.readOnly = true; + return input; + } + if (tag === 'textview' && previewBoolean(attrs.editable, true)) { + const textArea = document.createElement('textarea'); + textArea.value = previewValue(attrs.value); + textArea.placeholder = previewValue(attrs.placeholder); + textArea.disabled = previewBoolean(attrs.enabled, true) === false; + textArea.readOnly = true; + return textArea; + } + if (tag === 'image' || tag === 'animatedimage') { + const image = document.createElement('img'); + image.alt = previewValue(attrs.accessibilityLabel || attrs.alt || ''); + const source = previewImageSource(attrs); + const safeSource = previewSafeMediaSource(source); + if (safeSource) image.src = safeSource; + else if (source) image.dataset.previewResourceOmitted = 'true'; + image.draggable = false; + return image; + } + if (tag === 'video') { + const video = document.createElement('video'); + const source = previewImageSource(attrs); + const safeSource = previewSafeMediaSource(source); + if (safeSource) video.src = safeSource; + else if (source) video.dataset.previewResourceOmitted = 'true'; + video.muted = true; + video.playsInline = true; + return video; + } + if (tag === 'webview') { + const placeholder = document.createElement('div'); + placeholder.dataset.previewResourceOmitted = 'true'; + placeholder.textContent = 'WebView content omitted'; + return placeholder; + } + if (tag === 'button') { + const button = document.createElement('button'); + button.textContent = previewText(node, attrs); + return button; + } + return document.createElement('div'); +} + +function finishPreviewElement(element, node) { + const attrs = getNodeAttributes(node); + const tag = String(node.tag || 'view').toLowerCase(); + const nodeId = getNodeId(node); + const elementId = getElementIdForNode(node); + element.classList.add('valdi-html-node', `valdi-html-${previewClassName(tag)}`); + if (isInteractiveNode(node) || ['button', 'textfield', 'textview'].includes(tag)) { + element.classList.add('preview-interactive'); + } + element.dataset.previewNodeId = nodeId; + if (elementId !== null) element.dataset.previewElementId = String(elementId); + element.title = describeOverlayNode(node); + applyPreviewFrame(element, node); + applyPreviewBoxStyles(element, node); + + if (tag === 'label' || (tag === 'textview' && element.tagName !== 'TEXTAREA')) { + element.classList.add('valdi-html-text'); + element.textContent = previewText(node, attrs); + applyPreviewTextStyles(element, attrs); + element.style.userSelect = previewBoolean(attrs.selectable, false) ? 'text' : 'none'; + } else if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'BUTTON') { + applyPreviewTextStyles(element, attrs); + } else if (element.tagName === 'IMG' || element.tagName === 'VIDEO') { + element.style.objectFit = previewValue(attrs.objectFit) || 'cover'; + } + + if (tag === 'scroll') { + element.classList.add('valdi-html-scroll'); + element.scrollLeft = previewNumber(attrs.contentOffsetX, 0); + element.scrollTop = previewNumber(attrs.contentOffsetY, 0); + } +} + +function appendPreviewNode(node, parent) { + if (!node) return; + if (node.element) { + const element = createPreviewElement(node); + finishPreviewElement(element, node); + parent.appendChild(element); + const childParent = canHostPreviewChildren(element) ? element : parent; + for (const child of node.children || []) appendPreviewNode(child, childParent); + return; + } + for (const child of node.children || []) appendPreviewNode(child, parent); +} + +function canHostPreviewChildren(element) { + return !['INPUT', 'IMG', 'TEXTAREA', 'VIDEO'].includes(element.tagName); +} + +function updateHtmlPreviewScale() { + const canvas = elements.htmlPreviewRoot.querySelector('.html-preview-canvas'); + if (!canvas) return; + const viewport = getViewportBounds(); + const screenBounds = elements.screen.getBoundingClientRect(); + const scaleX = screenBounds.width / Math.max(1, viewport.width); + const scaleY = screenBounds.height / Math.max(1, viewport.height); + canvas.style.transform = `scale(${scaleX}, ${scaleY})`; +} + +function markHtmlPreviewSelection() { + elements.htmlPreviewRoot.querySelectorAll('.preview-selected').forEach(element => { + element.classList.remove('preview-selected'); + }); + if (!state.selectedNodeId) return; + const selected = Array.from(elements.htmlPreviewRoot.querySelectorAll('[data-preview-node-id]')).find( + element => element.dataset.previewNodeId === String(state.selectedNodeId), + ); + selected?.classList.add('preview-selected'); +} + +function renderHtmlPreview() { + elements.htmlPreviewRoot.replaceChildren(); + elements.htmlPreviewRoot.classList.toggle('active', false); + if (!hasSnapshotTree()) return false; + + const viewport = getViewportBounds(); + elements.device.style.setProperty('--device-w', viewport.width); + elements.device.style.setProperty('--device-h', viewport.height); + + const canvas = document.createElement('div'); + canvas.className = 'html-preview-canvas'; + canvas.style.width = `${viewport.width}px`; + canvas.style.height = `${viewport.height}px`; + appendPreviewNode(state.snapshot.tree, canvas); + if (!canvas.querySelector('[data-preview-node-id]')) return false; + + elements.htmlPreviewRoot.appendChild(canvas); + elements.htmlPreviewRoot.classList.toggle('active', true); + updateHtmlPreviewScale(); + markHtmlPreviewSelection(); + return true; +} + +function previewElementNodeFromEvent(event) { + const element = event.target.closest('[data-preview-node-id]'); + if (!element || !elements.htmlPreviewRoot.contains(element)) return { element: null, node: null }; + const node = findNode(element.dataset.previewNodeId); + return { element, node }; +} + +function selectHtmlPreviewNodeAtEvent(event) { + const { element, node } = previewElementNodeFromEvent(event); + if (!element || !node) return; + event.preventDefault(); + event.stopPropagation(); + selectPreviewNode(node); +} diff --git a/npm_modules/cli/debugger/debugger-render.js b/npm_modules/cli/debugger/debugger-render.js new file mode 100644 index 000000000..5f47d1ec2 --- /dev/null +++ b/npm_modules/cli/debugger/debugger-render.js @@ -0,0 +1,507 @@ +// Main UI rendering for preview, hierarchy, overlay, targets, and inspector. +function render() { + renderHeader(); + renderTargets(); + renderDaemonStatus(); + renderTree(); + renderPreview(); + renderOverlay(); + renderInspector(); + renderLogs(); + renderPerformance(); +} + +function renderPreview() { + elements.appPreview.querySelectorAll('.preview-warning').forEach(node => node.remove()); + const htmlPreviewRendered = renderHtmlPreview(); + if (state.rootSnapshotImage && !htmlPreviewRendered) { + elements.rootSnapshotImage.src = state.rootSnapshotImage; + elements.rootSnapshotImage.hidden = false; + } else { + elements.rootSnapshotImage.removeAttribute('src'); + elements.rootSnapshotImage.hidden = true; + } + if (!hasSnapshotTree()) { + const empty = document.createElement('div'); + empty.className = 'preview-warning'; + empty.textContent = 'No Valdi snapshot loaded. Attach to a live target.'; + elements.appPreview.appendChild(empty); + } else if (state.source !== 'daemon' && state.snapshot.target?.clientId && state.snapshot.target?.contextId) { + const warning = document.createElement('div'); + warning.className = 'preview-warning'; + warning.textContent = + 'Live Valdi target found, but this preview is not attached. Press Attach or Refresh to fetch the current tree.'; + elements.appPreview.appendChild(warning); + } + elements.copyPreviewButton.disabled = !state.rootSnapshotImage && !hasSnapshotTree(); +} + +function renderPreviewTargetSelect() { + const options = [ + { + value: String(STANDALONE_DAEMON_PORT), + label: `SnapDrawing / standalone :${STANDALONE_DAEMON_PORT}`, + }, + { + value: String(MOBILE_DAEMON_PORT), + label: `mobile :${MOBILE_DAEMON_PORT}`, + }, + ]; + + const selectedPort = String(selectedDaemonPort()); + const selectedValue = options.some(option => option.value === selectedPort) + ? selectedPort + : String(STANDALONE_DAEMON_PORT); + const markup = options + .map(option => ``) + .join(''); + if (elements.portSelect.innerHTML !== markup) { + elements.portSelect.innerHTML = markup; + } + elements.portSelect.value = selectedValue; +} + +function renderHeader() { + renderPreviewTargetSelect(); + const target = state.snapshot.target || emptyTarget; + const attached = isDebuggerAttached(); + elements.sessionSubtitle.textContent = + state.source === 'empty' + ? 'No session selected' + : `${target.name} - ${displayPlatform(target)} - ${target.transport || 'daemon'}`; + elements.reloaderDot.className = state.source === 'daemon' ? 'dot good' : 'dot warn'; + elements.reloaderState.textContent = state.source === 'daemon' ? 'Live snapshot' : 'No live snapshot'; + const proxyOnline = state.lastStatus?.hotReloadProxy?.connected; + elements.proxyDot.className = proxyOnline || attached ? 'dot good' : 'dot warn'; + elements.proxyState.textContent = proxyOnline + ? `Proxy ${state.lastStatus.hotReloadProxy.port} online` + : attached + ? `Target ${target.proxyPort || target.port || elements.portSelect.value} attached` + : hasSelectedLiveTarget() + ? `Target ${target.proxyPort || target.port || elements.portSelect.value} available` + : `Port ${elements.portSelect.value} idle`; + elements.attachButton.textContent = attached ? 'Detach' : hasSelectedLiveTarget() ? 'Resume' : 'Attach'; + elements.autoRefreshToggle.checked = state.autoRefresh; + elements.hierarchySource.textContent = hierarchySourceLabel(); + elements.hierarchySource.className = `source-pill ${state.source === 'daemon' ? 'live' : ''}`; +} + +function renderTargets() { + const targets = debuggerTargets(); + if (!targets.length) { + elements.targetList.innerHTML = `
No Valdi targets found. Start a playground app and hot reloader, then refresh.
`; + return; + } + + elements.targetList.innerHTML = targets + .map(target => { + const active = target.id === state.snapshot.target?.id ? ' active' : ''; + const dot = target.state === 'attached' ? 'good' : target.state === 'paused' ? 'warn' : ''; + return ` +
+
+ ${escapeHtml(target.name)} + ${escapeHtml(target.state)} +
+
+
Platform
${escapeHtml(displayPlatform(target))}
+
Transport
${escapeHtml(target.transport || state.snapshot.target.transport || 'n/a')}
+ ${target.applicationId ? `
App
${escapeHtml(target.applicationId)}
` : ''} +
+
+ `; + }) + .join(''); +} + +function renderDaemonStatus() { + if (!state.lastStatus) { + elements.daemonStatus.innerHTML = `
Status has not been checked yet.
`; + return; + } + + const rows = []; + for (const portStatus of state.lastStatus.ports || []) { + const contextCount = (portStatus.clients || []).reduce((sum, client) => sum + (client.contexts || []).length, 0); + const dot = portStatus.connected && contextCount ? 'good' : portStatus.connected ? 'warn' : 'bad'; + const detail = portStatus.connected + ? `${(portStatus.clients || []).length} client(s), ${contextCount} context(s)` + : portStatus.error || 'not reachable'; + rows.push(` +
+
+ ${escapeHtml(portStatus.portName)} :${escapeHtml(portStatus.port)} + ${portStatus.connected ? 'reachable' : 'offline'} +
+
${escapeHtml(detail)}
+
+ `); + } + + const proxy = state.lastStatus.hotReloadProxy; + if (proxy) { + rows.push(` +
+
+ hotreload proxy :${escapeHtml(proxy.port)} + ${proxy.connected ? 'online' : 'idle'} +
+
hot reload connection proxy
+
+ `); + } + + elements.daemonStatus.innerHTML = rows.join(''); +} + +function nodeMatchesSearch(node, search) { + if (!search) return true; + const id = getNodeId(node); + const attributes = JSON.stringify(getNodeAttributes(node)).toLowerCase(); + const viewModel = JSON.stringify(node.viewModel || {}).toLowerCase(); + const component = JSON.stringify(node.component || {}).toLowerCase(); + const componentState = JSON.stringify(node.state || {}).toLowerCase(); + const haystack = `${node.tag} ${id} #${id} ${attributes} ${viewModel} ${component} ${componentState}`.toLowerCase(); + return haystack.includes(search); +} + +function collectSearchVisibleNodeIds(root, search) { + const visibleNodeIds = new Set(); + + function visit(node, ancestorMatched = false) { + const id = getNodeId(node); + const selfMatched = nodeMatchesSearch(node, search); + let descendantMatched = false; + for (const child of node.children || []) { + if (visit(child, ancestorMatched || selfMatched)) { + descendantMatched = true; + } + } + + if (ancestorMatched || selfMatched || descendantMatched || id === state.selectedNodeId) { + visibleNodeIds.add(id); + } + return selfMatched || descendantMatched; + } + + visit(root); + return visibleNodeIds; +} + +function parseExactNodeIdSearch(search) { + const match = search.match(/^#?(\d+)$/); + return match ? match[1] : null; +} + +function renderTree() { + if (!hasSnapshotTree()) { + elements.tree.innerHTML = `
No hierarchy loaded. Attach to a target.
`; + return; + } + const search = elements.treeSearch.value.trim().toLowerCase(); + const exactSearchNodeId = search ? parseExactNodeIdSearch(search) : null; + const exactSearchRoot = exactSearchNodeId ? findNodeInTree(state.snapshot.tree, exactSearchNodeId) : null; + const searchVisibleNodeIds = + search && !exactSearchRoot ? collectSearchVisibleNodeIds(state.snapshot.tree, search) : null; + const rows = []; + const traversal = exactSearchRoot ? walk : search ? walk : walkVisible; + const traversalRoot = exactSearchRoot || state.snapshot.tree; + traversal(traversalRoot, (node, parent, depth) => { + const id = getNodeId(node); + const isSelected = id === state.selectedNodeId; + const selected = isSelected ? ' selected' : ''; + const hidden = searchVisibleNodeIds && !searchVisibleNodeIds.has(id) ? ' filtered-out' : ''; + const kind = getNodeKind(node); + const hasChildren = (node.children || []).length > 0; + const expanded = search || exactSearchRoot ? hasChildren : state.expandedNodeIds.has(id); + const toggleClass = hasChildren ? '' : ' empty'; + const toggleLabel = hasChildren ? (expanded ? '-' : '+') : ''; + const ariaExpanded = hasChildren ? `aria-expanded="${expanded}"` : ''; + rows.push(` +
+ + + ${escapeHtml(node.tag)} + #${escapeHtml(id)} +
+ `); + }); + elements.tree.innerHTML = rows.join(''); +} + +function clipBoundsToViewport(bounds, viewport) { + const left = Math.max(bounds.x, viewport.x); + const top = Math.max(bounds.y, viewport.y); + const right = Math.min(bounds.x + bounds.width, viewport.x + viewport.width); + const bottom = Math.min(bounds.y + bounds.height, viewport.y + viewport.height); + if (right <= left || bottom <= top) return null; + return { + x: left, + y: top, + width: right - left, + height: bottom - top, + }; +} + +function shouldRenderOverlayNode(node) { + const id = getNodeId(node); + if (state.overlayMode === 'issues') { + return state.snapshot.issues.some(issue => String(issue.nodeId) === id); + } + return false; +} + +function collectOverlayEntries(viewport) { + const entries = []; + if (!hasSnapshotTree()) return entries; + walk(state.snapshot.tree, (node, _parent, depth) => { + if (!shouldRenderOverlayNode(node)) return; + const geometry = getNodeGeometry(node); + if (!geometry) return; + const bounds = clipBoundsToViewport(geometry.absolute, viewport); + if (!bounds) return; + entries.push({ node, depth, bounds }); + }); + + return entries; +} + +function renderOverlay() { + elements.screen.querySelectorAll('.overlay-node').forEach(node => node.remove()); + const viewport = getViewportBounds(); + elements.device.style.setProperty('--device-w', viewport.width); + elements.device.style.setProperty('--device-h', viewport.height); + updateHtmlPreviewScale(); + markHtmlPreviewSelection(); + const screenBounds = elements.screen.getBoundingClientRect(); + const scaleX = screenBounds.width / viewport.width; + const scaleY = screenBounds.height / viewport.height; + for (const entry of collectOverlayEntries(viewport)) { + const node = entry.node; + const id = getNodeId(node); + const bounds = entry.bounds; + const left = (bounds.x - viewport.x) * scaleX; + const top = (bounds.y - viewport.y) * scaleY; + const overlay = document.createElement('button'); + overlay.className = `overlay-node ${getNodeKind(node)}${id === state.selectedNodeId ? ' selected' : ''}`; + overlay.dataset.nodeId = id; + overlay.style.left = `${left}px`; + overlay.style.top = `${top}px`; + overlay.style.width = `${Math.max(8, bounds.width * scaleX)}px`; + overlay.style.height = `${Math.max(8, bounds.height * scaleY)}px`; + const label = overlayNodeLabel(node); + const detail = overlayNodeDetail(node); + overlay.title = describeOverlayNode(node); + overlay.innerHTML = detail + ? `${escapeHtml(label)}${escapeHtml(detail)}` + : `${escapeHtml(label)}`; + elements.screen.appendChild(overlay); + } +} + +function selectedNode() { + if (!hasSnapshotTree()) return null; + return findNode(state.selectedNodeId) || state.snapshot.tree; +} + +function getComponentDebugData(node) { + return node.component && typeof node.component === 'object' ? node.component : {}; +} + +function isComponentDebugDataOmitted(node) { + return getComponentDebugData(node).debugDataOmitted === true; +} + +function hasDebugPayload(value) { + return value !== undefined; +} + +function getNodeViewModelPayload(node) { + const componentData = getComponentDebugData(node); + if (componentData.viewModel !== undefined) return componentData.viewModel; + if (node.viewModel !== undefined) return node.viewModel; + return undefined; +} + +function getNodeStatePayload(node) { + const componentData = getComponentDebugData(node); + if (componentData.state !== undefined) return componentData.state; + if (node.state !== undefined) return node.state; + return undefined; +} + +function payloadToDisplayString(payload) { + if (typeof payload === 'string') return payload; + try { + return JSON.stringify(payload, null, 2); + } catch { + return String(payload); + } +} + +function renderPayload(payload) { + return `
${escapeHtml(payloadToDisplayString(payload))}
`; +} + +function renderDataSection(title, body) { + return ` +
+

${escapeHtml(title)}

+ ${body} +
+ `; +} + +function renderAttributesTable(attributes) { + const rows = Object.entries(attributes) + .map(([key, value]) => `
${escapeHtml(key)}
${escapeHtml(value)}
`) + .join(''); + return rows ? `
${rows}
` : ''; +} + +async function captureRootSnapshot(params, options = {}) { + if (!hasSnapshotTree()) { + state.rootSnapshotImage = null; + renderPreview(); + return; + } + const rootElement = firstElementWithFrame(state.snapshot.tree); + if (state.source !== 'daemon' || !rootElement?.element) { + state.rootSnapshotImage = null; + renderPreview(); + return; + } + + const requestId = ++state.rootSnapshotRequestId; + try { + const result = await apiGet('/api/element-snapshot', { + ...params, + elementId: rootElement.element.id, + }); + if (requestId !== state.rootSnapshotRequestId || state.source !== 'daemon') return; + state.rootSnapshotImage = result.image || null; + renderPreview(); + } catch (error) { + if (requestId !== state.rootSnapshotRequestId) return; + state.rootSnapshotImage = null; + renderPreview(); + if (!options.silent) addLog('warn', 'inspector', `Could not capture live root snapshot: ${error.message}`); + } +} + +function renderInspector() { + const node = selectedNode(); + if (!node) { + elements.selectedSummary.textContent = 'No node selected'; + elements.inspector.innerHTML = `
No node is selected because no snapshot is loaded.
`; + return; + } + const id = getNodeId(node); + const kind = getNodeKind(node); + const path = getPathToNode(id); + const geometry = getNodeGeometry(node); + const scrollOffset = getScrollOffset(node); + const viewModelPayload = getNodeViewModelPayload(node); + const statePayload = getNodeStatePayload(node); + elements.selectedSummary.textContent = `${node.tag} #${id} - ${kind}`; + + const tab = state.activeTab; + if (tab === 'overview') { + elements.inspector.innerHTML = ` + ${renderBreadcrumb(path)} +
+
Tag
${escapeHtml(node.tag)}
+
Kind
${escapeHtml(kind)}
+
Node id
${escapeHtml(id)}
+
Key
${escapeHtml(node.key || 'n/a')}
+
Children
${(node.children || []).length}
+
Local bounds
${renderBounds(geometry?.local || node.bounds)}
+
Absolute bounds
${renderBounds(geometry?.absolute)}
+ ${hasScrollState(node) ? `
Host scroll offset
x ${scrollOffset.x}, y ${scrollOffset.y}
` : ''} +
Platform class
${escapeHtml(resolvePlatformClass(node))}
+
Props
${hasDebugPayload(viewModelPayload) || Object.keys(getNodeAttributes(node)).length ? 'available' : isComponentDebugDataOmitted(node) ? 'omitted by snapshot budget' : 'not exported'}
+
State
${hasDebugPayload(statePayload) ? 'available' : isComponentDebugDataOmitted(node) ? 'omitted by snapshot budget' : 'not present'}
+
Last updated
${escapeHtml(state.lastUpdated || 'n/a')}
+
+ ${state.selectedSnapshotImage ? `
` : ''} + `; + if (state.selectedSnapshotImage) { + const snapshotContainer = elements.inspector.querySelector('[data-selected-snapshot]'); + if (snapshotContainer) { + const snapshotImage = document.createElement('img'); + snapshotImage.alt = 'Selected element snapshot'; + snapshotImage.src = state.selectedSnapshotImage; + snapshotContainer.appendChild(snapshotImage); + } + } + return; + } + + if (tab === 'props') { + const attributes = getNodeAttributes(node); + const sections = []; + if (hasDebugPayload(viewModelPayload)) { + sections.push(renderDataSection('ViewModel', renderPayload(viewModelPayload))); + } + const attributesTable = renderAttributesTable(attributes); + if (attributesTable) { + sections.push(renderDataSection('Element Attributes', attributesTable)); + } + elements.inspector.innerHTML = sections.length + ? `${sections.join('')}${isComponentDebugDataOmitted(node) ? '
Some component debug data was omitted by the snapshot budget.
' : ''}` + : `
This node has no exported ViewModel or element attributes.
`; + return; + } + + if (tab === 'state') { + elements.inspector.innerHTML = hasDebugPayload(statePayload) + ? renderDataSection('Component State', renderPayload(statePayload)) + : isComponentDebugDataOmitted(node) + ? `
Component state was omitted by the snapshot budget.
` + : `
No component state is exported for this node. State appears for StatefulComponent instances after the runtime exports a non-undefined state value.
`; + return; + } + + if (tab === 'raw') { + const payload = { + node, + geometry, + target: state.snapshot.target, + }; + elements.inspector.innerHTML = `
${escapeHtml(JSON.stringify(payload, null, 2))}
`; + return; + } + + const related = state.snapshot.issues.filter(issue => String(issue.nodeId) === id || issue.nodeId === undefined); + elements.inspector.innerHTML = related.length + ? `
${related.map(renderIssue).join('')}
` + : `
No runtime issues are attached to this node.
`; +} + +function renderBreadcrumb(path) { + return ``; +} + +function renderBounds(bounds) { + if (!bounds) return 'not reported'; + return `x ${bounds.x}, y ${bounds.y}, ${bounds.width} x ${bounds.height}`; +} + +function resolvePlatformClass(node) { + const attrs = getNodeAttributes(node); + if ((state.snapshot.target?.platform === 'macOS' || isMacDesktopTarget()) && !attrs.macosClass && attrs.iosClass) { + return `${attrs.iosClass} (macOS fallback from iosClass)`; + } + return attrs.macosClass || attrs.iosClass || attrs.androidClass || attrs.webClass || 'n/a'; +} + +function renderIssue(issue) { + return ` +
+
${escapeHtml(issue.title)} ${escapeHtml(issue.time || '')}
+
${escapeHtml(issue.message)}
+
+ `; +} diff --git a/npm_modules/cli/debugger/debugger-runtime.js b/npm_modules/cli/debugger/debugger-runtime.js new file mode 100644 index 000000000..265493aa0 --- /dev/null +++ b/npm_modules/cli/debugger/debugger-runtime.js @@ -0,0 +1,371 @@ +// Runtime logs, daemon target refresh, snapshots, heap, exports, and preview copy. +function renderLogs() { + const search = elements.logSearch.value.trim().toLowerCase(); + const rows = state.snapshot.logs + .filter(log => !search || `${log.level} ${log.source} ${log.message}`.toLowerCase().includes(search)) + .map( + log => ` +
+ ${escapeHtml(log.time)} + ${escapeHtml(log.level.toUpperCase())} + [${escapeHtml(log.source)}] ${escapeHtml(log.message)} +
+ `, + ) + .join(''); + elements.logs.innerHTML = rows || `
No logs match the current filter.
`; +} + +function trimRuntimeLogs() { + if (state.snapshot.logs.length <= MAX_RUNTIME_LOG_ROWS) return; + state.snapshot.logs = state.snapshot.logs.slice(-MAX_RUNTIME_LOG_ROWS); +} + +function addLog(level, source, message) { + const now = new Date(); + const time = now.toTimeString().slice(0, 8); + state.snapshot.logs.push({ time, level, source, message }); + trimRuntimeLogs(); + if (state.activeSection === 'logs') { + renderLogs(); + elements.logs.scrollTop = elements.logs.scrollHeight; + } +} + +function appendRuntimeLogs(logs) { + let added = 0; + for (const log of logs || []) { + const normalized = { + time: log.time || '', + level: log.level || 'info', + source: log.source || 'valdi', + message: log.message || '', + }; + state.snapshot.logs.push(normalized); + added++; + } + if (added > 0) { + trimRuntimeLogs(); + if (state.activeSection === 'logs') { + renderLogs(); + elements.logs.scrollTop = elements.logs.scrollHeight; + } + } + return added; +} + +function runtimeLogParams() { + const target = state.snapshot.target || {}; + const params = new URLSearchParams(); + params.set('limit', '120'); + if (target.applicationId) params.set('applicationId', target.applicationId); + if (target.platform) params.set('platform', target.platform); + return params; +} + +function stopRuntimeLogStream() { + if (state.runtimeLogStream) { + state.runtimeLogStream.close(); + state.runtimeLogStream = null; + } + state.runtimeLogStreamKey = null; +} + +function startRuntimeLogStream(options = {}) { + if (state.source !== 'daemon' || !isDebuggerAttached()) { + stopRuntimeLogStream(); + return; + } + + const params = runtimeLogParams(); + const key = params.toString(); + if (state.runtimeLogStream && state.runtimeLogStreamKey === key) return; + + stopRuntimeLogStream(); + const source = new EventSource(`/api/runtime-logs/stream?${key}`); + state.runtimeLogStream = source; + state.runtimeLogStreamKey = key; + + source.addEventListener('meta', event => { + const payload = JSON.parse(event.data); + state.runtimeLogPath = payload.logFile || null; + if (!options.silent) { + addLog( + 'info', + 'logs', + payload.logFile + ? `Streaming runtime logs from ${payload.logFile}.` + : 'Runtime log stream is waiting for a Valdi log file.', + ); + } + }); + + source.addEventListener('logs', event => { + const payload = JSON.parse(event.data); + appendRuntimeLogs(payload.logs || []); + }); + + source.addEventListener('stream-error', event => { + const payload = JSON.parse(event.data); + addLog('error', 'logs', payload.error || 'Runtime log stream failed.'); + }); + + source.addEventListener('error', () => { + if (state.runtimeLogStream === source) { + state.runtimeLogPath = null; + } + }); +} + +async function refreshTargets(options = {}) { + try { + if (!options.silent) addLog('info', 'daemon', 'Refreshing Valdi daemon targets.'); + const status = await apiGet('/api/status'); + state.lastStatus = status; + const targets = []; + + for (const portStatus of status.ports) { + for (const client of portStatus.clients || []) { + for (const context of client.contexts || []) { + targets.push({ + id: `${portStatus.port}:${client.client_id}:${context.id}`, + name: context.rootComponentName || client.application_id || `Client ${client.client_id}`, + platform: client.platform || portStatus.portName, + state: 'available', + transport: `daemon:${portStatus.port}`, + port: portStatus.port, + proxyPort: portStatus.port, + clientId: client.client_id, + contextId: context.id, + applicationId: client.application_id, + }); + } + } + } + + if (targets.length) { + const previousTargetId = state.snapshot.target?.id || null; + const selectedTarget = chooseLiveTarget(targets); + const targetChanged = selectedTarget.id !== previousTargetId; + state.snapshot.targets = markSelectedTarget(targets, selectedTarget); + state.snapshot.target = { + ...state.snapshot.target, + ...selectedTarget, + }; + elements.portSelect.value = String(selectedTarget.port); + state.attached = false; + state.rootSnapshotImage = null; + state.rootSnapshotRequestId++; + state.lastError = null; + if (!options.silent) + addLog('info', 'daemon', `Found ${targets.length} Valdi context(s); selected ${selectedTarget.name}.`); + if (!state.manualDetach && options.autoAttach !== false) { + if (options.skipSnapshotIfSame && !targetChanged) { + render(); + return; + } + await loadRealSnapshot(selectedTarget, { + silent: options.silent, + preserveSelection: state.source === 'daemon' && !targetChanged, + queueIfBusy: options.queueIfBusy === true, + }); + return; + } + } else { + const errors = status.ports + .map(portStatus => `${portStatus.port}: ${portStatus.error || 'no contexts'}`) + .join('; '); + state.lastError = errors; + state.snapshot.targets = []; + state.attached = false; + state.source = 'empty'; + state.rootSnapshotImage = null; + state.rootSnapshotRequestId++; + if (!options.silent) addLog('warn', 'daemon', `No live Valdi contexts found. ${errors}`); + } + render(); + } catch (error) { + state.lastError = error.message; + if (!options.silent) addLog('error', 'daemon', error.message); + renderHeader(); + } +} + +async function loadRealSnapshot(target, options = {}) { + if (state.refreshInFlight) { + const elapsed = state.refreshStartedAt ? Date.now() - state.refreshStartedAt : 0; + if (elapsed < 5000 && options.queueIfBusy) { + state.pendingRefreshRequest = { + target, + options: { + ...options, + queueIfBusy: false, + silent: false, + }, + }; + if (!options.silent) + addLog('info', 'daemon', 'A Valdi snapshot fetch is already running; queued another refresh.'); + return; + } + if (elapsed < 5000) return; + state.refreshInFlight = false; + if (!options.silent) addLog('warn', 'daemon', 'Previous refresh stalled; starting a new Valdi snapshot fetch.'); + } + state.refreshInFlight = true; + state.refreshStartedAt = Date.now(); + const params = target + ? { port: target.port || target.proxyPort, clientId: target.clientId, contextId: target.contextId } + : getSelectedTargetParams(); + const previousSelectedNodeId = state.selectedNodeId; + const previousLogs = state.source === 'daemon' ? state.snapshot.logs : []; + + try { + if (!options.silent) addLog('info', 'daemon', `Fetching Valdi tree from port ${params.port}.`); + const snapshot = await apiGet('/api/snapshot', params); + state.snapshot = decorateSnapshot(snapshot); + state.snapshot.logs = [...previousLogs, ...(state.snapshot.logs || [])]; + trimRuntimeLogs(); + state.selectedNodeId = + options.preserveSelection && findNodeInTree(state.snapshot.tree, previousSelectedNodeId) + ? previousSelectedNodeId + : getNodeId(state.snapshot.tree); + if (!options.preserveSelection) collapseTree(); + state.source = 'daemon'; + state.attached = true; + state.manualDetach = false; + state.lastError = null; + state.selectedSnapshotImage = null; + state.lastUpdated = new Date().toLocaleTimeString(); + elements.portSelect.value = String(state.snapshot.target.proxyPort || params.port); + render(); + if (!options.silent || !state.rootSnapshotImage) { + void captureRootSnapshot(params, options); + } + if (state.activeSection === 'logs') { + startRuntimeLogStream({ silent: options.silent }); + } else { + stopRuntimeLogStream(); + } + } catch (error) { + state.lastError = error.message; + state.source = 'empty'; + state.attached = false; + state.rootSnapshotImage = null; + state.rootSnapshotRequestId++; + stopRuntimeLogStream(); + if (!options.silent) addLog('error', 'daemon', error.message); + render(); + } finally { + state.refreshInFlight = false; + state.refreshStartedAt = null; + const pendingRefreshRequest = state.pendingRefreshRequest; + state.pendingRefreshRequest = null; + if (pendingRefreshRequest) { + window.setTimeout(() => { + loadRealSnapshot(pendingRefreshRequest.target, pendingRefreshRequest.options); + }, 0); + } + } +} + +function firstElementDescendant(node) { + if (!node) return null; + if (node.element && node.element.id !== undefined) return node; + for (const child of node.children || []) { + const match = firstElementDescendant(child); + if (match) return match; + } + return null; +} + +async function captureSelectedElementSnapshot() { + const node = selectedNode(); + if (!node) { + addLog('warn', 'inspector', 'No node is selected because no snapshot is loaded.'); + return; + } + const elementNode = firstElementDescendant(node); + const elementId = + elementNode && elementNode.element && elementNode.element.id !== undefined ? String(elementNode.element.id) : null; + if (!elementId) { + addLog('warn', 'inspector', 'No element descendant is available for the selected node.'); + return; + } + + try { + const params = { + ...getSelectedTargetParams(), + elementId, + }; + const result = await apiGet('/api/element-snapshot', params); + state.selectedSnapshotImage = result.image; + const sourceNote = elementNode === node ? '' : ` via ${elementNode.tag} #${elementId}`; + addLog('info', 'inspector', `Captured element snapshot for ${node.tag} #${getNodeId(node)}${sourceNote}.`); + renderInspector(); + } catch (error) { + addLog('error', 'inspector', error.message); + } +} + +async function dumpHeap() { + try { + const result = await apiPost('/api/heap', getSelectedTargetParams(), { performGC: false }); + const heap = result.heap || {}; + const fileName = `${targetFilePrefix()}-heap-${new Date().toISOString().replaceAll(':', '-')}.json`; + downloadJson(heap, fileName, 'Captured heap report.'); + if (heap.memoryUsageBytes !== undefined) { + const mb = (heap.memoryUsageBytes / 1024 / 1024).toFixed(2); + addLog('info', 'heap', `Memory usage: ${mb} MB. The full report is available in the export panel.`); + } + } catch (error) { + addLog('error', 'heap', error.message); + } +} + +function targetFilePrefix() { + return (state.snapshot.target?.name || 'valdi').replace(/[^a-z0-9_-]+/gi, '-').toLowerCase(); +} + +function downloadJson(payload, fileName, logMessage) { + const json = JSON.stringify(payload, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + if (state.exportObjectUrl) { + URL.revokeObjectURL(state.exportObjectUrl); + state.exportObjectUrl = null; + } + const url = URL.createObjectURL(blob); + state.exportObjectUrl = url; + elements.exportTitle.textContent = fileName; + elements.exportText.value = json; + elements.exportOpenLink.href = url; + elements.exportOpenLink.download = fileName; + elements.exportPanel.classList.add('open'); + addLog('info', 'debugger', `${logMessage} Open or copy it from the export panel.`); +} + +async function copyPreview() { + if (state.rootSnapshotImage && navigator.clipboard?.write && window.ClipboardItem) { + try { + const response = await fetch(state.rootSnapshotImage); + const blob = await response.blob(); + const type = blob.type || 'image/png'; + await navigator.clipboard.write([new ClipboardItem({ [type]: blob })]); + addLog('info', 'preview', 'Copied live preview image.'); + return; + } catch (error) { + addLog('warn', 'preview', `Preview image copy failed: ${error.message}. Copying snapshot JSON instead.`); + } + } + + if (!hasSnapshotTree()) { + addLog('warn', 'preview', 'No preview image or snapshot JSON is available to copy.'); + return; + } + + try { + await navigator.clipboard.writeText(JSON.stringify(state.snapshot, null, 2)); + addLog('info', 'preview', 'Copied current snapshot JSON.'); + } catch (error) { + addLog('error', 'preview', `Copy failed: ${error.message}`); + } +} diff --git a/npm_modules/cli/debugger/debugger-session.js b/npm_modules/cli/debugger/debugger-session.js new file mode 100644 index 000000000..ca548d503 --- /dev/null +++ b/npm_modules/cli/debugger/debugger-session.js @@ -0,0 +1,140 @@ +// Lightweight debugger UI session persistence across page reloads. +const DEBUGGER_SESSION_STORAGE_KEY = 'valdi.debugger.session.v1'; + +let debuggerSessionPersistInterval = null; + +function debuggerSessionTarget() { + const target = state.snapshot.target || {}; + if (!target.id && !target.clientId && !target.contextId) return null; + return { + id: target.id || '', + name: target.name || '', + platform: target.platform || '', + transport: target.transport || '', + state: target.state || 'available', + port: target.port || target.proxyPort || selectedDaemonPort(), + proxyPort: target.proxyPort || target.port || selectedDaemonPort(), + clientId: target.clientId, + contextId: target.contextId, + applicationId: target.applicationId, + }; +} + +function debuggerSessionPayload() { + return { + activeSection: state.activeSection, + activeTab: state.activeTab, + overlayMode: state.overlayMode, + autoRefresh: state.autoRefresh, + followLatestTarget: state.followLatestTarget, + manualDetach: state.manualDetach, + selectedNodeId: state.selectedNodeId, + target: debuggerSessionTarget(), + port: selectedDaemonPort(), + treeSearch: elements.treeSearch.value, + logSearch: elements.logSearch.value, + expandedNodeIds: Array.from(state.expandedNodeIds), + profileDuration: elements.profileDurationInput.value, + profileContextId: elements.profileContextSelect.value, + }; +} + +function persistDebuggerSessionState() { + try { + window.sessionStorage.setItem(DEBUGGER_SESSION_STORAGE_KEY, JSON.stringify(debuggerSessionPayload())); + } catch (error) { + console.warn(`Could not persist debugger session: ${error.message}`); + } +} + +function readDebuggerSessionState() { + try { + const raw = window.sessionStorage.getItem(DEBUGGER_SESSION_STORAGE_KEY); + return raw ? JSON.parse(raw) : null; + } catch (error) { + console.warn(`Could not read debugger session: ${error.message}`); + return null; + } +} + +function restoreDebuggerSessionState() { + const saved = readDebuggerSessionState(); + if (!saved || typeof saved !== 'object') return false; + + const activeSection = normalizeSection(saved.activeSection); + if (elements.sectionPanels.some(panel => panel.dataset.sectionPanel === activeSection)) { + state.activeSection = activeSection; + } + + if (['overview', 'props', 'state', 'issues', 'raw'].includes(saved.activeTab)) { + state.activeTab = saved.activeTab; + } + + if (['live', 'views', 'components', 'issues'].includes(saved.overlayMode)) { + state.overlayMode = + saved.overlayMode === 'views' || saved.overlayMode === 'components' ? 'live' : saved.overlayMode; + } + + if (typeof saved.autoRefresh === 'boolean') state.autoRefresh = saved.autoRefresh; + if (typeof saved.followLatestTarget === 'boolean') state.followLatestTarget = saved.followLatestTarget; + if (typeof saved.manualDetach === 'boolean') state.manualDetach = saved.manualDetach; + if (saved.selectedNodeId !== undefined && saved.selectedNodeId !== null) { + state.selectedNodeId = String(saved.selectedNodeId); + } + + if (Array.isArray(saved.expandedNodeIds)) { + state.expandedNodeIds = new Set(saved.expandedNodeIds.map(String)); + } + + const port = Number.parseInt(String(saved.port || saved.target?.port || saved.target?.proxyPort || ''), 10); + if (Number.isFinite(port)) { + elements.portSelect.value = String(port); + state.snapshot.target = { + ...state.snapshot.target, + port, + proxyPort: port, + }; + } + + if (saved.target && typeof saved.target === 'object') { + state.snapshot.target = { + ...state.snapshot.target, + ...saved.target, + }; + state.snapshot.targets = [state.snapshot.target]; + state.source = 'daemon'; + } + + if (saved.treeSearch !== undefined) elements.treeSearch.value = String(saved.treeSearch); + if (saved.logSearch !== undefined) elements.logSearch.value = String(saved.logSearch); + if (saved.profileDuration !== undefined) elements.profileDurationInput.value = String(saved.profileDuration); + if (saved.profileContextId !== undefined) elements.profileContextSelect.value = String(saved.profileContextId); + + return true; +} + +function applyDebuggerSessionDomState() { + elements.sectionButtons.forEach(button => { + button.classList.toggle('active', button.dataset.sectionButton === state.activeSection); + }); + elements.sectionPanels.forEach(panel => { + panel.classList.toggle('active', panel.dataset.sectionPanel === state.activeSection); + }); + document.querySelectorAll('.tabs button').forEach(button => { + button.classList.toggle('active', button.dataset.tab === state.activeTab); + }); + document.querySelectorAll('.segmented button').forEach(button => button.classList.remove('active')); + document + .getElementById(`mode${state.overlayMode[0].toUpperCase()}${state.overlayMode.slice(1)}`) + ?.classList.add('active'); + if (state.activeSection === 'logs') renderLogs(); +} + +function installDebuggerSessionPersistence() { + if (debuggerSessionPersistInterval) clearInterval(debuggerSessionPersistInterval); + debuggerSessionPersistInterval = window.setInterval(persistDebuggerSessionState, 1000); + window.addEventListener('beforeunload', persistDebuggerSessionState); + document.addEventListener('visibilitychange', () => { + if (document.hidden) persistDebuggerSessionState(); + }); +} diff --git a/npm_modules/cli/debugger/debugger-state.js b/npm_modules/cli/debugger/debugger-state.js new file mode 100644 index 000000000..0d8bb024c --- /dev/null +++ b/npm_modules/cli/debugger/debugger-state.js @@ -0,0 +1,171 @@ +// Shared app state, DOM handles, and small argument helpers. +const AUTO_REFRESH_INTERVAL_MS = 2500; +const MAX_RUNTIME_LOG_ROWS = 400; +const UI_SECTION = 'ui'; +const STANDALONE_DAEMON_PORT = 13591; +const MOBILE_DAEMON_PORT = 13592; + +const emptyTarget = { + id: '', + name: 'No target attached', + platform: 'n/a', + transport: '', + state: 'idle', + proxyPort: STANDALONE_DAEMON_PORT, +}; + +function createEmptySnapshot() { + return { + target: { ...emptyTarget }, + targets: [], + tree: null, + issues: [], + logs: [], + }; +} + +const state = { + snapshot: createEmptySnapshot(), + selectedNodeId: null, + activeSection: 'ui', + activeTab: 'overview', + overlayMode: 'live', + attached: false, + source: 'empty', + lastStatus: null, + lastError: null, + selectedSnapshotImage: null, + geometry: null, + autoRefresh: false, + autoRefreshTimer: null, + refreshInFlight: false, + refreshStartedAt: null, + pendingRefreshRequest: null, + lastUpdated: null, + expandedNodeIds: new Set(), + runtimeLogStream: null, + runtimeLogStreamKey: null, + runtimeLogPath: null, + debuggerEventStream: null, + debuggerEventsConnected: false, + lastDebuggerRevision: 0, + rootSnapshotImage: null, + rootSnapshotRequestId: 0, + manualDetach: false, + followLatestTarget: true, + exportObjectUrl: null, + performance: { + profileActive: false, + activeProfileContextId: null, + lastProfile: null, + profileContexts: [], + }, +}; + +const elements = { + sectionButtons: Array.from(document.querySelectorAll('[data-section-button]')), + sectionPanels: Array.from(document.querySelectorAll('[data-section-panel]')), + sessionSubtitle: document.getElementById('sessionSubtitle'), + targetList: document.getElementById('targetList'), + daemonStatus: document.getElementById('daemonStatus'), + tree: document.getElementById('tree'), + hierarchySource: document.getElementById('hierarchySource'), + htmlPreviewRoot: document.getElementById('htmlPreviewRoot'), + rootSnapshotImage: document.getElementById('rootSnapshotImage'), + appPreview: document.getElementById('appPreview'), + previewStage: document.getElementById('previewStage'), + device: document.getElementById('deviceFrame'), + screen: document.getElementById('screen'), + inspector: document.getElementById('inspector'), + selectedSummary: document.getElementById('selectedSummary'), + logs: document.getElementById('logs'), + treeSearch: document.getElementById('treeSearch'), + logSearch: document.getElementById('logSearch'), + exportPanel: document.getElementById('exportPanel'), + exportTitle: document.getElementById('exportTitle'), + exportOpenLink: document.getElementById('exportOpenLink'), + exportText: document.getElementById('exportText'), + copyExportButton: document.getElementById('copyExportButton'), + closeExportButton: document.getElementById('closeExportButton'), + reloaderDot: document.getElementById('reloaderDot'), + reloaderState: document.getElementById('reloaderState'), + proxyDot: document.getElementById('proxyDot'), + proxyState: document.getElementById('proxyState'), + attachButton: document.getElementById('attachButton'), + portSelect: document.getElementById('portSelect'), + snapshotButton: document.getElementById('snapshotButton'), + copyPreviewButton: document.getElementById('copyPreviewButton'), + autoRefreshToggle: document.getElementById('autoRefreshToggle'), + profileContextSelect: document.getElementById('profileContextSelect'), + profileRefreshButton: document.getElementById('profileRefreshButton'), + profileDurationInput: document.getElementById('profileDurationInput'), + profileStartButton: document.getElementById('profileStartButton'), + profileStopButton: document.getElementById('profileStopButton'), + profileCaptureButton: document.getElementById('profileCaptureButton'), + profileExportButton: document.getElementById('profileExportButton'), + profileStatusPill: document.getElementById('profileStatusPill'), + profileSummary: document.getElementById('profileSummary'), +}; + +function hasSnapshotTree(snapshot = state.snapshot) { + return Boolean(snapshot?.tree); +} + +function actionString(params, ...keys) { + for (const key of keys) { + const value = params?.[key]; + if (value !== undefined && value !== null && value !== '') return String(value); + } + return null; +} + +function actionBoolean(params, key, fallback) { + const value = params?.[key]; + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + const normalized = String(value).toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return Boolean(value); +} + +function actionNumber(params, key) { + const value = params?.[key]; + if (value === undefined || value === null || value === '') return null; + const parsed = Number.parseInt(String(value), 10); + return Number.isNaN(parsed) ? null : parsed; +} + +function numericPortValue(value) { + if (value === undefined || value === null || value === '') return null; + const parsed = Number.parseInt(String(value), 10); + return Number.isNaN(parsed) ? null : parsed; +} + +function selectedDaemonPort(fallback = STANDALONE_DAEMON_PORT) { + return ( + numericPortValue(elements.portSelect.value) ?? + numericPortValue(state.snapshot.target?.proxyPort) ?? + numericPortValue(state.snapshot.target?.port) ?? + fallback + ); +} + +function debuggerTargets() { + const targets = [...(state.snapshot.targets || [])]; + const targetPorts = new Set(targets.map(target => Number(target.port || target.proxyPort)).filter(Number.isFinite)); + for (const portStatus of state.lastStatus?.ports || []) { + if (targetPorts.has(Number(portStatus.port))) continue; + targets.push({ + id: `daemon:${portStatus.port}`, + name: `${portStatus.portName || 'Valdi'} daemon`, + platform: portStatus.portName || 'native', + state: portStatus.connected ? 'waiting' : 'offline', + transport: `daemon:${portStatus.port}`, + port: portStatus.port, + proxyPort: portStatus.port, + daemonPort: true, + }); + } + return targets; +} diff --git a/npm_modules/cli/debugger/debugger.css b/npm_modules/cli/debugger/debugger.css new file mode 100644 index 000000000..33daf2a1d --- /dev/null +++ b/npm_modules/cli/debugger/debugger.css @@ -0,0 +1,1574 @@ +:root { + color-scheme: light; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +:root[data-theme='sunrise'] { + --bg: #f6efe5; + --surface: #fffaf2; + --surface-2: #f2e6d7; + --surface-selected: #fff7b8; + --line: #dfd0bd; + --line-strong: #b99f7d; + --text: #1d1b16; + --text-inverse: #fbf7ec; + --text-inverse-muted: rgba(251, 247, 236, 0.78); + --muted: #6f675c; + --accent: #fff000; + --accent-strong: #c9bb00; + --accent-soft: #fff8b8; + --focus-ring: rgba(255, 240, 0, 0.38); + --good: #087f5b; + --good-soft: #e7f6ed; + --good-line: rgba(8, 127, 91, 0.28); + --warn: #a46300; + --bad: #b42318; + --info: #0f6f9f; + --component: #7357d7; + --component-soft: rgba(115, 87, 215, 0.06); + --component-line: rgba(115, 87, 215, 0.42); + --element: #0f8b8d; + --element-soft: rgba(15, 139, 141, 0.07); + --element-line: rgba(15, 139, 141, 0.5); + --device-shell: #10110e; + --screen-bg: #fffefb; + --overlay-label-bg: #10110e; + --console-bg: #11110d; + --console-surface: #1e1d17; + --console-surface-2: #29271f; + --console-line: rgba(251, 247, 236, 0.16); + --console-text: #f7f2e6; + --log-info: #7dd3fc; + --log-warn: #facc15; + --log-error: #fca5a5; + --shadow: 0 18px 42px rgba(54, 42, 26, 0.12); +} + +:root[data-theme='sunset'] { + --bg: #21142a; + --surface: #fff2df; + --surface-2: #f8d7b6; + --surface-selected: #ffe084; + --line: #e6b58a; + --line-strong: #b8734d; + --text: #271514; + --text-inverse: #fff4e7; + --text-inverse-muted: rgba(255, 244, 231, 0.76); + --muted: #74554a; + --accent: #ffdf3d; + --accent-strong: #d98f1d; + --accent-soft: #fff0aa; + --focus-ring: rgba(255, 151, 67, 0.34); + --good: #0f766e; + --good-soft: #dff4ec; + --good-line: rgba(15, 118, 110, 0.3); + --warn: #a85300; + --bad: #b91c1c; + --info: #1d6b9b; + --component: #8b5cf6; + --component-soft: rgba(139, 92, 246, 0.08); + --component-line: rgba(139, 92, 246, 0.48); + --element: #0e8f99; + --element-soft: rgba(14, 143, 153, 0.08); + --element-line: rgba(14, 143, 153, 0.52); + --device-shell: #120c16; + --screen-bg: #fff8ef; + --overlay-label-bg: #120c16; + --console-bg: #130d18; + --console-surface: #211629; + --console-surface-2: #2f1d34; + --console-line: rgba(255, 244, 231, 0.16); + --console-text: #fff4e7; + --log-info: #67e8f9; + --log-warn: #fde047; + --log-error: #fda4af; + --shadow: 0 20px 48px rgba(39, 21, 20, 0.18); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + border: 1px solid var(--line-strong); + background: var(--surface); + color: var(--text); + border-radius: 6px; + padding: 7px 10px; + cursor: pointer; +} + +button:hover { + border-color: var(--text); +} + +button.primary { + background: var(--accent); + border-color: var(--accent-strong); + color: var(--text); + font-weight: 700; +} + +button.icon { + width: 32px; + height: 32px; + padding: 0; + display: inline-grid; + place-items: center; +} + +input, +textarea, +select { + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + background: var(--surface); + padding: 8px 9px; + outline: none; +} + +input:focus, +textarea:focus, +select:focus { + border-color: var(--text); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +code, +pre { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +} + +.app { + min-height: 100vh; + display: grid; + grid-template-rows: auto minmax(0, 1fr); +} + +.topbar { + display: grid; + grid-template-columns: 1fr auto; + gap: 16px; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.title-row { + display: flex; + gap: 12px; + align-items: center; + min-width: 0; +} + +.brand-mark { + width: 36px; + height: 36px; + border-radius: 8px; + display: grid; + place-items: center; + background: var(--accent); + border: 1px solid var(--accent-strong); + font-weight: 900; +} + +.title-copy { + min-width: 0; +} + +h1 { + margin: 0; + font-size: 17px; + line-height: 1.2; +} + +.subtitle { + margin-top: 3px; + color: var(--muted); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.toolbar select { + width: auto; + min-width: 184px; + padding: 7px 28px 7px 9px; +} + +.toolbar label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} + +.toolbar button { + flex: 0 0 auto; +} + +.toolbar input[type='checkbox'] { + width: auto; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 6px 10px; + background: var(--surface-2); + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} + +.status-pill span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); +} + +.dot.good { + background: var(--good); +} + +.dot.warn { + background: var(--warn); +} + +.dot.bad { + background: var(--bad); +} + +.debugger-shell { + min-height: 0; + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + background: var(--bg); +} + +.section-rail { + min-height: 0; + overflow: auto; + border-right: 1px solid var(--line); + background: var(--surface); + padding: 12px; + display: grid; + align-content: start; + gap: 8px; +} + +.section-button { + width: 100%; + display: grid; + gap: 3px; + text-align: left; + border-color: transparent; + background: transparent; +} + +.section-button:hover, +.section-button.active { + border-color: var(--line-strong); + background: var(--surface-2); +} + +.section-button.active { + box-shadow: inset 3px 0 0 var(--accent-strong); +} + +.section-button strong { + font-size: 13px; +} + +.section-button span { + color: var(--muted); + font-size: 11px; + line-height: 1.3; +} + +.section-panel { + min-width: 0; + min-height: 0; + display: none; +} + +.section-panel.active { + display: grid; +} + +.workspace { + min-height: 0; + display: grid; + grid-template-columns: minmax(420px, 1fr) minmax(310px, 370px); +} + +.panel { + min-height: 0; + overflow: hidden; + background: var(--surface); + border-right: 1px solid var(--line); + display: flex; + flex-direction: column; +} + +.panel:last-child { + border-right: 0; +} + +.panel.inspector { + border-left: 1px solid var(--line); +} + +.panel-header { + padding: 12px; + border-bottom: 1px solid var(--line); + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; +} + +.panel-title { + margin: 0; + font-size: 12px; + text-transform: uppercase; + color: var(--muted); + letter-spacing: 0.05em; +} + +.panel-body { + min-height: 0; + overflow: auto; + padding: 12px; +} + +.target-list { + display: grid; + gap: 8px; +} + +.daemon-status { + display: grid; + gap: 7px; +} + +.daemon-row { + border: 1px solid var(--line); + border-radius: 7px; + padding: 8px; + display: grid; + gap: 5px; + background: var(--surface-2); + font-size: 12px; +} + +.daemon-row-top { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.daemon-row strong { + font-size: 12px; +} + +.daemon-detail { + color: var(--muted); + overflow-wrap: anywhere; +} + +.target { + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px; + display: grid; + gap: 6px; + background: var(--surface); + cursor: pointer; +} + +.target.active { + border-color: var(--accent-strong); + background: var(--surface-selected); +} + +.target-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.target-name { + font-weight: 700; + font-size: 13px; +} + +.meta-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.metric { + min-width: 0; + border: 1px solid var(--line); + border-radius: 7px; + padding: 7px; + background: var(--surface-2); +} + +.metric-label { + color: var(--muted); + font-size: 11px; +} + +.metric-value { + margin-top: 2px; + font-size: 13px; + font-weight: 700; +} + +.section-gap { + margin-top: 14px; +} + +.connection-drawer { + position: relative; + background: transparent; +} + +.connection-summary { + min-height: 32px; + padding: 6px 10px; + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface-2); + cursor: pointer; + user-select: none; +} + +.connection-summary::marker { + color: var(--muted); +} + +.connection-summary-text { + font-size: 12px; + font-weight: 700; + color: var(--muted); + letter-spacing: 0; + text-transform: none; +} + +.connection-summary-hint { + color: var(--muted); + font-size: 11px; +} + +.connection-content { + position: fixed; + top: 148px; + left: 16px; + right: 16px; + width: auto; + max-height: 56vh; + overflow: auto; + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px 14px; + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr); + gap: 14px; + background: var(--surface); + box-shadow: var(--shadow); + z-index: 40; +} + +.connection-section { + min-width: 0; + display: grid; + align-content: start; + gap: 8px; +} + +.connection-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.tree-controls { + display: grid; + gap: 8px; +} + +.hierarchy-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.source-pill { + flex: 0 0 auto; + border: 1px solid var(--line); + border-radius: 999px; + padding: 3px 7px; + background: var(--surface-2); + color: var(--muted); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.source-pill.live { + border-color: var(--good-line); + background: var(--good-soft); + color: var(--good); +} + +.tree { + margin-top: 10px; + display: grid; + gap: 2px; +} + +.tree-node { + border: 1px solid transparent; + border-radius: 6px; + padding: 6px 7px; + display: grid; + grid-template-columns: 18px auto minmax(0, 0.9fr) minmax(0, 1.1fr); + gap: 7px; + align-items: center; + cursor: pointer; + min-width: 0; +} + +.tree-node:hover { + background: var(--surface-2); +} + +.tree-node.selected { + border-color: var(--accent-strong); + background: var(--surface-selected); +} + +.tree-node.filtered-out { + display: none; +} + +.tree-toggle { + width: 18px; + height: 18px; + padding: 0; + border: 1px solid var(--line); + border-radius: 5px; + display: grid; + place-items: center; + background: var(--surface); + color: var(--muted); + font-size: 12px; + line-height: 1; + cursor: pointer; +} + +.tree-toggle.empty { + border-color: transparent; + background: transparent; + cursor: default; +} + +.node-kind { + width: 9px; + height: 9px; + border-radius: 2px; +} + +.node-kind.component { + background: var(--component); +} + +.node-kind.element { + background: var(--element); +} + +.node-label { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 13px; +} + +.node-id { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--muted); + font-size: 11px; +} + +.main { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(260px, 1.05fr) minmax(180px, 0.95fr); + overflow: hidden; + background: var(--bg); +} + +.main-toolbar { + border-bottom: 1px solid var(--line); + padding: 10px 12px; + background: var(--surface); + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +} + +.main-title { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.main-toolbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} + +.main-toolbar-actions .status-pill { + min-width: 0; + max-width: 100%; +} + +#selectedSummary { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.segmented { + display: inline-flex; + border: 1px solid var(--line); + border-radius: 7px; + overflow: hidden; + background: var(--surface); +} + +.segmented button { + border: 0; + border-radius: 0; + border-right: 1px solid var(--line); + background: transparent; +} + +.segmented button:last-child { + border-right: 0; +} + +.segmented button.active { + background: var(--accent); + font-weight: 700; +} + +.stage { + min-height: 0; + overflow: hidden; + padding: 18px; + display: grid; + place-items: center; +} + +.hierarchy-dock { + min-height: 0; + overflow: auto; + border-top: 1px solid var(--line); + background: var(--surface); + padding: 12px 14px 16px; +} + +.device { + width: min(100%, 430px); + aspect-ratio: var(--device-w, 390) / var(--device-h, 760); + max-height: 100%; + overflow: hidden; + border: 1px solid var(--device-shell); + border-radius: 34px; + background: var(--device-shell); + padding: 16px; + box-shadow: var(--shadow); +} + +.screen { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + border-radius: 22px; + background: var(--screen-bg); + touch-action: none; +} + +.app-preview { + position: absolute; + inset: 0; + background: var(--screen-bg); +} + +.root-snapshot { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: fill; +} + +.html-preview-root { + position: absolute; + inset: 0; + overflow: hidden; + background: var(--screen-bg); +} + +.html-preview-canvas { + position: absolute; + left: 0; + top: 0; + overflow: hidden; + transform-origin: 0 0; + contain: layout style paint; +} + +.valdi-html-node { + box-sizing: border-box; + min-width: 0; + min-height: 0; + overflow: hidden; + color: var(--text); + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-size: 14px; + line-height: 1.2; +} + +.valdi-html-scroll { + overflow: auto; + overscroll-behavior: contain; +} + +.valdi-html-text { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.valdi-html-node.preview-interactive { + cursor: pointer; +} + +.valdi-html-node.preview-selected { + outline: 2px solid var(--text); + outline-offset: -2px; + box-shadow: inset 0 0 0 3px var(--accent); +} + +.valdi-html-node input, +.valdi-html-node textarea { + font: inherit; +} + +input.valdi-html-node, +textarea.valdi-html-node, +button.valdi-html-node { + border: 1px solid var(--line); + background: transparent; + color: inherit; +} + +textarea.valdi-html-node { + resize: none; +} + +img.valdi-html-node, +video.valdi-html-node { + display: block; + border: 0; +} + +.preview-warning { + position: absolute; + left: 12px; + right: 12px; + top: 12px; + z-index: 5; + border: 1px solid var(--accent-strong); + border-radius: 7px; + background: var(--accent-soft); + color: var(--text); + padding: 8px 10px; + font-size: 12px; + font-weight: 700; + line-height: 1.35; +} + +.overlay-node { + position: absolute; + z-index: 4; + border: 2px solid var(--element-line); + background: var(--element-soft); + border-radius: 5px; + cursor: pointer; +} + +.overlay-node.component { + border-color: var(--component-line); + background: var(--component-soft); +} + +.overlay-node.selected { + border-color: var(--text); + box-shadow: 0 0 0 3px var(--accent); + background: var(--accent-soft); +} + +.overlay-node .label { + position: absolute; + left: -2px; + top: -39px; + max-width: 220px; + overflow: hidden; + background: var(--overlay-label-bg); + color: var(--text-inverse); + border-radius: 4px; + padding: 3px 6px; + font-size: 10px; + line-height: 1.35; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; +} + +.overlay-node .label-line { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.overlay-node .label-detail { + color: var(--text-inverse-muted); +} + +.overlay-node:hover .label, +.overlay-node:focus-visible .label { + opacity: 1; +} + +.export-panel { + display: none; + border-bottom: 1px solid var(--line); + background: var(--surface); + padding: 12px; + gap: 8px; +} + +.export-panel.open { + display: grid; +} + +.export-header, +.export-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; +} + +.export-title { + font-size: 12px; + font-weight: 800; +} + +.export-panel textarea { + min-height: 220px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; + font-size: 11px; +} + +.performance-panel { + min-height: 0; + overflow: auto; + background: transparent; + padding: 16px; + display: grid; + align-content: start; + gap: 12px; +} + +.section-heading { + display: grid; + gap: 4px; +} + +.section-heading h2 { + margin: 0; + font-size: 20px; + line-height: 1.2; +} + +.section-heading p { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.performance-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); + gap: 10px; +} + +.performance-card { + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; + background: var(--surface); + display: grid; + gap: 8px; +} + +.placeholder-grid { + min-height: 0; + overflow: auto; + padding: 16px; + display: grid; + align-content: start; + gap: 12px; +} + +.placeholder-card { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + padding: 14px; + display: grid; + gap: 6px; +} + +.placeholder-card h3 { + margin: 0; + font-size: 15px; +} + +.placeholder-card p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.4; +} + +.section-panel:not(.active) { + display: none; +} + +.section-panel.active { + display: grid; +} + +.performance-card:not([open]) { + gap: 0; +} + +.performance-card-header, +.performance-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; +} + +.performance-card-header { + cursor: pointer; + list-style: none; +} + +.performance-card-header::-webkit-details-marker { + display: none; +} + +.performance-title { + font-size: 12px; + font-weight: 800; +} + +.performance-controls { + justify-content: flex-start; +} + +.performance-controls label { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.performance-controls input[type='checkbox'] { + width: auto; +} + +.performance-controls input[type='number'] { + width: 76px; + padding: 6px 7px; +} + +.performance-controls select { + width: min(100%, 220px); + padding: 6px 28px 6px 7px; +} + +.performance-summary { + min-height: 18px; + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.performance-summary strong { + color: var(--text); +} + +.tabs { + display: grid; + grid-template-columns: repeat(5, 1fr); + border-bottom: 1px solid var(--line); +} + +.tabs button { + border: 0; + border-right: 1px solid var(--line); + border-radius: 0; + padding: 10px 6px; + background: var(--surface); + font-size: 12px; +} + +.tabs button:last-child { + border-right: 0; +} + +.tabs button.active { + background: var(--accent); + font-weight: 800; +} + +.inspector-content { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 12px; +} + +.kv { + display: grid; + grid-template-columns: minmax(92px, 0.8fr) minmax(0, 1.2fr); + border-top: 1px solid var(--line); +} + +.kv div { + padding: 8px 6px; + border-bottom: 1px solid var(--line); + min-width: 0; + overflow-wrap: anywhere; + font-size: 12px; +} + +.kv div:nth-child(odd) { + color: var(--muted); + background: var(--surface-2); +} + +.breadcrumb { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-bottom: 12px; +} + +.crumb { + border: 1px solid var(--line); + border-radius: 999px; + padding: 4px 7px; + background: var(--surface); + font-size: 11px; +} + +.codebox { + background: var(--console-bg); + color: var(--console-text); + border-radius: 8px; + padding: 10px; + overflow: auto; + font-size: 12px; + line-height: 1.45; +} + +.data-section { + display: grid; + gap: 8px; + margin-bottom: 14px; +} + +.data-section:last-child { + margin-bottom: 0; +} + +.data-heading { + margin: 0; + color: var(--muted); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.snapshot-preview { + margin-top: 12px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px; + background: var(--surface-2); +} + +.snapshot-preview img { + display: block; + width: 100%; + height: auto; + border-radius: 6px; + background: var(--screen-bg); +} + +.issue-list { + display: grid; + gap: 8px; +} + +.issue { + border: 1px solid var(--line); + border-left-width: 4px; + border-radius: 7px; + padding: 9px; + background: var(--surface); + cursor: pointer; +} + +.issue.warn { + border-left-color: var(--warn); +} + +.issue.error { + border-left-color: var(--bad); +} + +.issue-title { + font-weight: 800; + font-size: 12px; +} + +.issue-message { + margin-top: 4px; + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.console { + min-height: 0; + border-top: 0; + background: var(--console-bg); + color: var(--console-text); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.console-top { + display: grid; + grid-template-columns: auto minmax(160px, 1fr) auto; + gap: 6px; + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid var(--console-line); +} + +.console-title { + font-size: 12px; + font-weight: 800; +} + +.console input { + background: var(--console-surface); + border-color: var(--console-line); + color: var(--console-text); +} + +.log-filter { + display: grid; + grid-template-columns: minmax(0, 1fr); + min-width: 0; +} + +.console-action { + border-color: var(--console-line); + background: var(--console-surface-2); + color: var(--console-text); + padding: 6px 8px; + font-size: 11px; + font-weight: 700; + white-space: nowrap; +} + +.console-action:hover { + border-color: var(--text-inverse-muted); +} + +.log-list { + overflow: auto; + padding: 8px 12px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + line-height: 1.45; +} + +.log-row { + display: grid; + grid-template-columns: 72px 74px 1fr; + gap: 8px; + padding: 2px 0; +} + +.log-row .level-info { + color: var(--log-info); +} + +.log-row .level-warn { + color: var(--log-warn); +} + +.log-row .level-error { + color: var(--log-error); +} + +.log-row span:last-child { + white-space: pre-wrap; +} + +.console-prompt { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 8px; + align-items: center; + padding: 8px 12px; + border-top: 1px solid var(--console-line); +} + +.prompt-glyph { + color: var(--accent); + font-weight: 900; +} + +.hidden { + display: none; +} + +.empty { + color: var(--muted); + font-size: 12px; + line-height: 1.45; + padding: 8px; +} + +@media (max-width: 1080px) { + .workspace { + grid-template-columns: minmax(380px, 1fr); + } + + .panel.inspector { + grid-column: 1 / -1; + border-left: 0; + border-top: 1px solid var(--line); + max-height: 42vh; + } +} + +@media (max-width: 900px) { + button { + padding: 6px 9px; + } + + .topbar { + grid-template-columns: 1fr; + gap: 10px; + padding: 10px 12px; + } + + .brand-mark { + width: 30px; + height: 30px; + border-radius: 7px; + } + + h1 { + font-size: 16px; + } + + .toolbar { + justify-content: flex-start; + gap: 6px; + } + + .toolbar select { + flex: 1 1 180px; + min-width: 0; + } + + .status-pill { + max-width: 100%; + padding: 5px 8px; + } + + .connection-summary { + min-height: 30px; + padding: 5px 8px; + } + + .debugger-shell { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr); + } + + .section-rail { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + overflow: visible; + border-right: 0; + border-bottom: 1px solid var(--line); + padding: 8px 10px; + gap: 6px; + } + + .section-button { + min-width: 0; + padding: 7px 8px; + position: relative; + text-align: center; + } + + .section-button::after { + content: ''; + position: absolute; + right: 24%; + bottom: 1px; + left: 24%; + height: 3px; + border-radius: 999px; + background: var(--line-strong); + opacity: 0; + transform: scaleX(0.5); + transition: + opacity 120ms ease, + transform 120ms ease, + background-color 120ms ease; + } + + .section-button:hover { + border-color: transparent; + background: transparent; + } + + .section-button:hover::after { + opacity: 0.45; + transform: scaleX(1); + } + + .section-button strong { + font-size: 12px; + } + + .section-button.active { + border-color: transparent; + background: transparent; + box-shadow: none; + } + + .section-button.active::after { + background: var(--accent-strong); + opacity: 1; + transform: scaleX(1); + } + + .section-button span { + display: none; + } + + .main { + grid-template-rows: auto minmax(260px, 0.8fr) minmax(170px, 0.8fr); + } + + .main-toolbar { + align-items: flex-start; + padding: 8px 10px; + } + + .main-toolbar-actions { + justify-content: flex-start; + width: 100%; + } + + .segmented { + flex: 1 1 240px; + } + + .segmented button { + flex: 1 1 0; + padding: 6px 8px; + } + + .stage { + padding: 12px; + } + + .hierarchy-dock { + padding: 8px 10px 12px; + } + + .tree-node { + grid-template-columns: 16px auto minmax(0, 0.7fr) minmax(0, 1.3fr); + gap: 5px; + padding: 5px; + } + + .node-label { + font-size: 12px; + } + + .node-id { + font-size: 10px; + } +} + +@media (max-width: 760px) { + .topbar { + grid-template-columns: 1fr; + } + + .toolbar { + justify-content: flex-start; + } + + .workspace { + grid-template-columns: 1fr; + } + + .main { + grid-template-rows: auto auto auto; + order: 1; + overflow: visible; + } + + .panel.inspector { + order: 2; + } + + .panel { + max-height: none; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .stage { + min-height: 420px; + } + + .console-title { + font-size: 11px; + } + + .console-top { + grid-template-columns: auto minmax(0, 1fr) auto; + } + + .connection-content { + top: 206px; + grid-template-columns: 1fr; + max-height: 42vh; + } +} diff --git a/npm_modules/cli/debugger/index.html b/npm_modules/cli/debugger/index.html new file mode 100644 index 000000000..c26deabda --- /dev/null +++ b/npm_modules/cli/debugger/index.html @@ -0,0 +1,219 @@ + + + + + + Valdi Debugger + + + +
+
+
+
V
+
+

Valdi Debugger

+
No session selected
+
+
+
+ Hot reloader online + Proxy 9010 idle +
+ + Targets / Daemon + Details + +
+
+
+

Targets

+ +
+
+
+
+

Daemon Status

+
+
+
+
+ + + + +
+
+ +
+ + +
+
+
+
+
+
Export
+
+ Open JSON + + +
+
+ +
+ +
+
+

Live Preview / View Hierarchy

+ Empty +
+
+
+ + +
+ +
+ No node selected +
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ + +
+
+ +
+
+

Performance

+

Capture Hermes CPU profiles for a connected Valdi JavaScript context.

+
+
+
+ +
CPU
+ Idle +
+
+ + + + + + + +
+
No CPU profile captured.
+
+
+
+ +
+
+
+
Runtime Logs
+
+ +
+ +
+
+
+ > + + +
+
+
+
+
+ + + + + + + + + + + + + diff --git a/npm_modules/cli/package.json b/npm_modules/cli/package.json index 39609cabc..568d70567 100644 --- a/npm_modules/cli/package.json +++ b/npm_modules/cli/package.json @@ -16,6 +16,7 @@ }, "files": [ "dist", + "debugger", "bundled-skills", ".metadata", ".bootstrap" diff --git a/npm_modules/cli/src/commands/debugger.ts b/npm_modules/cli/src/commands/debugger.ts new file mode 100644 index 000000000..d1d3af308 --- /dev/null +++ b/npm_modules/cli/src/commands/debugger.ts @@ -0,0 +1,89 @@ +import type { Argv } from 'yargs'; +import { startDebuggerServer } from '../debugger/server'; +import type { ArgumentsResolver } from '../utils/ArgumentsResolver'; +import { makeCommandHandler } from '../utils/errorUtils'; + +interface CommandParameters { + host: string; + port: number; + strictPort: boolean; + json: boolean; +} + +async function waitForShutdown(closeServer: () => Promise): Promise { + return new Promise((resolve, reject) => { + let shuttingDown = false; + const shutdown = () => { + if (shuttingDown) return; + shuttingDown = true; + process.off('SIGINT', shutdown); + process.off('SIGTERM', shutdown); + void closeServer().then(resolve, reject); + }; + + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + }); +} + +async function valdiDebugger(argv: ArgumentsResolver): Promise { + const host = argv.getArgument('host'); + const port = argv.getArgument('port'); + const strictPort = argv.getArgument('strictPort'); + const json = argv.getArgument('json'); + + const debuggerServer = await startDebuggerServer({ + host, + port, + strictPort, + }); + + if (json) { + console.log( + JSON.stringify({ + url: debuggerServer.url, + host: debuggerServer.host, + port: debuggerServer.port, + requestedPort: debuggerServer.requestedPort, + portWasAutoSelected: debuggerServer.portWasAutoSelected, + pid: process.pid, + }), + ); + } else { + console.log(`Valdi debugger listening on ${debuggerServer.url}`); + console.log(`VALDI_DEBUGGER_URL=${debuggerServer.url}`); + if (debuggerServer.portWasAutoSelected) { + console.log(`Port ${debuggerServer.requestedPort} was busy; using ${debuggerServer.port}.`); + } + } + + await waitForShutdown(debuggerServer.close); +} + +export const command = 'debugger'; +export const describe = 'Starts the Valdi debugger web interface'; +export const builder = (yargs: Argv) => { + yargs + .option('host', { + describe: 'Loopback host address to bind the debugger web server to', + type: 'string', + default: process.env['VALDI_DEBUGGER_HOST'] || '127.0.0.1', + }) + .option('port', { + describe: 'Preferred debugger web server port', + type: 'number', + default: Number.parseInt(process.env['VALDI_DEBUGGER_PORT'] || '8765', 10), + }) + .option('strict-port', { + describe: 'Fail instead of selecting the next available port when the preferred port is busy', + type: 'boolean', + default: false, + }) + .option('json', { + describe: 'Print startup information as one JSON object for automation', + type: 'boolean', + default: false, + }); +}; + +export const handler = makeCommandHandler(valdiDebugger); diff --git a/npm_modules/cli/src/commands/inspect_commands/snapshot.ts b/npm_modules/cli/src/commands/inspect_commands/snapshot.ts index b527be0bf..1df71071b 100644 --- a/npm_modules/cli/src/commands/inspect_commands/snapshot.ts +++ b/npm_modules/cli/src/commands/inspect_commands/snapshot.ts @@ -77,7 +77,7 @@ async function inspectSnapshot(argv: ArgumentsResolver) { let elementId = elementIdArg; if (keyOverride) { - const tree = await conn.getContextTree(clientId, contextId); + const tree = await conn.getContextTree(clientId, contextId, false); const found = findElementByKey(tree, keyOverride); if (!found) { throw new Error(`No element found with key "${keyOverride}". Use "valdi inspect tree" to see available keys.`); diff --git a/npm_modules/cli/src/commands/inspect_commands/tree.ts b/npm_modules/cli/src/commands/inspect_commands/tree.ts index 4b7ba5fec..9b3bf4ccc 100644 --- a/npm_modules/cli/src/commands/inspect_commands/tree.ts +++ b/npm_modules/cli/src/commands/inspect_commands/tree.ts @@ -46,7 +46,7 @@ async function inspectTree(argv: ArgumentsResolver) { await conn.configure(); const clientId = await resolveClientId(conn, clientOverride); const contextId = await resolveContextId(conn, clientId, contextIdArg); - let tree = await conn.getContextTree(clientId, contextId); + let tree = await conn.getContextTree(clientId, contextId, false); if (maxDepth !== undefined) { tree = trimDepth(tree, maxDepth); diff --git a/npm_modules/cli/src/core/packageFiles.spec.ts b/npm_modules/cli/src/core/packageFiles.spec.ts index 09579bb67..e396889b9 100644 --- a/npm_modules/cli/src/core/packageFiles.spec.ts +++ b/npm_modules/cli/src/core/packageFiles.spec.ts @@ -1,6 +1,21 @@ import 'jasmine'; import { execSync } from 'child_process'; +import * as fs from 'fs'; import path from 'path'; +import * as vm from 'vm'; + +interface Deferred { + promise: Promise; + resolve: (value: Value) => void; +} + +function createDeferred(): Deferred { + let resolvePromise!: (value: Value) => void; + const promise = new Promise(resolve => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +} /** * Verifies that all directories required at runtime are included in the @@ -9,13 +24,16 @@ import path from 'path'; * because template files were missing from the tarball. */ describe('npm package contents', () => { + let cliRoot: string; let packedFiles: string; beforeAll(() => { - const cliRoot = path.join(__dirname, '../..'); + // The CLI test suite is emitted as CommonJS. + // eslint-disable-next-line unicorn/prefer-module + cliRoot = path.join(__dirname, '../..'); packedFiles = execSync('npm pack --dry-run 2>&1', { cwd: cliRoot, - encoding: 'utf-8', + encoding: 'utf8', }); }); @@ -35,4 +53,176 @@ describe('npm package contents', () => { it('includes bundled-skills', () => { expect(packedFiles).toContain('bundled-skills/'); }); + + it('includes debugger assets', () => { + const debuggerRoot = path.join(cliRoot, 'debugger'); + const indexHtml = fs.readFileSync(path.join(debuggerRoot, 'index.html'), 'utf8'); + const stylesheetPaths: string[] = []; + for (const match of indexHtml.matchAll(/]*href=["']\.\/(.+?)["']/g)) { + const stylesheetPath = match[1]; + if (stylesheetPath !== undefined) stylesheetPaths.push(stylesheetPath); + } + const scriptPaths: string[] = []; + for (const match of indexHtml.matchAll(/ fs.readFileSync(path.join(debuggerRoot, scriptPath), 'utf8')) + .join('\n'); + expect(() => new vm.Script(orderedBundle, { filename: 'valdi-debugger.js' })).not.toThrow(); + for (const deferredSurface of [ + 'renderDataProviders', + 'renderNetwork', + 'dispatchDebuggerInput', + 'capturePerformanceTrace', + 'renderWebPreviewFrame', + ]) { + expect(orderedBundle).not.toContain(deferredSurface); + } + }); + + it('does not let projected trees auto-load remote media', () => { + const previewSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-preview-html.js'), 'utf8'); + const policyResult = new vm.Script( + `${previewSource}\n[ + previewSafeMediaSource('https://example.com/image.png'), + previewSafeMediaSource('http://127.0.0.1:8080/private'), + previewSafeMediaSource('data:image/png;base64,AA=='), + previewSafeMediaSource('blob:http://127.0.0.1:8765/id') + ];`, + { filename: 'debugger-preview-html.js' }, + ).runInNewContext({}) as string[]; + + expect(policyResult).toEqual(['', '', 'data:image/png;base64,AA==', 'blob:http://127.0.0.1:8765/id']); + expect(previewSource).not.toContain('frame.src = source'); + }); + + it('recovers an active CPU profile from the contexts response', async () => { + const performanceSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-performance.js'), 'utf8'); + const bootstrapSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-bootstrap.js'), 'utf8'); + const performanceState = { + profileActive: false, + activeProfileContextId: null as string | null, + lastProfile: null, + profileContexts: [], + }; + const profileContextSelect = { + value: 'previous-context', + innerHTML: '', + disabled: false, + }; + const profileStartButton = { disabled: false }; + const profileStopButton = { disabled: true }; + const context = { + state: { performance: performanceState }, + elements: { + profileStatusPill: { textContent: '', className: '' }, + profileStartButton, + profileStopButton, + profileCaptureButton: { disabled: false }, + profileExportButton: { disabled: false }, + profileSummary: { innerHTML: '' }, + profileContextSelect, + }, + apiGet: () => + Promise.resolve({ + contexts: [ + { id: 'previous-context', title: 'Previous context' }, + { id: 'active-context', title: 'Active context' }, + ], + active: { + profiling: true, + contextId: 'active-context', + contextTitle: 'Active context', + }, + }), + addLog: () => {}, + escapeHtml: String, + }; + + await (new vm.Script(`${performanceSource}\nrefreshProfileContexts({ silent: true });`, { + filename: 'debugger-performance.js', + }).runInNewContext(context) as Promise); + + expect(performanceState.profileActive).toBeTrue(); + expect(performanceState.activeProfileContextId).toBe('active-context'); + expect(profileContextSelect.innerHTML).toContain('value="active-context" selected'); + expect(profileContextSelect.disabled).toBeTrue(); + expect(profileStartButton.disabled).toBeTrue(); + expect(profileStopButton.disabled).toBeFalse(); + expect(bootstrapSource).toContain('void refreshProfileContexts({ silent: true });'); + }); + + it('shows one-shot CPU capture as active until the profile completes', async () => { + const performanceSource = fs.readFileSync(path.join(cliRoot, 'debugger', 'debugger-performance.js'), 'utf8'); + const completedProfile = { + profile: { nodes: [] }, + summary: { sampleCount: 3, nodeCount: 0 }, + }; + const pendingCapture = createDeferred(); + const performanceState = { + profileActive: false, + activeProfileContextId: null as string | null, + lastProfile: null as typeof completedProfile | null, + profileContexts: [{ id: 'requested-context', title: 'Requested context' }], + }; + const profileStatusPill = { textContent: '', className: '' }; + const profileContextSelect = { + value: 'requested-context', + innerHTML: '', + disabled: false, + }; + const profileStartButton = { disabled: false }; + const profileStopButton = { disabled: true }; + const profileCaptureButton = { disabled: false }; + const context = { + state: { performance: performanceState }, + elements: { + profileStatusPill, + profileStartButton, + profileStopButton, + profileCaptureButton, + profileExportButton: { disabled: true }, + profileSummary: { innerHTML: '' }, + profileContextSelect, + profileDurationInput: { value: '5' }, + }, + apiPost: () => pendingCapture.promise, + addLog: () => {}, + escapeHtml: String, + }; + + const captureOperation = new vm.Script(`${performanceSource}\ncaptureCpuProfile();`, { + filename: 'debugger-performance.js', + }).runInNewContext(context) as Promise; + + expect(performanceState.profileActive).toBeTrue(); + expect(performanceState.activeProfileContextId).toBe('requested-context'); + expect(profileStatusPill.textContent).toBe('Recording'); + expect(profileContextSelect.disabled).toBeTrue(); + expect(profileStartButton.disabled).toBeTrue(); + expect(profileStopButton.disabled).toBeFalse(); + expect(profileCaptureButton.disabled).toBeTrue(); + + pendingCapture.resolve(completedProfile); + await captureOperation; + + expect(performanceState.profileActive).toBeFalse(); + expect(performanceState.activeProfileContextId).toBeNull(); + expect(performanceState.lastProfile).toBe(completedProfile); + expect(profileStatusPill.textContent).toBe('Idle'); + expect(profileContextSelect.disabled).toBeFalse(); + expect(profileStartButton.disabled).toBeFalse(); + expect(profileStopButton.disabled).toBeTrue(); + expect(profileCaptureButton.disabled).toBeFalse(); + }); }); diff --git a/npm_modules/cli/src/debugger/server.spec.ts b/npm_modules/cli/src/debugger/server.spec.ts new file mode 100644 index 000000000..ba7a153ae --- /dev/null +++ b/npm_modules/cli/src/debugger/server.spec.ts @@ -0,0 +1,589 @@ +import 'jasmine'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { DebuggerServerInfo } from './server'; +import { startDebuggerServer } from './server'; + +interface HttpResult { + body: string; + contentSecurityPolicy: string; + contentType: string; + statusCode: number; + xFrameOptions: string; +} + +interface HttpRequestOptions { + method: string; + headers: http.OutgoingHttpHeaders; + body: string | undefined; +} + +interface StreamingHttpRequest { + request: http.ClientRequest; + result: Promise; +} + +const GET_REQUEST_OPTIONS: HttpRequestOptions = { + method: 'GET', + headers: {}, + body: undefined, +}; + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (typeof address !== 'object' || address === null) { + server.close(() => reject(new Error('Could not allocate a TCP port.'))); + return; + } + server.close(error => (error ? reject(error) : resolve(address.port))); + }); + }); +} + +async function listenOnPort(port: number): Promise { + const server = net.createServer(); + return await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => resolve(server)); + }); +} + +async function closeServer(server: { close: (callback: (error?: Error) => void) => void }): Promise { + await new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())); + }); +} + +async function request(url: string, options: HttpRequestOptions): Promise { + return await new Promise((resolve, reject) => { + const outgoingRequest = http.request( + url, + { + method: options.method, + headers: options.headers, + }, + response => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', chunk => { + body += chunk; + }); + response.on('end', () => { + resolve({ + body, + contentSecurityPolicy: String(response.headers['content-security-policy'] ?? ''), + contentType: String(response.headers['content-type'] ?? ''), + statusCode: response.statusCode ?? 0, + xFrameOptions: String(response.headers['x-frame-options'] ?? ''), + }); + }); + }, + ); + outgoingRequest.once('error', reject); + if (options.body !== undefined) { + outgoingRequest.write(options.body); + } + outgoingRequest.end(); + }); +} + +function startStreamingRequest( + url: string, + method: string, + headers: http.OutgoingHttpHeaders = {}, +): StreamingHttpRequest { + const outgoingRequest = http.request(url, { method, headers }); + const result = new Promise((resolve, reject) => { + outgoingRequest.once('response', response => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', chunk => { + body += chunk; + }); + response.on('end', () => { + resolve({ + body, + contentSecurityPolicy: String(response.headers['content-security-policy'] ?? ''), + contentType: String(response.headers['content-type'] ?? ''), + statusCode: response.statusCode ?? 0, + xFrameOptions: String(response.headers['x-frame-options'] ?? ''), + }); + }); + }); + outgoingRequest.once('error', reject); + }); + return { request: outgoingRequest, result }; +} + +async function readSseEvents( + url: string, + eventName: string, + count: number, + onEvent: (payload: Record, index: number) => void, +): Promise>> { + return await new Promise((resolve, reject) => { + let settled = false; + const payloads: Array> = []; + const outgoingRequest = http.get(url, response => { + let pending = ''; + response.setEncoding('utf8'); + response.on('data', chunk => { + pending += chunk; + let separatorIndex = pending.indexOf('\n\n'); + while (separatorIndex !== -1) { + const eventBlock = pending.slice(0, separatorIndex); + pending = pending.slice(separatorIndex + 2); + const lines = eventBlock.split('\n'); + if (lines[0] !== `event: ${eventName}`) { + separatorIndex = pending.indexOf('\n\n'); + continue; + } + const dataLine = lines.find(line => line.startsWith('data: ')); + if (dataLine && !settled) { + const payload = JSON.parse(dataLine.slice(6)) as Record; + payloads.push(payload); + onEvent(payload, payloads.length - 1); + if (payloads.length === count) { + settled = true; + response.destroy(); + outgoingRequest.destroy(); + resolve(payloads); + return; + } + } + separatorIndex = pending.indexOf('\n\n'); + } + }); + }); + outgoingRequest.once('error', error => { + if (!settled) reject(error); + }); + }); +} + +describe('debugger server', () => { + let assetRoot: string; + let debuggerServer: DebuggerServerInfo | undefined; + let occupiedPortServer: net.Server | undefined; + let originalHome: string | undefined; + + beforeEach(() => { + originalHome = process.env['HOME']; + assetRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'valdi-debugger-assets-')); + fs.writeFileSync(path.join(assetRoot, 'index.html'), 'Valdi Debugger', 'utf8'); + }); + + afterEach(async () => { + if (debuggerServer) { + await debuggerServer.close(); + debuggerServer = undefined; + } + if (occupiedPortServer) { + await closeServer(occupiedPortServer); + occupiedPortServer = undefined; + } + if (originalHome === undefined) { + delete process.env['HOME']; + } else { + process.env['HOME'] = originalHome; + } + fs.rmSync(assetRoot, { recursive: true, force: true }); + }); + + it('serves the packaged debugger application', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + + const result = await request(debuggerServer.url, GET_REQUEST_OPTIONS); + + expect(result.statusCode).toBe(200); + expect(result.contentType).toBe('text/html; charset=utf-8'); + expect(result.body).toContain('Valdi Debugger'); + expect(result.contentSecurityPolicy).toContain("script-src 'self'"); + expect(result.contentSecurityPolicy).toContain("frame-ancestors 'none'"); + expect(result.xFrameOptions).toBe('DENY'); + }); + + it('closes cleanly while an event stream is open', async () => { + const serverToClose = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + debuggerServer = serverToClose; + + const response = await new Promise((resolve, reject) => { + const outgoingRequest = http.get(new URL('/api/debugger/events', serverToClose.url).toString(), resolve); + outgoingRequest.once('error', reject); + }); + response.resume(); + const responseEnded = new Promise(resolve => response.once('end', resolve)); + debuggerServer = undefined; + + await serverToClose.close(); + await responseEnded; + }); + + it('selects the next port when the preferred port is occupied', async () => { + const preferredPort = await getFreePort(); + occupiedPortServer = await listenOnPort(preferredPort); + + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: preferredPort, + strictPort: false, + }); + + expect(debuggerServer.requestedPort).toBe(preferredPort); + expect(debuggerServer.port).toBe(preferredPort + 1); + expect(debuggerServer.portWasAutoSelected).toBeTrue(); + }); + + it('fails when strict port selection encounters an occupied port', async () => { + const preferredPort = await getFreePort(); + occupiedPortServer = await listenOnPort(preferredPort); + + await expectAsync( + startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: preferredPort, + strictPort: true, + }), + ).toBeRejectedWith(jasmine.objectContaining({ code: 'EADDRINUSE' })); + }); + + it('rejects non-loopback bind addresses', async () => { + await expectAsync( + startDebuggerServer({ + assetRoot, + host: '0.0.0.0', + port: await getFreePort(), + strictPort: true, + }), + ).toBeRejectedWithError(/only binds to loopback hosts/); + }); + + it('rejects invalid debugger ports', async () => { + await expectAsync( + startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: 65_536, + strictPort: true, + }), + ).toBeRejectedWithError(/between 1 and 65535/); + }); + + it('rejects non-loopback Host headers', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + + const result = await request(debuggerServer.url, { + method: 'GET', + headers: { Host: `attacker.example:${debuggerServer.port}` }, + body: undefined, + }); + + expect(result.statusCode).toBe(403); + }); + + it('rejects requests from a non-loopback browser origin', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + + const result = await request(new URL('/api/debugger/actions', debuggerServer.url).toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'https://attacker.example', + }, + body: JSON.stringify({ action: 'refreshSnapshot', params: {} }), + }); + + expect(result.statusCode).toBe(403); + }); + + it('rejects requests from a different loopback origin', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + + const result = await request(new URL('/api/debugger/actions', debuggerServer.url).toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: `http://127.0.0.2:${debuggerServer.port}`, + }, + body: JSON.stringify({ action: 'refreshSnapshot', params: {} }), + }); + + expect(result.statusCode).toBe(403); + }); + + it('rejects cross-site API subresource requests without an Origin header', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + + const result = await request(new URL('/api/status', debuggerServer.url).toString(), { + method: 'GET', + headers: { 'Sec-Fetch-Site': 'cross-site' }, + body: undefined, + }); + + expect(result.statusCode).toBe(403); + }); + + it('accepts same-origin browser actions', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + + const result = await request(new URL('/api/debugger/actions', debuggerServer.url).toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: new URL(debuggerServer.url).origin, + }, + body: JSON.stringify({ action: 'setAutoRefresh', params: { enabled: true } }), + }); + + expect(result.statusCode).toBe(200); + const responseBody = JSON.parse(result.body) as { state: { autoRefresh: boolean } }; + expect(responseBody.state.autoRefresh).toBeTrue(); + }); + + it('rejects unknown debugger actions and invalid ports', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + const actionsUrl = new URL('/api/debugger/actions', debuggerServer.url).toString(); + + const unknown = await request(actionsUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'typoAction', params: {} }), + }); + const invalidPort = await request(actionsUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'setPort', params: { port: 70_000 } }), + }); + + expect(unknown.statusCode).toBe(400); + expect((JSON.parse(unknown.body) as { error: string }).error).toContain('Unknown debugger action'); + expect(invalidPort.statusCode).toBe(400); + expect((JSON.parse(invalidPort.body) as { error: string }).error).toContain('between 1 and 65535'); + }); + + it('decodes JSON only after joining split UTF-8 request bytes', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + const body = Buffer.from(JSON.stringify({ action: 'refreshSnapshot', params: {}, source: 'café' }), 'utf8'); + const encodedCharacter = Buffer.from('é', 'utf8'); + const characterOffset = body.indexOf(encodedCharacter); + expect(characterOffset).toBeGreaterThan(0); + const streaming = startStreamingRequest(new URL('/api/debugger/actions', debuggerServer.url).toString(), 'POST', { + 'Content-Type': 'application/json', + }); + + streaming.request.write(body.subarray(0, characterOffset + 1)); + await new Promise(resolve => setImmediate(resolve)); + streaming.request.end(body.subarray(characterOffset + 1)); + const result = await streaming.result; + + expect(result.statusCode).toBe(200); + expect((JSON.parse(result.body) as { source: string }).source).toBe('café'); + }); + + it('serializes concurrent CPU profile transitions before reading their bodies', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + const profileUrl = new URL('/api/performance/profile/start', debuggerServer.url).toString(); + const first = startStreamingRequest(profileUrl, 'POST', { 'Content-Type': 'application/json' }); + first.request.write('{'); + await new Promise(resolve => setTimeout(resolve, 20)); + + const second = await request(profileUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + first.request.end('invalid'); + await first.result; + + expect(second.statusCode).toBe(500); + expect((JSON.parse(second.body) as { error: string }).error).toContain('transition is already in progress'); + }); + + it('does not serve paths outside the debugger asset root', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + + const result = await request(new URL('/%2e%2e%2foutside.txt', debuggerServer.url).toString(), GET_REQUEST_OPTIONS); + + expect(result.statusCode).toBe(403); + expect(result.body).toBe('Forbidden'); + }); + + it('returns JSON for unknown API routes', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + + const result = await request(new URL('/api/unknown', debuggerServer.url).toString(), GET_REQUEST_OPTIONS); + + expect(result.statusCode).toBe(404); + expect(result.contentType).toBe('application/json; charset=utf-8'); + expect(JSON.parse(result.body)).toEqual({ error: 'Unknown API route /api/unknown' }); + }); + + it('selects the exact runtime log for an application and platform', async () => { + const logsDirectory = path.join(assetRoot, 'logs'); + fs.mkdirSync(logsDirectory); + fs.writeFileSync( + path.join(logsDirectory, 'ios-example.log'), + '2026-08-24 01:02:03 +0000 [info] [JS] exact application\n', + 'utf8', + ); + fs.writeFileSync( + path.join(logsDirectory, 'ios-example-preview.log'), + '2026-08-24 01:02:04 +0000 [info] [JS] newer prefix collision\n', + 'utf8', + ); + const newerTime = new Date(Date.now() + 10_000); + fs.utimesSync(path.join(logsDirectory, 'ios-example-preview.log'), newerTime, newerTime); + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + logsDirectory, + port: await getFreePort(), + strictPort: true, + }); + + const result = await request( + new URL('/api/runtime-logs?applicationId=example&platform=ios', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + const responseBody = JSON.parse(result.body) as { + logFile: string; + logs: Array<{ message: string }>; + }; + + expect(result.statusCode).toBe(200); + expect(responseBody.logFile).toBe('ios-example.log'); + expect(responseBody.logs.map(log => log.message)).toEqual(['exact application']); + }); + + it('reads the logs directory through the shared YAML config parser', async () => { + const testHome = path.join(assetRoot, 'home'); + const logsDirectory = path.join(testHome, 'logs#debug'); + fs.mkdirSync(path.join(testHome, '.valdi'), { recursive: true }); + fs.mkdirSync(logsDirectory); + fs.writeFileSync( + path.join(testHome, '.valdi', 'config.yaml'), + 'logs_output_dir: "~/logs#debug" # inline comments are valid YAML\n', + 'utf8', + ); + fs.writeFileSync( + path.join(logsDirectory, 'ios-configured.log'), + '2026-08-24 01:02:03 +0000 [info] [JS] configured directory\n', + 'utf8', + ); + process.env['HOME'] = testHome; + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + + const result = await request( + new URL('/api/runtime-logs?applicationId=configured&platform=ios', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + const responseBody = JSON.parse(result.body) as { logFile: string; logs: Array<{ message: string }> }; + + expect(result.statusCode).toBe(200); + expect(responseBody.logFile).toBe('ios-configured.log'); + expect(responseBody.logs.map(log => log.message)).toEqual(['configured directory']); + }); + + it('preserves repeated runtime log entries as the stream window advances', async () => { + const logsDirectory = path.join(assetRoot, 'logs'); + fs.mkdirSync(logsDirectory); + const repeatedLine = '2026-08-24 01:02:03 +0000 [info] [JS] repeated\n'; + const logPath = path.join(logsDirectory, 'ios-example.log'); + fs.writeFileSync(logPath, repeatedLine.repeat(2), 'utf8'); + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + logsDirectory, + port: await getFreePort(), + strictPort: true, + }); + + const payloads = await readSseEvents( + new URL('/api/runtime-logs/stream?applicationId=example&platform=ios&limit=2', debuggerServer.url).toString(), + 'logs', + 2, + (_payload, index) => { + if (index === 0) fs.appendFileSync(logPath, repeatedLine, 'utf8'); + }, + ); + const initialLogs = payloads[0]?.['logs'] as Array<{ message: string }>; + const appendedLogs = payloads[1]?.['logs'] as Array<{ message: string }>; + + expect(initialLogs.map(log => log.message)).toEqual(['repeated', 'repeated']); + expect(appendedLogs.map(log => log.message)).toEqual(['repeated']); + }); +}); diff --git a/npm_modules/cli/src/debugger/server.ts b/npm_modules/cli/src/debugger/server.ts new file mode 100644 index 000000000..b02c1b5a9 --- /dev/null +++ b/npm_modules/cli/src/debugger/server.ts @@ -0,0 +1,1565 @@ +import { watch as watchFileSystem } from 'node:fs'; +import type { FSWatcher, Stats } from 'node:fs'; +import fs from 'node:fs/promises'; +import type { IncomingMessage, Server, ServerResponse } from 'node:http'; +import http from 'node:http'; +import net from 'node:net'; +import path from 'node:path'; +import { + type DaemonConnectedClient, + type DaemonConnection, + MOBILE_PORT, + type RemoteContext, + STANDALONE_PORT, + connectToDaemon, +} from '../utils/daemonClient'; +import { getUserConfig, resolveFilePath } from '../utils/fileUtils'; +import { type CpuProfile, HERMES_PORT, HermesConnection, listHermesDevices } from '../utils/hermesClient'; + +const DEFAULT_HOST = process.env['VALDI_DEBUGGER_HOST'] || '127.0.0.1'; +const DEFAULT_PORT = Number.parseInt(process.env['VALDI_DEBUGGER_PORT'] || '8765', 10); +const HOT_RELOAD_PROXY_PORT = Number.parseInt(process.env['VALDI_HOT_RELOAD_PROXY_PORT'] || '9010', 10); +const PORT_SEARCH_LIMIT = 50; +const MAX_RUNTIME_LOG_READ_BYTES = 1024 * 1024; + +const MIME_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', +}; + +interface LogEntry { + time: string; + level: string; + source: string; + message: string; + timestamp?: string; +} + +interface LogFile { + path: string; + stat: Stats; +} + +interface ParsedLogRecord { + entry: LogEntry; + offset: number; +} + +interface RuntimeLogTail { + content: string; + startOffset: number; +} + +interface ClientWithContexts extends DaemonConnectedClient { + contexts: RemoteContext[]; + contextError: string | null; +} + +interface ActiveProfileSession { + conn: HermesConnection; + port: number; + contextId: string; + contextTitle: string; + startedAtMs: number; + startedAtEpochMs: number; +} + +interface DebuggerActionRecord { + action: string; + params: Record; + source: string; + time: string; +} + +interface DebuggerUiState { + revision: number; + selectedNodeId: string | null; + selectedTargetId: string | null; + activeSection: string; + activeTab: string; + overlayMode: string; + autoRefresh: boolean; + followLatestTarget: boolean; + port: number; + updatedAt: string; + lastAction: DebuggerActionRecord | null; +} + +interface DebuggerServerOptions { + host?: string; + port?: number; + strictPort?: boolean; + assetRoot?: string; + logsDirectory?: string; +} + +class ApiRequestError extends Error { + readonly statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.statusCode = statusCode; + } +} + +export interface DebuggerServerInfo { + server: Server; + close: () => Promise; + host: string; + port: number; + url: string; + requestedPort: number; + portWasAutoSelected: boolean; +} + +const devEventClients = new Set(); +const debuggerEventClients = new Set(); +const eventStreamClosers = new Set<() => void>(); +const assetWatchers = new Set(); +const debuggerActions = [ + 'attach', + 'detach', + 'refreshTargets', + 'refreshSnapshot', + 'selectTarget', + 'selectNode', + 'setActiveSection', + 'setActiveTab', + 'setOverlayMode', + 'setAutoRefresh', + 'setPort', + 'clearLogs', + 'captureElementSnapshot', + 'dumpHeap', + 'refreshHermesContexts', + 'startCpuProfile', + 'stopCpuProfile', + 'captureCpuProfile', +]; +let devRevision = 0; +let debuggerEventRevision = 0; +let devReloadTimer: NodeJS.Timeout | null = null; +let activeHost = DEFAULT_HOST; +let assetRoot = getDefaultAssetRoot(); +let activeLogsDirectory: string | null = null; +let activeProfileSession: ActiveProfileSession | null = null; +let profileTransitionInProgress = false; +const debuggerUiState = createDebuggerUiState(); + +function getDefaultAssetRoot(): string { + // The published CLI is emitted as CommonJS, so __dirname is the reliable package-relative anchor. + // eslint-disable-next-line unicorn/prefer-module + return path.resolve(__dirname, '..', '..', 'debugger'); +} + +function normalizedHostname(hostname: string): string { + return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; +} + +function isLoopbackHost(host: string): boolean { + const normalizedHost = normalizedHostname(host); + return ( + normalizedHost === 'localhost' || + normalizedHost === '::1' || + (net.isIP(normalizedHost) === 4 && normalizedHost.startsWith('127.')) + ); +} + +function hostForUrl(host: string): string { + return net.isIP(normalizedHostname(host)) === 6 ? `[${normalizedHostname(host)}]` : host; +} + +function parseHttpUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} + +function hasExpectedLoopbackAuthority(url: URL, port: number): boolean { + const effectivePort = url.port || (url.protocol === 'http:' ? '80' : '443'); + return ( + url.protocol === 'http:' && + isLoopbackHost(url.hostname) && + effectivePort === String(port) && + !url.username && + !url.password + ); +} + +function isAllowedRequestHost(hostHeader: string | undefined, port: number): boolean { + if (!hostHeader) return false; + const parsed = parseHttpUrl(`http://${hostHeader}`); + return parsed !== null && hasExpectedLoopbackAuthority(parsed, port); +} + +function isAllowedRequestOrigin( + originHeader: string | undefined, + hostHeader: string | undefined, + port: number, +): boolean { + if (originHeader === undefined) return true; + const parsed = parseHttpUrl(originHeader); + const requestAuthority = hostHeader ? parseHttpUrl(`http://${hostHeader}`) : null; + return ( + parsed !== null && + requestAuthority !== null && + hasExpectedLoopbackAuthority(parsed, port) && + hasExpectedLoopbackAuthority(requestAuthority, port) && + normalizedHostname(parsed.hostname) === normalizedHostname(requestAuthority.hostname) && + parsed.pathname === '/' && + !parsed.search && + !parsed.hash + ); +} + +function isAllowedApiFetchSite(fetchSiteHeader: string | undefined): boolean { + return fetchSiteHeader === undefined || fetchSiteHeader === 'same-origin' || fetchSiteHeader === 'none'; +} + +function probeTcpPort(port: number, timeoutMs: number): Promise { + return new Promise(resolve => { + const socket = connectToTcpPort(port); + let settled = false; + const done = (connected: boolean) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(connected); + }; + socket.setTimeout(timeoutMs, () => done(false)); + socket.once('connect', () => done(true)); + socket.once('error', () => done(false)); + }); +} + +function connectToTcpPort(port: number): net.Socket { + return net.connect({ host: activeHost, port }); +} + +function portName(port: number): string { + if (port === STANDALONE_PORT) return 'standalone'; + if (port === MOBILE_PORT) return 'mobile'; + return 'custom'; +} + +function errorPayload(error: unknown): { error: string } { + return { + error: error instanceof Error ? error.message : String(error), + }; +} + +function isValidSnapshotBase64(value: string): boolean { + if (!value || value.length % 4 === 1) return false; + const firstPaddingIndex = value.indexOf('='); + const dataLength = firstPaddingIndex === -1 ? value.length : firstPaddingIndex; + const paddingLength = value.length - dataLength; + if (paddingLength > 2 || (paddingLength > 0 && value.length % 4 !== 0)) return false; + + for (let index = 0; index < dataLength; index += 1) { + const code = value.codePointAt(index) ?? 0; + const valid = + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + code === 43 || + code === 47; + if (!valid) return false; + } + for (let index = dataLength; index < value.length; index += 1) { + if (value[index] !== '=') return false; + } + return true; +} + +function sendJson(response: ServerResponse, status: number, payload: unknown): void { + response.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }); + response.end(JSON.stringify(payload)); +} + +async function readJsonBody(request: IncomingMessage): Promise> { + const chunks: Buffer[] = []; + let byteLength = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + byteLength += buffer.length; + if (byteLength > 1024 * 1024) { + throw new Error('Request body is too large.'); + } + chunks.push(buffer); + } + + const raw = Buffer.concat(chunks, byteLength).toString('utf8'); + if (!raw.trim()) return {}; + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Request body must be a JSON object.'); + } + return parsed as Record; +} + +function readNumber(searchParams: URLSearchParams, key: string, fallback: number): number { + const value = searchParams.get(key); + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? fallback : parsed; +} + +function readBodyNumber(body: Record, key: string, fallback: number): number { + const value = body[key]; + if (value === undefined) return fallback; + const parsed = Number.parseInt(String(value), 10); + return Number.isNaN(parsed) ? fallback : parsed; +} + +function readBodyString(body: Record, key: string): string | undefined { + const value = body[key]; + if (value === undefined || value === null || value === '') return undefined; + return String(value); +} + +function readBodyRecord(body: Record, key: string): Record { + const value = body[key]; + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} + +function readRecordString(record: Record, key: string): string | undefined { + const value = record[key]; + if (value === undefined || value === null || value === '') return undefined; + return String(value); +} + +function readRecordNumber(record: Record, key: string): number | undefined { + const value = record[key]; + if (value === undefined || value === null || value === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function readRecordBoolean(record: Record, key: string): boolean | undefined { + const value = record[key]; + if (value === undefined || value === null || value === '') return undefined; + if (typeof value === 'boolean') return value; + const normalized = String(value).toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return Boolean(value); +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function delay(durationMs: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, durationMs); + }); +} + +function createDebuggerUiState(): DebuggerUiState { + return { + revision: 0, + selectedNodeId: null, + selectedTargetId: null, + activeSection: 'ui', + activeTab: 'overview', + overlayMode: 'live', + autoRefresh: false, + followLatestTarget: true, + port: STANDALONE_PORT, + updatedAt: new Date().toISOString(), + lastAction: null, + }; +} + +function normalizeDebuggerSection(section: string): string { + const normalized = section.trim().toLowerCase(); + if (normalized === 'logger' || normalized === 'loger') return 'logs'; + return normalized; +} + +function readValdiLogsDirectory(): string { + if (activeLogsDirectory !== null) return activeLogsDirectory; + const logsOutputDirectory = getUserConfig().logs_output_dir; + if (typeof logsOutputDirectory !== 'string' || logsOutputDirectory.trim() === '') { + throw new Error("Missing 'logs_output_dir' config value in the Valdi config file."); + } + return resolveFilePath(logsOutputDirectory); +} + +async function collectLogFiles(directory: string): Promise { + const files: LogFile[] = []; + + async function visit(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await visit(fullPath); + } else if (entry.isFile() && fullPath.endsWith('.log')) { + const stat = await fs.stat(fullPath); + files.push({ path: fullPath, stat }); + } + } + } + + await visit(directory); + return files.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs); +} + +function parseValdiLogRecords(content: string, limit: number, startOffset: number): ParsedLogRecord[] { + const records: ParsedLogRecord[] = []; + let current: ParsedLogRecord | null = null; + let byteOffset = startOffset; + const linePattern = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) [+-]\d{4} \[(debug|log|info|warn|error)] (.*)$/; + + function pushCurrent() { + if (current) records.push(current); + current = null; + } + + for (const rawLine of content.match(/[^\n]*(?:\n|$)/g) ?? []) { + if (rawLine === '') continue; + const lineOffset = byteOffset; + byteOffset += Buffer.byteLength(rawLine, 'utf8'); + const line = rawLine.endsWith('\n') ? rawLine.slice(0, -1).replace(/\r$/, '') : rawLine; + const match = line.match(linePattern); + if (match) { + pushCurrent(); + const rawMessage = match[4] ?? ''; + const isJsLog = rawMessage.startsWith('[JS]'); + current = { + entry: { + time: match[2] ?? '', + level: match[3] === 'log' ? 'info' : (match[3] ?? 'info'), + source: isJsLog ? 'js' : 'valdi', + message: isJsLog ? rawMessage.replace(/^\[JS]\s*/, '') : rawMessage, + timestamp: `${match[1] ?? ''}T${match[2] ?? ''}Z`, + }, + offset: lineOffset, + }; + } else if (/^-+ Session started /.test(line)) { + pushCurrent(); + records.length = 0; + } else if (/^-+ Session /.test(line)) { + pushCurrent(); + } else if (current && line.trim()) { + current.entry.message += `\n${line}`; + } + } + pushCurrent(); + + return records.slice(-limit); +} + +function parseValdiLog(content: string, limit: number): LogEntry[] { + return parseValdiLogRecords(content, limit, 0).map(record => record.entry); +} + +async function inspectRuntimeLogs(searchParams: URLSearchParams): Promise> { + const limit = clampNumber(readNumber(searchParams, 'limit', 120), 1, 1000); + const selected = await selectRuntimeLogFile(searchParams); + if (!selected.latest) return { logFile: null, logs: [] }; + + const { content } = await readRuntimeLogTail(selected.latest); + return { + logFile: path.basename(selected.latest.path), + size: selected.latest.stat.size, + modifiedAt: selected.latest.stat.mtime.toISOString(), + logs: parseValdiLog(content, limit), + }; +} + +async function readRuntimeLogTail(logFile: LogFile): Promise { + if (logFile.stat.size <= MAX_RUNTIME_LOG_READ_BYTES) { + return { + content: await fs.readFile(logFile.path, 'utf8'), + startOffset: 0, + }; + } + + const file = await fs.open(logFile.path, 'r'); + try { + const buffer = Buffer.alloc(MAX_RUNTIME_LOG_READ_BYTES); + const position = Math.max(0, logFile.stat.size - MAX_RUNTIME_LOG_READ_BYTES); + const { bytesRead } = await file.read(buffer, 0, buffer.length, position); + const tail = buffer.subarray(0, bytesRead); + const firstLineBreak = tail.indexOf(10); + if (firstLineBreak === -1) return { content: '', startOffset: position + bytesRead }; + return { + content: tail.subarray(firstLineBreak + 1).toString('utf8'), + startOffset: position + firstLineBreak + 1, + }; + } finally { + await file.close(); + } +} + +async function selectRuntimeLogFile(searchParams: URLSearchParams): Promise<{ + logsDirectory: string; + latest: LogFile | null; +}> { + const applicationId = searchParams.get('applicationId'); + const platform = searchParams.get('platform'); + const logsDirectory = readValdiLogsDirectory(); + const logFiles = await collectLogFiles(logsDirectory); + let latest: LogFile | undefined; + if (applicationId !== null && platform !== null) { + latest = logFiles.find(file => path.basename(file.path) === `${platform}-${applicationId}.log`); + } else if (applicationId !== null) { + latest = logFiles.find(file => path.basename(file.path).endsWith(`-${applicationId}.log`)); + } else if (platform !== null) { + latest = logFiles.find(file => path.basename(file.path).startsWith(`${platform}-`)); + } else if (logFiles.length === 1) { + latest = logFiles[0]; + } + + return { logsDirectory, latest: latest ?? null }; +} + +function sendSse(response: ServerResponse, event: string, payload: unknown): void { + response.write(`event: ${event}\n`); + response.write(`data: ${JSON.stringify(payload)}\n\n`); +} + +function registerEventStream(request: IncomingMessage, response: ServerResponse, onClose: () => void): () => void { + let closed = false; + const close = () => { + if (closed) return; + closed = true; + eventStreamClosers.delete(close); + onClose(); + if (!response.writableEnded) response.end(); + }; + eventStreamClosers.add(close); + request.once('aborted', close); + response.once('close', close); + response.once('finish', close); + return close; +} + +function broadcastDevEvent(event: string, payload: unknown): void { + for (const response of devEventClients) { + try { + sendSse(response, event, payload); + } catch { + devEventClients.delete(response); + } + } +} + +function scheduleDevReload(fileName: string): void { + if (devReloadTimer) clearTimeout(devReloadTimer); + devReloadTimer = setTimeout(() => { + devReloadTimer = null; + devRevision += 1; + broadcastDevEvent('reload', { + revision: devRevision, + file: fileName, + time: new Date().toISOString(), + }); + }, 75); +} + +function streamDevEvents(request: IncomingMessage, response: ServerResponse): void { + response.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-store, no-transform', + Connection: 'keep-alive', + }); + response.write('\n'); + devEventClients.add(response); + sendSse(response, 'ready', { + revision: devRevision, + time: new Date().toISOString(), + }); + + const heartbeat = setInterval(() => { + try { + sendSse(response, 'heartbeat', { + revision: devRevision, + time: new Date().toISOString(), + }); + } catch { + if (!response.writableEnded) response.end(); + } + }, 15_000); + registerEventStream(request, response, () => { + devEventClients.delete(response); + clearInterval(heartbeat); + }); +} + +function cloneDebuggerActionRecord(record: DebuggerActionRecord | null): DebuggerActionRecord | null { + if (!record) return null; + return { + ...record, + params: { ...record.params }, + }; +} + +function cloneDebuggerUiState(): DebuggerUiState { + return { + ...debuggerUiState, + lastAction: cloneDebuggerActionRecord(debuggerUiState.lastAction), + }; +} + +function debuggerStatePayload(): Record { + return { + state: cloneDebuggerUiState(), + capabilities: { + actions: debuggerActions, + }, + server: { + host: activeHost, + hotReloadProxyPort: HOT_RELOAD_PROXY_PORT, + time: new Date().toISOString(), + }, + }; +} + +function broadcastDebuggerEvent(event: string, payload: unknown): void { + for (const response of debuggerEventClients) { + try { + sendSse(response, event, payload); + } catch { + debuggerEventClients.delete(response); + } + } +} + +function streamDebuggerEvents(request: IncomingMessage, response: ServerResponse): void { + response.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-store, no-transform', + Connection: 'keep-alive', + }); + response.write('\n'); + debuggerEventClients.add(response); + sendSse(response, 'ready', debuggerStatePayload()); + + const heartbeat = setInterval(() => { + try { + sendSse(response, 'heartbeat', debuggerStatePayload()); + } catch { + if (!response.writableEnded) response.end(); + } + }, 15_000); + registerEventStream(request, response, () => { + debuggerEventClients.delete(response); + clearInterval(heartbeat); + }); +} + +function applyDebuggerUiAction(action: string, params: Record, source: string): DebuggerActionRecord { + const time = new Date().toISOString(); + const nextParams = { ...params }; + + switch (action) { + case 'selectNode': { + const id = readRecordString(nextParams, 'id') ?? readRecordString(nextParams, 'nodeId'); + if (id !== undefined) debuggerUiState.selectedNodeId = id; + + break; + } + case 'selectTarget': { + const id = readRecordString(nextParams, 'id') ?? readRecordString(nextParams, 'targetId'); + if (id !== undefined) { + debuggerUiState.selectedTargetId = id; + debuggerUiState.followLatestTarget = false; + } + + break; + } + case 'setActiveTab': { + const tab = readRecordString(nextParams, 'tab'); + if (tab !== undefined) debuggerUiState.activeTab = tab; + + break; + } + case 'setActiveSection': { + const section = readRecordString(nextParams, 'section'); + if (section !== undefined) { + const normalizedSection = normalizeDebuggerSection(section); + nextParams['section'] = normalizedSection; + debuggerUiState.activeSection = normalizedSection; + } + + break; + } + case 'setOverlayMode': { + const mode = readRecordString(nextParams, 'mode'); + if (mode !== undefined) { + const normalizedMode = mode === 'views' || mode === 'components' ? 'live' : mode; + nextParams['mode'] = normalizedMode; + debuggerUiState.overlayMode = normalizedMode; + } + + break; + } + case 'setAutoRefresh': { + const enabled = readRecordBoolean(nextParams, 'enabled'); + if (enabled !== undefined) debuggerUiState.autoRefresh = enabled; + + break; + } + case 'setPort': { + const port = readRecordNumber(nextParams, 'port'); + if (port === undefined || !Number.isInteger(port) || port < 1 || port > 65_535) { + throw new ApiRequestError(400, 'setPort requires an integer port between 1 and 65535.'); + } + debuggerUiState.port = port; + + break; + } + case 'attach': { + debuggerUiState.followLatestTarget = true; + + break; + } + case 'detach': { + debuggerUiState.followLatestTarget = false; + + break; + } + // No default + } + + const record = { + action, + params: nextParams, + source, + time, + }; + debuggerEventRevision += 1; + debuggerUiState.revision = debuggerEventRevision; + debuggerUiState.updatedAt = time; + debuggerUiState.lastAction = record; + return record; +} + +async function handleDebuggerAction(request: IncomingMessage): Promise> { + const body = await readJsonBody(request); + const action = readBodyString(body, 'action') ?? readBodyString(body, 'type'); + if (!action) { + throw new ApiRequestError(400, 'Debugger action requests require an action.'); + } + if (!debuggerActions.includes(action)) { + throw new ApiRequestError(400, `Unknown debugger action '${action}'.`); + } + const params = readBodyRecord(body, 'params'); + const source = readBodyString(body, 'source') ?? 'agent'; + const record = applyDebuggerUiAction(action, params, source); + const payload = { + ...record, + state: cloneDebuggerUiState(), + }; + broadcastDebuggerEvent('debugger-action', payload); + broadcastDebuggerEvent('debugger-state', debuggerStatePayload()); + return { + ok: true, + ...payload, + }; +} + +async function streamRuntimeLogs( + request: IncomingMessage, + response: ServerResponse, + searchParams: URLSearchParams, +): Promise { + const limit = clampNumber(readNumber(searchParams, 'limit', 120), 1, 1000); + const sentKeyLimit = limit * 2; + const sent = new Set(); + let closed = false; + let polling = false; + let lastLogPath: string | null = null; + let lastLogInode: number | null = null; + let lastLogModifiedMs = -1; + let lastLogSize = -1; + + response.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-store, no-transform', + Connection: 'keep-alive', + }); + response.write('\n'); + + async function readAndSend() { + if (closed) return; + try { + const selected = await selectRuntimeLogFile(searchParams); + if (!selected.latest) { + sendSse(response, 'meta', { logFile: null }); + return; + } + + const logFileChanged = selected.latest.path !== lastLogPath || selected.latest.stat.ino !== lastLogInode; + const logFileTruncated = !logFileChanged && lastLogSize >= 0 && selected.latest.stat.size < lastLogSize; + if (logFileChanged || logFileTruncated) { + sent.clear(); + lastLogPath = selected.latest.path; + lastLogInode = selected.latest.stat.ino; + lastLogModifiedMs = -1; + lastLogSize = -1; + sendSse(response, 'meta', { + logFile: path.basename(selected.latest.path), + size: selected.latest.stat.size, + modifiedAt: selected.latest.stat.mtime.toISOString(), + }); + } + + if (selected.latest.stat.mtimeMs === lastLogModifiedMs && selected.latest.stat.size === lastLogSize) { + return; + } + lastLogModifiedMs = selected.latest.stat.mtimeMs; + lastLogSize = selected.latest.stat.size; + + const tail = await readRuntimeLogTail(selected.latest); + const records = parseValdiLogRecords(tail.content, limit, tail.startOffset); + const nextLogs: LogEntry[] = []; + for (const record of records) { + const key = `${selected.latest.stat.ino}:${record.offset}`; + if (sent.has(key)) continue; + sent.add(key); + nextLogs.push(record.entry); + } + while (sent.size > sentKeyLimit) { + const oldestKey = sent.values().next().value; + if (oldestKey === undefined) break; + sent.delete(oldestKey); + } + + if (nextLogs.length > 0) sendSse(response, 'logs', { logs: nextLogs }); + } catch (error) { + sendSse(response, 'stream-error', errorPayload(error)); + } + } + + async function poll() { + if (closed || polling) return; + polling = true; + try { + await readAndSend(); + } finally { + polling = false; + } + } + + // Poll instead of fs.watch so several debugger tabs do not exhaust macOS file watcher limits. + const poller = setInterval(() => { + void poll(); + }, 1000); + + const heartbeat = setInterval(() => { + if (!closed) sendSse(response, 'heartbeat', { time: new Date().toISOString() }); + }, 15_000); + + registerEventStream(request, response, () => { + closed = true; + clearInterval(poller); + clearInterval(heartbeat); + }); + + await poll(); +} + +async function withConnection(port: number, callback: (conn: DaemonConnection) => Promise): Promise { + const conn = await connectToDaemon(port); + try { + await conn.configure(); + return await callback(conn); + } finally { + conn.close(); + } +} + +async function collectClientContexts( + conn: DaemonConnection, + clients: DaemonConnectedClient[], + knownClientId: string | null, + knownContexts: RemoteContext[] | null, +): Promise { + const clientsWithContexts: ClientWithContexts[] = []; + for (const client of clients) { + let contexts: RemoteContext[] = []; + let contextError = null; + if (client.client_id === knownClientId && knownContexts !== null) { + contexts = knownContexts; + } else { + try { + contexts = await conn.listContexts(client.client_id); + } catch (error) { + contextError = errorPayload(error).error; + } + } + + clientsWithContexts.push({ + ...client, + contexts, + contextError, + }); + } + return clientsWithContexts; +} + +async function inspectPort(port: number): Promise<{ + port: number; + portName: string; + connected: boolean; + clients: ClientWithContexts[]; + error: string | null; +}> { + try { + return await withConnection(port, async conn => { + const clients = await conn.listConnectedClients(); + const clientsWithContexts = await collectClientContexts(conn, clients, null, null); + + return { + port, + portName: portName(port), + connected: true, + clients: clientsWithContexts, + error: null, + }; + }); + } catch (error) { + return { + port, + portName: portName(port), + connected: false, + clients: [], + error: errorPayload(error).error, + }; + } +} + +async function inspectStatus(searchParams: URLSearchParams): Promise> { + const explicitPort = searchParams.get('port'); + const ports = explicitPort ? [readNumber(searchParams, 'port', MOBILE_PORT)] : [STANDALONE_PORT, MOBILE_PORT]; + + const results: Array>> = []; + for (const port of ports) { + results.push(await inspectPort(port)); + } + + return { + ports: results, + hotReloadProxy: { + port: HOT_RELOAD_PROXY_PORT, + connected: await probeTcpPort(HOT_RELOAD_PROXY_PORT, 750), + }, + defaultPort: STANDALONE_PORT, + }; +} + +async function resolveTarget( + searchParams: URLSearchParams, + conn: DaemonConnection, +): Promise<{ + clients: DaemonConnectedClient[]; + client: DaemonConnectedClient; + contexts: RemoteContext[]; + context: RemoteContext; +}> { + const clients = await conn.listConnectedClients(); + if (clients.length === 0) { + throw new Error('No clients connected to this Valdi daemon.'); + } + + const requestedClientId = searchParams.get('clientId'); + const firstClient = clients[0]; + if (!firstClient) { + throw new Error('No clients connected to this Valdi daemon.'); + } + let client = firstClient; + if (requestedClientId !== null) { + const requestedClient = clients.find(candidate => candidate.client_id === requestedClientId); + if (!requestedClient) { + throw new Error(`No connected Valdi client has id ${requestedClientId}.`); + } + client = requestedClient; + } + + const contexts = await conn.listContexts(client.client_id); + if (contexts.length === 0) { + throw new Error(`No Valdi contexts found for client ${client.client_id}.`); + } + + const requestedContextId = searchParams.get('contextId'); + const lastContext = contexts.at(-1); + if (!lastContext) { + throw new Error(`No Valdi contexts found for client ${client.client_id}.`); + } + let context = lastContext; + if (requestedContextId !== null) { + const requestedContext = contexts.find(candidate => candidate.id === requestedContextId); + if (!requestedContext) { + throw new Error(`Client ${client.client_id} has no Valdi context with id ${requestedContextId}.`); + } + context = requestedContext; + } + + return { clients, client, contexts, context }; +} + +function flattenTargets( + port: number, + clients: ClientWithContexts[], + selectedClientId: string, + selectedContextId: string, +): Array> { + const targets: Array> = []; + for (const client of clients) { + for (const context of client.contexts || []) { + const selected = client.client_id === selectedClientId && context.id === selectedContextId; + targets.push({ + id: `${port}:${client.client_id}:${context.id}`, + name: context.rootComponentName || client.application_id || `Client ${client.client_id}`, + platform: client.platform || portName(port), + state: selected ? 'attached' : 'available', + transport: `daemon:${port}`, + port, + clientId: client.client_id, + contextId: context.id, + applicationId: client.application_id, + }); + } + } + return targets; +} + +async function inspectSnapshot(searchParams: URLSearchParams): Promise> { + const port = readNumber(searchParams, 'port', STANDALONE_PORT); + return await withConnection(port, async conn => { + const { clients, client, contexts, context } = await resolveTarget(searchParams, conn); + const tree = await conn.getContextTree(client.client_id, context.id, true); + const clientsWithContexts = await collectClientContexts(conn, clients, client.client_id, contexts); + const targets = flattenTargets(port, clientsWithContexts, client.client_id, context.id); + + return { + source: 'valdi-daemon', + target: { + id: `${port}:${client.client_id}:${context.id}`, + name: context.rootComponentName || client.application_id || `Client ${client.client_id}`, + platform: client.platform || portName(port), + transport: `daemon:${port}`, + state: 'attached', + proxyPort: port, + clientId: client.client_id, + contextId: context.id, + applicationId: client.application_id, + }, + targets, + contexts: targets, + tree, + issues: [], + logs: [ + { + time: new Date().toTimeString().slice(0, 8), + level: 'info', + source: 'daemon', + message: `Fetched ${context.rootComponentName || context.id} from Valdi daemon port ${port}.`, + }, + ], + }; + }); +} + +async function inspectElementSnapshot(searchParams: URLSearchParams): Promise> { + const port = readNumber(searchParams, 'port', STANDALONE_PORT); + const elementId = searchParams.get('elementId'); + if (!elementId) { + throw new Error('Missing required elementId query parameter.'); + } + + return await withConnection(port, async conn => { + const { client, context } = await resolveTarget(searchParams, conn); + const base64 = await conn.takeSnapshot(client.client_id, elementId, context.id); + if (!isValidSnapshotBase64(base64)) { + throw new Error('The target returned an invalid base64 element snapshot.'); + } + return { + image: `data:image/png;base64,${base64}`, + elementId, + port, + clientId: client.client_id, + contextId: context.id, + }; + }); +} + +async function inspectHeap(request: IncomingMessage, searchParams: URLSearchParams): Promise> { + if (request.method !== 'POST') { + throw new Error('Heap inspection requires POST.'); + } + + const body = await readJsonBody(request); + const port = readNumber(searchParams, 'port', STANDALONE_PORT); + const performGC = body['performGC'] === true; + return await withConnection(port, async conn => { + const { client } = await resolveTarget(searchParams, conn); + const heap = await conn.dumpHeap(client.client_id, performGC); + return { + port, + clientId: client.client_id, + heap, + }; + }); +} + +async function inspectProfileContexts(searchParams: URLSearchParams): Promise> { + const port = readNumber(searchParams, 'hermesPort', HERMES_PORT); + const contexts = await listHermesDevices(port); + return { + port, + contexts, + active: profileStatusPayload(), + }; +} + +async function startCpuProfile( + request: IncomingMessage, + searchParams: URLSearchParams, +): Promise> { + if (request.method !== 'POST') { + throw new Error('CPU profile start requires POST.'); + } + return await runProfileTransition(async () => { + assertNoActiveProfile(); + const body = await readJsonBody(request); + activeProfileSession = await createProfileSession(searchParams, body); + return profileStatusPayload(); + }); +} + +async function stopCpuProfile(request: IncomingMessage): Promise> { + if (request.method !== 'POST') { + throw new Error('CPU profile stop requires POST.'); + } + + return await runProfileTransition(stopActiveProfileSession); +} + +async function captureCpuProfile( + request: IncomingMessage, + searchParams: URLSearchParams, +): Promise> { + if (request.method !== 'POST') { + throw new Error('CPU profile capture requires POST.'); + } + return await runProfileTransition(async () => { + assertNoActiveProfile(); + const body = await readJsonBody(request); + const durationMs = clampNumber(readBodyNumber(body, 'durationMs', 5000), 100, 60_000); + activeProfileSession = await createProfileSession(searchParams, body); + await delay(durationMs); + return await stopActiveProfileSession(); + }); +} + +async function runProfileTransition(transition: () => Promise): Promise { + if (profileTransitionInProgress) { + throw new Error('Another CPU profile transition is already in progress.'); + } + + profileTransitionInProgress = true; + try { + return await transition(); + } finally { + profileTransitionInProgress = false; + } +} + +function assertNoActiveProfile(): void { + if (activeProfileSession) { + throw new Error(`CPU profiling is already active for context ${activeProfileSession.contextId}.`); + } +} + +async function createProfileSession( + searchParams: URLSearchParams, + body: Record, +): Promise { + const port = readBodyNumber(body, 'port', readNumber(searchParams, 'hermesPort', HERMES_PORT)); + const contexts = await listHermesDevices(port); + if (contexts.length === 0) { + throw new Error('No debuggable Hermes JS contexts found.'); + } + + const requestedContextId = readBodyString(body, 'contextId') ?? readBodyString(body, 'context'); + const firstContext = contexts[0]; + if (!firstContext) { + throw new Error('No debuggable Hermes JS contexts found.'); + } + const context = + requestedContextId === undefined ? firstContext : contexts.find(candidate => candidate.id === requestedContextId); + if (!context) { + throw new Error(`Hermes context ${String(requestedContextId)} was not found.`); + } + + const conn = await HermesConnection.connect(port, context.id); + try { + await conn.startProfiling(); + } catch (error) { + conn.close(); + throw error; + } + + return { + conn, + port, + contextId: context.id, + contextTitle: context.title, + startedAtMs: Date.now(), + startedAtEpochMs: Date.now(), + }; +} + +async function stopActiveProfileSession(): Promise> { + const session = activeProfileSession; + if (!session) { + throw new Error('No CPU profile recording is active.'); + } + + activeProfileSession = null; + try { + const profile = await session.conn.stopProfiling(); + return { + profiling: false, + port: session.port, + contextId: session.contextId, + contextTitle: session.contextTitle, + startedAtEpochMs: session.startedAtEpochMs, + elapsedMs: Date.now() - session.startedAtMs, + profile, + summary: summarizeCpuProfile(profile), + }; + } finally { + session.conn.close(); + } +} + +function profileStatusPayload(): Record { + if (!activeProfileSession) { + return { + profiling: false, + }; + } + + return { + profiling: true, + port: activeProfileSession.port, + contextId: activeProfileSession.contextId, + contextTitle: activeProfileSession.contextTitle, + startedAtEpochMs: activeProfileSession.startedAtEpochMs, + elapsedMs: Date.now() - activeProfileSession.startedAtMs, + }; +} + +function summarizeCpuProfile(profile: CpuProfile): Record { + const samples = profile.samples ?? []; + const sampleCountByNodeId = new Map(); + for (const sample of samples) { + sampleCountByNodeId.set(sample, (sampleCountByNodeId.get(sample) ?? 0) + 1); + } + + const topFunctions = profile.nodes + .map(node => ({ + name: node.callFrame.functionName || '(anonymous)', + url: node.callFrame.url, + lineNumber: node.callFrame.lineNumber, + sampleCount: sampleCountByNodeId.get(node.id) ?? node.hitCount ?? 0, + })) + .filter(node => node.sampleCount > 0) + .sort((left, right) => right.sampleCount - left.sampleCount) + .slice(0, 12); + + return { + nodeCount: profile.nodes.length, + sampleCount: samples.length, + durationMs: (profile.endTime - profile.startTime) / 1000, + topFunctions, + }; +} + +async function handleApi(request: IncomingMessage, response: ServerResponse, url: URL): Promise { + try { + if (url.pathname === '/api/dev-events') { + streamDevEvents(request, response); + return; + } + + if (url.pathname === '/api/debugger/events') { + streamDebuggerEvents(request, response); + return; + } + + if (url.pathname === '/api/debugger/state') { + sendJson(response, 200, debuggerStatePayload()); + return; + } + + if (url.pathname === '/api/debugger/actions') { + if (request.method !== 'POST') { + sendJson(response, 405, { error: 'Debugger actions require POST.' }); + return; + } + sendJson(response, 200, await handleDebuggerAction(request)); + return; + } + + if (url.pathname === '/api/runtime-logs/stream') { + await streamRuntimeLogs(request, response, url.searchParams); + return; + } + + if (url.pathname === '/api/status') { + sendJson(response, 200, await inspectStatus(url.searchParams)); + return; + } + + if (url.pathname === '/api/snapshot') { + sendJson(response, 200, await inspectSnapshot(url.searchParams)); + return; + } + + if (url.pathname === '/api/element-snapshot') { + sendJson(response, 200, await inspectElementSnapshot(url.searchParams)); + return; + } + + if (url.pathname === '/api/heap') { + sendJson(response, 200, await inspectHeap(request, url.searchParams)); + return; + } + + if (url.pathname === '/api/performance/profile/status') { + sendJson(response, 200, profileStatusPayload()); + return; + } + + if (url.pathname === '/api/performance/profile/contexts') { + sendJson(response, 200, await inspectProfileContexts(url.searchParams)); + return; + } + + if (url.pathname === '/api/performance/profile/start') { + sendJson(response, 200, await startCpuProfile(request, url.searchParams)); + return; + } + + if (url.pathname === '/api/performance/profile/stop') { + sendJson(response, 200, await stopCpuProfile(request)); + return; + } + + if (url.pathname === '/api/performance/profile/capture') { + sendJson(response, 200, await captureCpuProfile(request, url.searchParams)); + return; + } + + if (url.pathname === '/api/runtime-logs') { + sendJson(response, 200, await inspectRuntimeLogs(url.searchParams)); + return; + } + + sendJson(response, 404, { error: `Unknown API route ${url.pathname}` }); + } catch (error) { + sendJson(response, error instanceof ApiRequestError ? error.statusCode : 500, errorPayload(error)); + } +} + +async function serveStatic(_request: IncomingMessage, response: ServerResponse, url: URL): Promise { + let requestedPath: string; + try { + requestedPath = decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname); + } catch { + response.writeHead(400); + response.end('Invalid URL encoding'); + return; + } + + const resolvedPath = path.resolve(assetRoot, requestedPath.replace(/^\/+/, '')); + const relativePath = path.relative(assetRoot, resolvedPath); + if (relativePath === '..' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) { + response.writeHead(403); + response.end('Forbidden'); + return; + } + + try { + const data = await fs.readFile(resolvedPath); + const ext = path.extname(resolvedPath); + response.writeHead(200, { + 'Content-Type': MIME_TYPES[ext] ?? 'application/octet-stream', + 'Cache-Control': 'no-store', + 'Content-Security-Policy': + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + }); + response.end(data); + } catch { + response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end(`Not found: ${url.pathname}`); + } +} + +function createDebuggerHttpServer(host: string, port: number): Server { + return http.createServer(async (request, response) => { + const url = new URL(request.url || '/', `http://${hostForUrl(host)}:${port}`); + const apiFetchSiteAllowed = + !url.pathname.startsWith('/api/') || isAllowedApiFetchSite(request.headers['sec-fetch-site']); + if ( + !isAllowedRequestHost(request.headers.host, port) || + !isAllowedRequestOrigin(request.headers.origin, request.headers.host, port) || + !apiFetchSiteAllowed + ) { + response.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end('Forbidden'); + return; + } + + if (url.pathname.startsWith('/api/')) { + await handleApi(request, response, url); + return; + } + + await serveStatic(request, response, url); + }); +} + +function listen(server: Server, host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off('listening', onListening); + reject(error); + }; + const onListening = () => { + server.off('error', onError); + resolve(); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(port, host); + }); +} + +async function watchDebuggerAssets(root: string): Promise { + let fileNames: string[] = []; + try { + const entries = await fs.readdir(root, { withFileTypes: true }); + fileNames = entries + .filter(entry => entry.isFile() && /\.(?:html|css|js)$/.test(entry.name)) + .map(entry => entry.name) + .sort(); + } catch (error) { + console.warn(`Could not list debugger assets in ${root}: ${errorPayload(error).error}`); + } + + if (!fileNames.includes('index.html')) fileNames.push('index.html'); + + for (const fileName of fileNames) { + const watchedPath = path.join(root, fileName); + try { + const watcher = watchFileSystem(watchedPath, { persistent: false }, () => { + scheduleDevReload(fileName); + }); + assetWatchers.add(watcher); + watcher.once('close', () => assetWatchers.delete(watcher)); + } catch (error) { + console.warn(`Could not watch ${watchedPath}: ${errorPayload(error).error}`); + } + } +} + +function closeAssetWatchers(): void { + for (const watcher of Array.from(assetWatchers)) { + watcher.close(); + } + assetWatchers.clear(); +} + +async function closeHttpServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())); + }); +} + +async function closeDebuggerServer(server: Server): Promise { + for (const closeStream of Array.from(eventStreamClosers)) { + closeStream(); + } + closeAssetWatchers(); + if (devReloadTimer) { + clearTimeout(devReloadTimer); + devReloadTimer = null; + } + // Wait for in-flight profile setup/capture requests before inspecting the + // active session so shutdown cannot orphan a session created late. + await closeHttpServer(server); + if (activeProfileSession) { + try { + await stopActiveProfileSession(); + } catch (error) { + console.warn(`Could not stop the active Hermes profile during shutdown: ${errorPayload(error).error}`); + } + } +} + +export async function startDebuggerServer(options: DebuggerServerOptions): Promise { + activeHost = options.host ?? DEFAULT_HOST; + if (!isLoopbackHost(activeHost)) { + throw new Error(`The debugger server only binds to loopback hosts; received '${activeHost}'.`); + } + + assetRoot = options.assetRoot ?? getDefaultAssetRoot(); + activeLogsDirectory = options.logsDirectory ?? null; + const preferredPort = options.port ?? DEFAULT_PORT; + if (!Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65_535) { + throw new Error(`Debugger port must be an integer between 1 and 65535; received '${String(preferredPort)}'.`); + } + const strictPort = Boolean(options.strictPort); + const maxAttempts = strictPort ? 1 : PORT_SEARCH_LIMIT; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const port = preferredPort + attempt; + const server = createDebuggerHttpServer(activeHost, port); + try { + await listen(server, activeHost, port); + closeAssetWatchers(); + await watchDebuggerAssets(assetRoot); + let closePromise: Promise | undefined; + return { + server, + close: () => { + closePromise ??= closeDebuggerServer(server); + return closePromise; + }, + host: activeHost, + port, + url: `http://${hostForUrl(activeHost)}:${port}/`, + requestedPort: preferredPort, + portWasAutoSelected: port !== preferredPort, + }; + } catch (error) { + server.close(); + const err = error as NodeJS.ErrnoException; + if (strictPort || err.code !== 'EADDRINUSE') { + throw error; + } + } + } + + throw new Error(`No available port found in ${preferredPort}-${preferredPort + maxAttempts - 1}.`); +} diff --git a/npm_modules/cli/src/utils/daemonClient.spec.ts b/npm_modules/cli/src/utils/daemonClient.spec.ts new file mode 100644 index 000000000..47d69742b --- /dev/null +++ b/npm_modules/cli/src/utils/daemonClient.spec.ts @@ -0,0 +1,63 @@ +import 'jasmine'; +import { EventEmitter } from 'node:events'; +import type { Socket } from 'node:net'; +import { DaemonConnection } from './daemonClient'; + +const TEST_MAGIC = Buffer.from([0x33, 0xc6, 0x00, 0x01]); + +function encodeTestPacket(payload: object): Buffer { + const body = Buffer.from(JSON.stringify(payload), 'utf8'); + const header = Buffer.alloc(8); + TEST_MAGIC.copy(header, 0); + header.writeUInt32LE(body.length, 4); + return Buffer.concat([header, body]); +} + +// net.Socket uses EventEmitter semantics, which this protocol test mirrors. +// eslint-disable-next-line unicorn/prefer-event-target +class ErrorResponseSocket extends EventEmitter { + readonly remotePort = 13_591; + + write(data: Buffer, callback?: (error?: Error) => void): boolean { + const packet = JSON.parse(data.subarray(8).toString('utf8')) as Record; + const event = packet['event'] as Record | undefined; + const payloadFromClient = event?.['payload_from_client'] as Record | undefined; + if (payloadFromClient) { + const request = JSON.parse(String(payloadFromClient['payload_string'])) as Record; + const errorResponse = { + request: { + forward_client_payload: { + client_id: 1, + payload_string: JSON.stringify({ + type: -1, + requestId: request['requestId'], + body: { message: 'Heap dump failed.' }, + }), + }, + request_id: 'device-response-1', + }, + }; + queueMicrotask(() => this.emit('data', encodeTestPacket(errorResponse))); + } + callback?.(); + return true; + } + + destroy(): this { + this.emit('close'); + return this; + } +} + +describe('DaemonConnection', () => { + it('surfaces runtime error responses from debugger requests', async () => { + const socket = new ErrorResponseSocket(); + const connection = new DaemonConnection(socket as unknown as Socket); + + try { + await expectAsync(connection.dumpHeap('1', false)).toBeRejectedWithError('Heap dump failed.'); + } finally { + connection.close(); + } + }); +}); diff --git a/npm_modules/cli/src/utils/daemonClient.ts b/npm_modules/cli/src/utils/daemonClient.ts index a5bacfc07..c31c5636a 100644 --- a/npm_modules/cli/src/utils/daemonClient.ts +++ b/npm_modules/cli/src/utils/daemonClient.ts @@ -42,6 +42,7 @@ export const DEFAULT_PORT = MOBILE_PORT; // ─── Messages.ts protocol (inlined so CLI stays self-contained in open_source) ─ export const enum DaemonMsgType { + ERROR_RESPONSE = -1, LIST_CONTEXTS_REQUEST = 2, LIST_CONTEXTS_RESPONSE = -2, GET_CONTEXT_TREE_REQUEST = 3, @@ -102,6 +103,9 @@ function encodePacket(json: object): Buffer { // In the direct-to-device protocol we are "client 1" (non-zero required by device JS check). const DIRECT_CLIENT_ID = 1; +const ADB_FORWARD_REFRESH_INTERVAL_MS = 5000; +const adbForwardedAtByPort = new Map(); +const adbForwardRefreshByPort = new Map>(); interface PendingRequest { resolve: (value: Record) => void; @@ -126,7 +130,7 @@ export class DaemonConnection { this.socket = socket; socket.on('data', (data: Buffer) => this.onData(data)); socket.on('close', () => this.rejectAllPending(new Error('Connection closed unexpectedly'))); - socket.on('error', (err) => this.rejectAllPending(err)); + socket.on('error', err => this.rejectAllPending(err)); } private rejectAllPending(err: Error): void { @@ -187,7 +191,7 @@ export class DaemonConnection { // routed back via forward_client_payload). Auto-respond to all of them. const req = msg['request'] as Record; const reqId = req['request_id'] as string; - const respKey = Object.keys(req).find((k) => k !== 'request_id'); + const respKey = Object.keys(req).find(k => k !== 'request_id'); if (respKey) { this.socket.write(encodePacket({ response: { [respKey]: {}, request_id: reqId } })); if (respKey === 'configure') { @@ -232,25 +236,30 @@ export class DaemonConnection { // Note: events from the device (js_debugger_info, new_logs, etc.) are ignored. } - private sendRequest( - payload: Record, - timeoutMs = 5_000, - ): Promise> { + private sendRequest(payload: Record, timeoutMs = 5_000): Promise> { const reqId = String(++this.reqCounter); const envelope = { request: { ...payload, request_id: reqId } }; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingRequests.delete(reqId); - reject(new CliError( - `Valdi daemon did not respond (port ${(this.socket.remotePort ?? '?')}).\n` + - `Is the hot-reloader actually running and connected?`, - )); + reject( + new CliError( + `Valdi daemon did not respond (port ${this.socket.remotePort ?? '?'}).\n` + + `Is the hot-reloader actually running and connected?`, + ), + ); }, timeoutMs); this.pendingRequests.set(reqId, { - resolve: (v) => { clearTimeout(timer); resolve(v); }, - reject: (e) => { clearTimeout(timer); reject(e); }, + resolve: v => { + clearTimeout(timer); + resolve(v); + }, + reject: e => { + clearTimeout(timer); + reject(e); + }, }); - this.socket.write(encodePacket(envelope), (err) => { + this.socket.write(encodePacket(envelope), err => { if (err) { clearTimeout(timer); this.pendingRequests.delete(reqId); @@ -275,14 +284,22 @@ export class DaemonConnection { return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.configureReady = null; - reject(new CliError( - `Valdi daemon did not respond (port ${this.socket.remotePort ?? '?'}).\n` + - `Is the hot-reloader actually running and connected?`, - )); + reject( + new CliError( + `Valdi daemon did not respond (port ${this.socket.remotePort ?? '?'}).\n` + + `Is the hot-reloader actually running and connected?`, + ), + ); }, 5_000); this.configureReady = { - resolve: () => { clearTimeout(timer); resolve(); }, - reject: (e) => { clearTimeout(timer); reject(e); }, + resolve: () => { + clearTimeout(timer); + resolve(); + }, + reject: e => { + clearTimeout(timer); + reject(e); + }, }; }); } @@ -318,31 +335,49 @@ export class DaemonConnection { reject(new Error('Timeout waiting for device response. Is the app running?')); }, timeoutMs); this.payloadListeners.set(msgId, { - resolve: (v) => { clearTimeout(timer); resolve(v); }, - reject: (e) => { clearTimeout(timer); reject(e); }, + resolve: v => { + clearTimeout(timer); + resolve(v); + }, + reject: e => { + clearTimeout(timer); + reject(e); + }, }); }); // Send as an event — the device processes payload_from_client events directly. // It routes responses back as forward_client_payload requests (handled in dispatchMessage). - this.socket.write(encodePacket({ - event: { - payload_from_client: { - sender_client_id: DIRECT_CLIENT_ID, - payload_string: payloadString, + this.socket.write( + encodePacket({ + event: { + payload_from_client: { + sender_client_id: DIRECT_CLIENT_ID, + payload_string: payloadString, + }, }, - }, - }), (err) => { - if (err) { - const listener = this.payloadListeners.get(msgId); - if (listener) { - this.payloadListeners.delete(msgId); - listener.reject(err); + }), + err => { + if (err) { + const listener = this.payloadListeners.get(msgId); + if (listener) { + this.payloadListeners.delete(msgId); + listener.reject(err); + } } - } - }); + }, + ); - return resultPromise; + const response = await resultPromise; + if (response['type'] === DaemonMsgType.ERROR_RESPONSE) { + const body = response['body']; + const message = + body !== null && typeof body === 'object' && !Array.isArray(body) + ? (body as Record)['message'] + : undefined; + throw new Error(message === undefined ? 'The Valdi runtime rejected the debugger request.' : String(message)); + } + return response; } async listContexts(clientId: string): Promise { @@ -350,8 +385,11 @@ export class DaemonConnection { return (resp['body'] ?? []) as RemoteContext[]; } - async getContextTree(clientId: string, contextId: string): Promise { - const resp = await this.forwardAndWait(clientId, DaemonMsgType.GET_CONTEXT_TREE_REQUEST, { id: contextId }); + async getContextTree(clientId: string, contextId: string, includeComponentData: boolean): Promise { + const resp = await this.forwardAndWait(clientId, DaemonMsgType.GET_CONTEXT_TREE_REQUEST, { + id: contextId, + includeComponentData, + }); return resp['body']; } @@ -376,10 +414,30 @@ export class DaemonConnection { // ─── Factory ───────────────────────────────────────────────────────────────── async function tryAdbForward(port: number): Promise { + const lastForwardedAt = adbForwardedAtByPort.get(port); + if (lastForwardedAt !== undefined && Date.now() - lastForwardedAt < ADB_FORWARD_REFRESH_INTERVAL_MS) return; + + const pendingRefresh = adbForwardRefreshByPort.get(port); + if (pendingRefresh) { + await pendingRefresh; + return; + } + + const refresh = refreshAdbForward(port); + adbForwardRefreshByPort.set(port, refresh); + try { + await refresh; + } finally { + if (adbForwardRefreshByPort.get(port) === refresh) adbForwardRefreshByPort.delete(port); + } +} + +async function refreshAdbForward(port: number): Promise { try { await runCliCommand(`adb forward tcp:${port} tcp:${port}`); + adbForwardedAtByPort.set(port, Date.now()); } catch { - // adb may not be installed or no Android devices attached — non-fatal + // ADB is optional for standalone targets; retry the next time a mobile connection is requested. } } @@ -405,10 +463,12 @@ export async function connectToDaemon(port: number = DEFAULT_PORT): Promise { clearTimeout(timer); if (err.code === 'ECONNREFUSED') { - reject(new CliError( - `Valdi daemon not running on port ${port}.\n` + - `Start the hot-reloader (valdi hotreload) or pass --port to use a different port.`, - )); + reject( + new CliError( + `Valdi daemon not running on port ${port}.\n` + + `Start the hot-reloader (valdi hotreload) or pass --port to use a different port.`, + ), + ); } else { reject(err); } @@ -434,7 +494,7 @@ export async function resolveContextId( } if (contextIdOverride) { - if (!contexts.some((c) => c.id === contextIdOverride)) { + if (!contexts.some(c => c.id === contextIdOverride)) { throw new CliError( `Context "${contextIdOverride}" not found. Run "valdi inspect contexts" to see available contexts.`, ); @@ -448,7 +508,7 @@ export async function resolveContextId( // Multiple contexts — prompt return getUserChoice( - contexts.map((c) => ({ + contexts.map(c => ({ name: `${c.rootComponentName} [${c.id}]`, value: c.id, })), @@ -460,21 +520,18 @@ export async function resolveContextId( * Resolve which connected client to target. * Priority: explicit --client flag → saved config → auto (single device) → prompt. */ -export async function resolveClientId( - conn: DaemonConnection, - clientIdOverride?: string, -): Promise { +export async function resolveClientId(conn: DaemonConnection, clientIdOverride?: string): Promise { const clients = await conn.listConnectedClients(); if (clients.length === 0) { throw new CliError( 'No devices connected to the Valdi daemon.\n' + - 'Make sure the Valdi app is running and connected to the hot-reloader.', + 'Make sure the Valdi app is running and connected to the hot-reloader.', ); } if (clientIdOverride) { - if (!clients.some((c) => c.client_id === clientIdOverride)) { + if (!clients.some(c => c.client_id === clientIdOverride)) { throw new CliError( `Client "${clientIdOverride}" not found. Run "valdi inspect devices" to see connected clients.`, ); @@ -483,7 +540,7 @@ export async function resolveClientId( } const config = loadInspectConfig(); - if (config.selectedClientId && clients.some((c) => c.client_id === config.selectedClientId)) { + if (config.selectedClientId && clients.some(c => c.client_id === config.selectedClientId)) { return config.selectedClientId; } @@ -493,7 +550,7 @@ export async function resolveClientId( // Multiple devices — prompt return getUserChoice( - clients.map((c) => ({ + clients.map(c => ({ name: `${c.application_id} (${c.platform}) [${c.client_id}]`, value: c.client_id, })), diff --git a/src/valdi_modules/src/valdi/valdi_core/src/IRenderedVirtualNodeData.ts b/src/valdi_modules/src/valdi/valdi_core/src/IRenderedVirtualNodeData.ts index 99d3a3ab2..7e3e887cf 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/IRenderedVirtualNodeData.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/IRenderedVirtualNodeData.ts @@ -1,16 +1,351 @@ import { StringMap } from 'coreutils/src/StringMap'; import { ElementFrame } from 'valdi_tsx/src/Geometry'; +import { IComponent } from './IComponent'; import { IRenderedVirtualNode } from './IRenderedVirtualNode'; import { getNodeTag } from './utils/RenderedVirtualNodeUtils'; import { debugStringify } from './utils/StringUtils'; +const MAX_COMPONENT_DEBUG_DATA_CHARACTERS_PER_FIELD = 65_536; +const MAX_COMPONENT_DEBUG_DATA_CHARACTERS_PER_TREE = 262_144; +const COMPONENT_DEBUG_DATA_TRUNCATION_MARKER = '\n... '; + export interface IRenderedElementData { readonly id: number; readonly frame: ElementFrame; readonly attributes?: StringMap; } -export interface IRenderedComponentData {} +export interface IRenderedComponentData { + /** + * Debug-stringified component input. + */ + readonly viewModel?: string; + + /** + * Debug-stringified StatefulComponent state when present. + */ + readonly state?: string; + + /** + * Whether one or more component debug values were omitted by the snapshot + * character budget. + */ + readonly debugDataOmitted?: boolean; +} + +interface IDebuggableComponent extends IComponent { + readonly state?: unknown; +} + +interface ComponentDebugDataBudget { + remainingCharacters: number; +} + +interface ComponentDebugString { + value: string | undefined; + omitted: boolean; +} + +interface BoundedDebugWriter { + value: string; + readonly characterLimit: number; + truncated: boolean; +} + +interface BoundedComponentDebugKeys { + readonly keys: string[]; + readonly omitted: boolean; +} + +const ACCESSOR_DEBUG_VALUE = {}; +const MAX_COMPONENT_DEBUG_DEPTH = 4; +const MAX_COMPONENT_DEBUG_COLLECTION_ITEMS = 50; +const MAP_SIZE_GETTER = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.get; +const SET_SIZE_GETTER = Object.getOwnPropertyDescriptor(Set.prototype, 'size')?.get; + +export interface RenderedVirtualNodeDataOptions { + readonly includeAttributes: boolean; + readonly includeComponentData: boolean; + readonly onCreate: ((node: IRenderedVirtualNode, data: IRenderedVirtualNodeData) => void) | undefined; +} + +function appendComponentDebugText(writer: BoundedDebugWriter, text: string): void { + if (writer.truncated || text.length === 0) { + return; + } + + if (writer.value.length + text.length <= writer.characterLimit) { + writer.value += text; + return; + } + + const marker = COMPONENT_DEBUG_DATA_TRUNCATION_MARKER.slice(0, writer.characterLimit); + const prefixLength = Math.max(0, writer.characterLimit - marker.length); + if (writer.value.length > prefixLength) { + writer.value = writer.value.slice(0, prefixLength); + } else if (writer.value.length < prefixLength) { + writer.value += text.slice(0, prefixLength - writer.value.length); + } + writer.value += marker; + writer.truncated = true; +} + +function componentDebugIndent(depth: number): string { + return `\n${' '.repeat(depth)}`; +} + +function readComponentDebugProperty(object: object, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(object, key); + if (!descriptor) { + return undefined; + } + if (descriptor.get || descriptor.set) { + return ACCESSOR_DEBUG_VALUE; + } + return descriptor.value; +} + +function collectBoundedComponentDebugKeys(object: object): BoundedComponentDebugKeys { + const keys: string[] = []; + for (const key in object) { + const descriptor = Object.getOwnPropertyDescriptor(object, key); + if (!descriptor) break; + if (!descriptor.enumerable) continue; + if (keys.length >= MAX_COMPONENT_DEBUG_COLLECTION_ITEMS) return { keys, omitted: true }; + keys.push(key); + } + return { keys, omitted: false }; +} + +function readComponentDebugMapSize(map: Map): number { + return MAP_SIZE_GETTER ? Number(MAP_SIZE_GETTER.call(map)) : 0; +} + +function readComponentDebugSetSize(set: Set): number { + return SET_SIZE_GETTER ? Number(SET_SIZE_GETTER.call(set)) : 0; +} + +function writeComponentDebugArray( + array: unknown[], + writer: BoundedDebugWriter, + visited: Set, + depth: number, +): void { + const lengthDescriptor = Object.getOwnPropertyDescriptor(array, 'length'); + const length = typeof lengthDescriptor?.value === 'number' ? lengthDescriptor.value : 0; + if (length === 0) { + appendComponentDebugText(writer, '[]'); + return; + } + + appendComponentDebugText(writer, `[${componentDebugIndent(depth + 1)}`); + const itemCount = Math.min(length, MAX_COMPONENT_DEBUG_COLLECTION_ITEMS); + for (let index = 0; index < itemCount && !writer.truncated; index++) { + if (index > 0) { + appendComponentDebugText(writer, `,${componentDebugIndent(depth + 1)}`); + } + const item = readComponentDebugProperty(array, String(index)); + writeComponentDebugValue(item, writer, visited, depth + 1); + } + if (length > itemCount && !writer.truncated) { + appendComponentDebugText(writer, `,${componentDebugIndent(depth + 1)}... ${length - itemCount} more item(s) ...`); + } + appendComponentDebugText(writer, `${componentDebugIndent(depth)}]`); +} + +function writeComponentDebugMap( + map: Map, + writer: BoundedDebugWriter, + visited: Set, + depth: number, +): void { + appendComponentDebugText(writer, 'Map{'); + const size = readComponentDebugMapSize(map); + let itemCount = 0; + for (const [key, value] of Map.prototype.entries.call(map) as IterableIterator<[unknown, unknown]>) { + if (itemCount >= MAX_COMPONENT_DEBUG_COLLECTION_ITEMS || writer.truncated) { + break; + } + appendComponentDebugText(writer, componentDebugIndent(depth + 1)); + writeComponentDebugValue(key, writer, visited, depth + 1); + appendComponentDebugText(writer, ': '); + writeComponentDebugValue(value, writer, visited, depth + 1); + itemCount += 1; + if (itemCount < size) { + appendComponentDebugText(writer, ','); + } + } + if (size > itemCount && !writer.truncated) { + appendComponentDebugText(writer, `${componentDebugIndent(depth + 1)}... ${size - itemCount} more item(s) ...`); + } + if (size > 0) { + appendComponentDebugText(writer, componentDebugIndent(depth)); + } + appendComponentDebugText(writer, '}'); +} + +function writeComponentDebugSet( + set: Set, + writer: BoundedDebugWriter, + visited: Set, + depth: number, +): void { + appendComponentDebugText(writer, 'Set('); + const size = readComponentDebugSetSize(set); + let itemCount = 0; + for (const value of Set.prototype.values.call(set) as IterableIterator) { + if (itemCount >= MAX_COMPONENT_DEBUG_COLLECTION_ITEMS || writer.truncated) { + break; + } + appendComponentDebugText(writer, componentDebugIndent(depth + 1)); + writeComponentDebugValue(value, writer, visited, depth + 1); + itemCount += 1; + if (itemCount < size) { + appendComponentDebugText(writer, ','); + } + } + if (size > itemCount && !writer.truncated) { + appendComponentDebugText(writer, `${componentDebugIndent(depth + 1)}... ${size - itemCount} more item(s) ...`); + } + if (size > 0) { + appendComponentDebugText(writer, componentDebugIndent(depth)); + } + appendComponentDebugText(writer, ')'); +} + +function writeComponentDebugObject( + object: object, + writer: BoundedDebugWriter, + visited: Set, + depth: number, +): void { + const boundedKeys = collectBoundedComponentDebugKeys(object); + if (boundedKeys.keys.length === 0 && !boundedKeys.omitted) { + appendComponentDebugText(writer, '{}'); + return; + } + + appendComponentDebugText(writer, `{${componentDebugIndent(depth + 1)}`); + for (let index = 0; index < boundedKeys.keys.length && !writer.truncated; index++) { + const key = boundedKeys.keys[index]; + if (key === undefined) { + continue; + } + if (index > 0) { + appendComponentDebugText(writer, `,${componentDebugIndent(depth + 1)}`); + } + appendComponentDebugText(writer, `${key}: `); + const propertyValue = readComponentDebugProperty(object, key); + writeComponentDebugValue(propertyValue, writer, visited, depth + 1); + } + if (boundedKeys.omitted && !writer.truncated) { + const prefix = boundedKeys.keys.length > 0 ? ',' : ''; + appendComponentDebugText(writer, `${prefix}${componentDebugIndent(depth + 1)}... more properties ...`); + } + appendComponentDebugText(writer, `${componentDebugIndent(depth)}}`); +} + +function writeComponentDebugValue( + value: unknown, + writer: BoundedDebugWriter, + visited: Set, + depth: number, +): void { + if (writer.truncated) { + return; + } + if (depth >= MAX_COMPONENT_DEBUG_DEPTH) { + appendComponentDebugText(writer, '...'); + return; + } + if (value === ACCESSOR_DEBUG_VALUE) { + appendComponentDebugText(writer, ''); + return; + } + if (value === undefined) { + appendComponentDebugText(writer, 'undefined'); + return; + } + if (value === null) { + appendComponentDebugText(writer, 'null'); + return; + } + + const valueType = typeof value; + if (valueType === 'string') { + if (depth > 0) { + appendComponentDebugText(writer, '"'); + } + appendComponentDebugText(writer, value as string); + if (depth > 0) { + appendComponentDebugText(writer, '"'); + } + return; + } + if (valueType === 'number' || valueType === 'boolean' || valueType === 'bigint' || valueType === 'symbol') { + appendComponentDebugText(writer, String(value)); + return; + } + if (valueType === 'function') { + let functionName = ''; + try { + functionName = String((value as { readonly name?: unknown }).name ?? ''); + } catch { + // Proxied functions may reject property reads. The function is still represented without invoking it. + } + appendComponentDebugText(writer, ``); + return; + } + + const object = value as object; + if (visited.has(object)) { + appendComponentDebugText(writer, ''); + return; + } + visited.add(object); + + try { + if (Array.isArray(object)) { + writeComponentDebugArray(object, writer, visited, depth); + } else if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(object)) { + appendComponentDebugText(writer, ''); + } else if (object instanceof Map) { + writeComponentDebugMap(object, writer, visited, depth); + } else if (object instanceof Set) { + writeComponentDebugSet(object, writer, visited, depth); + } else if (object instanceof Date) { + appendComponentDebugText(writer, ``); + } else if (object instanceof Error) { + const message = readComponentDebugProperty(object, 'message'); + appendComponentDebugText(writer, ''); + } else { + writeComponentDebugObject(object, writer, visited, depth); + } + } catch { + appendComponentDebugText(writer, ''); + } +} + +function stringifyComponentDebugData(value: unknown, budget: ComponentDebugDataBudget): ComponentDebugString { + const availableCharacters = Math.min(MAX_COMPONENT_DEBUG_DATA_CHARACTERS_PER_FIELD, budget.remainingCharacters); + if (availableCharacters <= 0) { + return { value: undefined, omitted: true }; + } + + const writer: BoundedDebugWriter = { + value: '', + characterLimit: availableCharacters, + truncated: false, + }; + writeComponentDebugValue(value, writer, new Set(), 0); + budget.remainingCharacters -= writer.value.length; + return { value: writer.value, omitted: writer.truncated }; +} export interface IRenderedVirtualNodeData { /** @@ -55,24 +390,68 @@ export function fromRenderedVirtualNode( node: IRenderedVirtualNode, includeAttributes: boolean, onCreate?: (node: IRenderedVirtualNode, data: IRenderedVirtualNodeData) => void, +): IRenderedVirtualNodeData { + return fromRenderedVirtualNodeWithOptions(node, { + includeAttributes, + includeComponentData: false, + onCreate, + }); +} + +export function fromRenderedVirtualNodeWithOptions( + node: IRenderedVirtualNode, + options: RenderedVirtualNodeDataOptions, +): IRenderedVirtualNodeData { + const componentDebugDataBudget: ComponentDebugDataBudget = { + remainingCharacters: MAX_COMPONENT_DEBUG_DATA_CHARACTERS_PER_TREE, + }; + return fromRenderedVirtualNodeWithBudget(node, options, componentDebugDataBudget); +} + +function fromRenderedVirtualNodeWithBudget( + node: IRenderedVirtualNode, + options: RenderedVirtualNodeDataOptions, + componentDebugDataBudget: ComponentDebugDataBudget, ): IRenderedVirtualNodeData { let children: IRenderedVirtualNodeData[] | undefined; + let element: IRenderedElementData | undefined; + let component: IRenderedComponentData | undefined; + + if (node.component) { + if (options.includeComponentData) { + const debuggableComponent = node.component as IDebuggableComponent; + const componentViewModel = readComponentDebugProperty(debuggableComponent, 'viewModel'); + const componentState = readComponentDebugProperty(debuggableComponent, 'state'); + const viewModel = stringifyComponentDebugData(componentViewModel, componentDebugDataBudget); + const state = + componentState === undefined + ? undefined + : stringifyComponentDebugData(componentState, componentDebugDataBudget); + const debugDataOmitted = viewModel.omitted || state?.omitted === true; + component = { + ...(viewModel.value === undefined ? {} : { viewModel: viewModel.value }), + ...(state?.value === undefined ? {} : { state: state.value }), + ...(debugDataOmitted ? { debugDataOmitted: true } : {}), + }; + } else { + component = {}; + } + } if (node.children.length) { children = []; for (const child of node.children) { - children.push(fromRenderedVirtualNode(child, includeAttributes, onCreate)); + children.push(fromRenderedVirtualNodeWithBudget(child, options, componentDebugDataBudget)); } } - let element: IRenderedElementData | undefined; - let component: IRenderedComponentData | undefined; - + // Preserve the existing post-order attribute reads while reserving the + // component debug-data budget from the root downward. if (node.element) { const renderedElement = node.element; let attributes: StringMap | undefined; - if (includeAttributes) { + if (options.includeAttributes) { attributes = {}; for (const attributeName of renderedElement.getAttributeNames()) { const attributeValue = renderedElement.getAttribute(attributeName); @@ -87,10 +466,6 @@ export function fromRenderedVirtualNode( }; } - if (node.component) { - component = {}; - } - const tag = getNodeTag(node); const data = { @@ -101,8 +476,8 @@ export function fromRenderedVirtualNode( children, }; - if (onCreate) { - onCreate(node, data); + if (options.onCreate) { + options.onCreate(node, data); } return data; diff --git a/src/valdi_modules/src/valdi/valdi_core/src/RootComponentsManager.ts b/src/valdi_modules/src/valdi/valdi_core/src/RootComponentsManager.ts index becdad0bf..e4a3da2ea 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/RootComponentsManager.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/RootComponentsManager.ts @@ -8,7 +8,7 @@ import { EntryPointRenderFunction } from './EntryPointRenderFunction'; import { IComponent } from './IComponent'; import { IEntryPointComponent } from './IEntryPointComponent'; import { RequireFunc } from './IModuleLoader'; -import { fromRenderedVirtualNode } from './IRenderedVirtualNodeData'; +import { fromRenderedVirtualNodeWithOptions } from './IRenderedVirtualNodeData'; import { IRootComponentsManager } from './IRootComponentsManager'; import { getModuleLoader } from './ModuleLoaderGlobal'; import { withLocalNativeRefs } from './NativeReferences'; @@ -366,7 +366,11 @@ export class RootComponentsManager implements IRootComponentsManager, IDaemonCli return; } - const rootNodeData = fromRenderedVirtualNode(rootNode, true); + const rootNodeData = fromRenderedVirtualNodeWithOptions(rootNode, { + includeAttributes: true, + includeComponentData: message.message.body.includeComponentData === true, + onCreate: undefined, + }); message.respond(requestId => Messages.getContextTreeResponse(requestId, rootNodeData)); } else if (message.message.type === DaemonClientMessageType.TAKE_ELEMENT_SNAPSHOT_REQUEST) { const contextId = message.message.body.contextId; diff --git a/src/valdi_modules/src/valdi/valdi_core/src/debugging/Messages.ts b/src/valdi_modules/src/valdi/valdi_core/src/debugging/Messages.ts index 0e1c6479a..0c92977a1 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/debugging/Messages.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/debugging/Messages.ts @@ -33,6 +33,7 @@ export interface RemoteValdiContext { export interface GetContextTreeBody { id: string; + includeComponentData?: boolean; } export interface TakeElementSnapshotBody { diff --git a/src/valdi_modules/src/valdi/valdi_core/test/IRenderedVirtualNodeData.spec.ts b/src/valdi_modules/src/valdi/valdi_core/test/IRenderedVirtualNodeData.spec.ts new file mode 100644 index 000000000..6419fdbae --- /dev/null +++ b/src/valdi_modules/src/valdi/valdi_core/test/IRenderedVirtualNodeData.spec.ts @@ -0,0 +1,246 @@ +import 'jasmine/src/jasmine'; +import { IComponent } from '../src/IComponent'; +import { IRenderedElement } from '../src/IRenderedElement'; +import { IRenderedVirtualNode } from '../src/IRenderedVirtualNode'; +import { + fromRenderedVirtualNode, + fromRenderedVirtualNodeWithOptions, + IRenderedVirtualNodeData, +} from '../src/IRenderedVirtualNodeData'; +import { Messages } from '../src/debugging/Messages'; + +interface IDebuggableTestComponent extends IComponent { + readonly state?: unknown; +} + +function createComponentNode(viewModel: unknown, state: unknown): IRenderedVirtualNode { + const component = (state === undefined ? { viewModel } : { viewModel, state }) as unknown as IDebuggableTestComponent; + + return { + key: 'component', + uniqueId: 'component', + parent: undefined, + element: undefined, + component, + children: [], + parentIndex: 0, + renderer: component.renderer, + }; +} + +function createDetailedData(node: IRenderedVirtualNode): IRenderedVirtualNodeData { + return fromRenderedVirtualNodeWithOptions(node, { + includeAttributes: true, + includeComponentData: true, + onCreate: undefined, + }); +} + +function collectComponentDebugDataLength(node: IRenderedVirtualNodeData): number { + let length = (node.component?.viewModel?.length ?? 0) + (node.component?.state?.length ?? 0); + for (const child of node.children ?? []) { + length += collectComponentDebugDataLength(child); + } + return length; +} + +function hasOmittedComponentDebugData(node: IRenderedVirtualNodeData): boolean { + return node.component?.debugDataOmitted === true || (node.children ?? []).some(hasOmittedComponentDebugData); +} + +function createElementNode(): IRenderedVirtualNode { + const element = { + id: 42, + tag: 'label', + frame: { x: 1, y: 2, width: 30, height: 40 }, + getAttributeNames: () => ['value'], + getAttribute: () => 'Hello', + } as unknown as IRenderedElement; + + return { + key: 'element', + uniqueId: 'element', + parent: undefined, + element, + component: undefined, + children: [], + parentIndex: 0, + renderer: element.renderer, + }; +} + +describe('IRenderedVirtualNodeData', () => { + it('exports component ViewModel and state for detailed debugger snapshots', () => { + const data = createDetailedData(createComponentNode({ title: 'Debugger' }, { selectedNodeId: 42 })); + + expect(data.component?.viewModel).toContain('title: "Debugger"'); + expect(data.component?.state).toContain('selectedNodeId: 42'); + }); + + it('omits state when a component has no state', () => { + const data = createDetailedData(createComponentNode({ title: 'Stateless' }, undefined)); + + expect(data.component?.viewModel).toContain('title: "Stateless"'); + expect(data.component?.state).toBeUndefined(); + }); + + it('serializes circular and callable debug values safely', () => { + function callback(): void {} + + const viewModel: Record = { callback }; + viewModel['self'] = viewModel; + + const data = createDetailedData(createComponentNode(viewModel, {})); + + expect(data.component?.viewModel).toContain(''); + expect(data.component?.viewModel).toContain(''); + }); + + it('does not invoke component accessors or custom debug representations', () => { + let invocationCount = 0; + const viewModel = { + get secret(): string { + invocationCount += 1; + return 'secret'; + }, + toConsoleRepresentation(): string { + invocationCount += 1; + return 'custom'; + }, + toString(): string { + invocationCount += 1; + return 'custom'; + }, + }; + + const data = createDetailedData(createComponentNode(viewModel, {})); + + expect(invocationCount).toBe(0); + expect(data.component?.viewModel).toContain('secret: '); + expect(data.component?.viewModel).toContain('toConsoleRepresentation: '); + expect(data.component?.viewModel).toContain('toString: '); + }); + + it('does not invoke accessors on the component itself', () => { + let invocationCount = 0; + const node = createComponentNode({}, {}); + const component = node.component as object; + Object.defineProperties(component, { + viewModel: { + enumerable: true, + get: () => { + invocationCount += 1; + return { secret: 'view-model' }; + }, + }, + state: { + enumerable: true, + get: () => { + invocationCount += 1; + return { secret: 'state' }; + }, + }, + }); + + const data = createDetailedData(node); + + expect(invocationCount).toBe(0); + expect(data.component?.viewModel).toBe(''); + expect(data.component?.state).toBe(''); + }); + + it('summarizes array buffer views without enumerating their indexes', () => { + const data = createDetailedData(createComponentNode({ bytes: new Uint8Array(1_000_000) }, {})); + + expect(data.component?.viewModel).toContain('bytes: '); + expect(data.component?.viewModel?.length).toBeLessThan(100); + }); + + it('does not count inherited enumerable keys against the object limit', () => { + const prototype: Record = {}; + for (let index = 0; index < 100; index++) { + prototype[`inherited${index}`] = index; + } + const viewModel = Object.create(prototype) as Record; + viewModel['own'] = 'value'; + + const data = createDetailedData(createComponentNode(viewModel, {})); + + expect(data.component?.viewModel).toContain('own: "value"'); + expect(data.component?.viewModel).not.toContain('more properties'); + }); + + it('does not invoke overridden Map or Set size getters', () => { + let invocationCount = 0; + const map = new Map([['key', 'value']]); + const set = new Set(['value']); + for (const collection of [map, set]) { + Object.defineProperty(collection, 'size', { + get: () => { + invocationCount += 1; + return 1; + }, + }); + } + + const data = createDetailedData(createComponentNode({ map, set }, {})); + + expect(invocationCount).toBe(0); + expect(data.component?.viewModel).toContain('Map{'); + expect(data.component?.viewModel).toContain('Set('); + }); + + it('truncates one serialized component field to its character cap', () => { + const data = createDetailedData(createComponentNode('x'.repeat(70_000), {})); + + expect(data.component?.viewModel?.endsWith('\n... ')).toBeTrue(); + expect(data.component?.viewModel?.length).toBe(65_536); + expect(data.component?.debugDataOmitted).toBeTrue(); + }); + + it('does not expose component values through the compatibility serializer', () => { + const data = fromRenderedVirtualNode( + createComponentNode({ token: 'not-exported' }, { secret: 'not-exported' }), + true, + ); + + expect(data.component).toEqual({}); + }); + + it('applies one component debug-data character budget across the tree', () => { + const root = createComponentNode('root', {}); + for (let index = 0; index < 6; index++) { + root.children.push(createComponentNode(String(index).repeat(70_000), {})); + } + + const data = createDetailedData(root); + + expect(collectComponentDebugDataLength(data)).toBeLessThanOrEqual(262_144); + expect(hasOmittedComponentDebugData(data)).toBeTrue(); + }); + + it('preserves existing element snapshot data', () => { + const data = fromRenderedVirtualNode(createElementNode(), true); + + expect(data).toEqual({ + key: 'element', + tag: 'label', + element: { + id: 42, + frame: { x: 1, y: 2, width: 30, height: 40 }, + attributes: { value: 'Hello' }, + }, + component: undefined, + children: undefined, + }); + }); + + it('preserves component fields through the daemon message envelope', () => { + const data = createDetailedData(createComponentNode({ title: 'Wire' }, { attached: true })); + const message = Messages.parse(1, Messages.getContextTreeResponse('request-1', data)); + const body = message.body as IRenderedVirtualNodeData; + + expect(body.component?.viewModel).toContain('title: "Wire"'); + expect(body.component?.state).toContain('attached: true'); + }); +}); diff --git a/src/valdi_modules/src/valdi/valdi_core/test/RootComponentsManager.spec.ts b/src/valdi_modules/src/valdi/valdi_core/test/RootComponentsManager.spec.ts new file mode 100644 index 000000000..eecb224a3 --- /dev/null +++ b/src/valdi_modules/src/valdi/valdi_core/test/RootComponentsManager.spec.ts @@ -0,0 +1,77 @@ +import 'jasmine/src/jasmine'; +import { IComponent } from '../src/IComponent'; +import { IRenderedVirtualNode } from '../src/IRenderedVirtualNode'; +import { IRenderedVirtualNodeData } from '../src/IRenderedVirtualNodeData'; +import { RootComponentHandle, RootComponentsManager } from '../src/RootComponentsManager'; +import { RendererFactory } from '../src/RendererFactory'; +import { ReceivedDaemonClientMessage } from '../src/debugging/DaemonClientManager'; +import { GetContextTreeBody, Messages } from '../src/debugging/Messages'; + +function createComponentNode(): IRenderedVirtualNode { + const component = { + viewModel: { title: 'Sensitive input' }, + state: { token: 'sensitive-state' }, + } as unknown as IComponent; + + return { + key: 'component', + uniqueId: 'component', + parent: undefined, + element: undefined, + component, + children: [], + parentIndex: 0, + renderer: component.renderer, + }; +} + +function createManager(rootNode: IRenderedVirtualNode): RootComponentsManager { + const manager = new RootComponentsManager({} as RendererFactory, undefined, () => {}); + manager.rootComponents['context'] = { + renderer: { + getRootVirtualNode: () => rootNode, + }, + } as RootComponentHandle; + return manager; +} + +function requestContextTree( + manager: RootComponentsManager, + includeComponentData: boolean | undefined, +): IRenderedVirtualNodeData { + const body: GetContextTreeBody = { id: 'context' }; + if (includeComponentData !== undefined) { + body.includeComponentData = includeComponentData; + } + + const request = Messages.parse(1, Messages.getContextTreeRequest('request-1', body)); + let responseJson: string | undefined; + const receivedMessage = { + message: request, + respond: (makeMessage: (requestId: string) => string) => { + responseJson = makeMessage(request.requestId); + }, + } as ReceivedDaemonClientMessage; + + manager.onMessage(receivedMessage); + if (responseJson === undefined) { + throw new Error('RootComponentsManager did not respond to the tree request.'); + } + + return Messages.parse(1, responseJson).body as IRenderedVirtualNodeData; +} + +describe('RootComponentsManager debugger tree serialization', () => { + it('does not expose component data to existing tree clients by default', () => { + const data = requestContextTree(createManager(createComponentNode()), undefined); + + expect(data.component).toEqual({}); + }); + + it('exposes component data only when the request explicitly opts in', () => { + const data = requestContextTree(createManager(createComponentNode()), true); + + expect(data.component?.viewModel).toContain('Sensitive input'); + expect(data.component?.state).toContain('sensitive-state'); + }); +}); From 5169cef90971abb44f987c486e7aebf73d53a5ab Mon Sep 17 00:00:00 2001 From: Ben Dodson Date: Tue, 25 Aug 2026 12:26:01 -0700 Subject: [PATCH 2/2] fix(debugger): address review feedback --- npm_modules/cli/debugger/README.md | 20 +- npm_modules/cli/debugger/debugger-api.js | 29 +- npm_modules/cli/debugger/debugger-runtime.js | 2 +- npm_modules/cli/src/commands/debugger.ts | 6 +- npm_modules/cli/src/debugger/server.spec.ts | 280 ++++++++++++++- npm_modules/cli/src/debugger/server.ts | 340 +++++++++++++----- .../test/IRenderedVirtualNodeData.spec.ts | 33 ++ 7 files changed, 609 insertions(+), 101 deletions(-) diff --git a/npm_modules/cli/debugger/README.md b/npm_modules/cli/debugger/README.md index c1889fac6..d43a548f0 100644 --- a/npm_modules/cli/debugger/README.md +++ b/npm_modules/cli/debugger/README.md @@ -51,7 +51,25 @@ serialization. That data can be sensitive, is bounded by a per-field and whole-tree character budget, and is never included in ordinary `valdi inspect tree` requests. Auto-refresh starts disabled so serialization remains a deliberate local debugging action. The server rejects non-loopback Host, -Origin, and cross-site browser API requests. +Origin, and cross-site browser API requests. It also generates a fresh +high-entropy API token for every server instance and rejects every `/api/*` +GET, POST, and event stream that does not present it. Browser and DevTools +pages receive the token in the served HTML bootstrap, use the +`X-Valdi-Debugger-Token` header for fetches, and add it only to same-origin +EventSource URLs (whose document has `Referrer-Policy: no-referrer`). The +ordinary CLI log and `VALDI_DEBUGGER_URL` never contain the token. Agents must +start the command with `--json`, treat the returned `apiToken` as a secret, and +send it using the returned `apiTokenHeader` name. + +The UI listener and CLI target discovery use separate environment variables: + +- `VALDI_DEBUGGER_UI_PORT` selects the browser HTTP port (default `8765`). +- `VALDI_DEBUGGER_SERVICE_PORT` tells target discovery which Valdi runtime + debugger service port to probe. Without it, discovery probes + the standalone and mobile defaults (`13591` and `13592`). + +Both values must be decimal integers in the range 1–65535. The legacy +`VALDI_DEBUGGER_PORT` name is intentionally not used by the UI or service. ## Development Loop diff --git a/npm_modules/cli/debugger/debugger-api.js b/npm_modules/cli/debugger/debugger-api.js index e9c83bc70..98bb235a6 100644 --- a/npm_modules/cli/debugger/debugger-api.js +++ b/npm_modules/cli/debugger/debugger-api.js @@ -1,4 +1,21 @@ // HTTP helpers and the external debugger action/event bridge. +const DEBUGGER_API_TOKEN_HEADER = 'X-Valdi-Debugger-Token'; +const DEBUGGER_API_TOKEN_QUERY_PARAMETER = 'valdiDebuggerToken'; +const debuggerApiToken = document.querySelector('meta[name="valdi-debugger-api-token"]')?.content || ''; + +function debuggerApiHeaders(headers) { + return { + ...headers, + [DEBUGGER_API_TOKEN_HEADER]: debuggerApiToken, + }; +} + +function debuggerEventSourceUrl(path) { + const url = new URL(path, window.location.origin); + url.searchParams.set(DEBUGGER_API_TOKEN_QUERY_PARAMETER, debuggerApiToken); + return url.toString(); +} + async function apiGet(path, params = {}, options = {}) { const url = new URL(path, window.location.origin); for (const [key, value] of Object.entries(params)) { @@ -10,7 +27,11 @@ async function apiGet(path, params = {}, options = {}) { const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs || 8000); try { - const response = await fetch(url, { cache: 'no-store', signal: controller.signal }); + const response = await fetch(url, { + cache: 'no-store', + signal: controller.signal, + headers: debuggerApiHeaders({}), + }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || `Request failed: ${response.status}`); @@ -41,7 +62,7 @@ async function apiPost(path, params = {}, body = {}, options = {}) { method: 'POST', cache: 'no-store', signal: controller.signal, - headers: { 'Content-Type': 'application/json' }, + headers: debuggerApiHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(body), }); const data = await response.json(); @@ -61,7 +82,7 @@ async function apiPost(path, params = {}, body = {}, options = {}) { function startDebuggerDevReload() { if (!window.EventSource || window.location.search.includes('noDevReload=1')) return; - const source = new EventSource('/api/dev-events'); + const source = new EventSource(debuggerEventSourceUrl('/api/dev-events')); let loadedRevision = null; source.addEventListener('ready', event => { @@ -84,7 +105,7 @@ function startDebuggerDevReload() { function startDebuggerActionStream() { if (!window.EventSource) return; - const source = new EventSource('/api/debugger/events'); + const source = new EventSource(debuggerEventSourceUrl('/api/debugger/events')); state.debuggerEventStream = source; source.addEventListener('ready', event => { diff --git a/npm_modules/cli/debugger/debugger-runtime.js b/npm_modules/cli/debugger/debugger-runtime.js index 265493aa0..fc065675b 100644 --- a/npm_modules/cli/debugger/debugger-runtime.js +++ b/npm_modules/cli/debugger/debugger-runtime.js @@ -82,7 +82,7 @@ function startRuntimeLogStream(options = {}) { if (state.runtimeLogStream && state.runtimeLogStreamKey === key) return; stopRuntimeLogStream(); - const source = new EventSource(`/api/runtime-logs/stream?${key}`); + const source = new EventSource(debuggerEventSourceUrl(`/api/runtime-logs/stream?${key}`)); state.runtimeLogStream = source; state.runtimeLogStreamKey = key; diff --git a/npm_modules/cli/src/commands/debugger.ts b/npm_modules/cli/src/commands/debugger.ts index d1d3af308..c6d85694e 100644 --- a/npm_modules/cli/src/commands/debugger.ts +++ b/npm_modules/cli/src/commands/debugger.ts @@ -1,5 +1,5 @@ import type { Argv } from 'yargs'; -import { startDebuggerServer } from '../debugger/server'; +import { resolveDebuggerUiPort, startDebuggerServer } from '../debugger/server'; import type { ArgumentsResolver } from '../utils/ArgumentsResolver'; import { makeCommandHandler } from '../utils/errorUtils'; @@ -42,6 +42,8 @@ async function valdiDebugger(argv: ArgumentsResolver): Promis console.log( JSON.stringify({ url: debuggerServer.url, + apiToken: debuggerServer.apiToken, + apiTokenHeader: debuggerServer.apiTokenHeader, host: debuggerServer.host, port: debuggerServer.port, requestedPort: debuggerServer.requestedPort, @@ -72,7 +74,7 @@ export const builder = (yargs: Argv) => { .option('port', { describe: 'Preferred debugger web server port', type: 'number', - default: Number.parseInt(process.env['VALDI_DEBUGGER_PORT'] || '8765', 10), + default: resolveDebuggerUiPort(), }) .option('strict-port', { describe: 'Fail instead of selecting the next available port when the preferred port is busy', diff --git a/npm_modules/cli/src/debugger/server.spec.ts b/npm_modules/cli/src/debugger/server.spec.ts index ba7a153ae..965e22e35 100644 --- a/npm_modules/cli/src/debugger/server.spec.ts +++ b/npm_modules/cli/src/debugger/server.spec.ts @@ -4,14 +4,22 @@ import * as http from 'node:http'; import * as net from 'node:net'; import * as os from 'node:os'; import * as path from 'node:path'; +import type { CpuProfile } from '../utils/hermesClient'; import type { DebuggerServerInfo } from './server'; -import { startDebuggerServer } from './server'; +import { + createInterruptibleOperation, + resolveDebuggerServicePort, + resolveDebuggerUiPort, + startDebuggerServer, + summarizeCpuProfile, +} from './server'; interface HttpResult { body: string; contentSecurityPolicy: string; contentType: string; statusCode: number; + referrerPolicy: string; xFrameOptions: string; } @@ -81,6 +89,7 @@ async function request(url: string, options: HttpRequestOptions): Promise { + return await request(new URL(route, server.url).toString(), authenticatedOptions(server, options)); +} + +function eventSourceUrl(server: DebuggerServerInfo, route: string): string { + const url = new URL(route, server.url); + url.searchParams.set('valdiDebuggerToken', server.apiToken); + return url.toString(); +} + async function readSseEvents( url: string, eventName: string, @@ -140,7 +170,7 @@ async function readSseEvents( while (separatorIndex !== -1) { const eventBlock = pending.slice(0, separatorIndex); pending = pending.slice(separatorIndex + 2); - const lines = eventBlock.split('\n'); + const lines = eventBlock.trimStart().split('\n'); if (lines[0] !== `event: ${eventName}`) { separatorIndex = pending.indexOf('\n\n'); continue; @@ -173,9 +203,15 @@ describe('debugger server', () => { let debuggerServer: DebuggerServerInfo | undefined; let occupiedPortServer: net.Server | undefined; let originalHome: string | undefined; + let originalServicePort: string | undefined; + let originalUiPort: string | undefined; beforeEach(() => { originalHome = process.env['HOME']; + originalServicePort = process.env['VALDI_DEBUGGER_SERVICE_PORT']; + originalUiPort = process.env['VALDI_DEBUGGER_UI_PORT']; + delete process.env['VALDI_DEBUGGER_SERVICE_PORT']; + delete process.env['VALDI_DEBUGGER_UI_PORT']; assetRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'valdi-debugger-assets-')); fs.writeFileSync(path.join(assetRoot, 'index.html'), 'Valdi Debugger', 'utf8'); }); @@ -194,6 +230,16 @@ describe('debugger server', () => { } else { process.env['HOME'] = originalHome; } + if (originalServicePort === undefined) { + delete process.env['VALDI_DEBUGGER_SERVICE_PORT']; + } else { + process.env['VALDI_DEBUGGER_SERVICE_PORT'] = originalServicePort; + } + if (originalUiPort === undefined) { + delete process.env['VALDI_DEBUGGER_UI_PORT']; + } else { + process.env['VALDI_DEBUGGER_UI_PORT'] = originalUiPort; + } fs.rmSync(assetRoot, { recursive: true, force: true }); }); @@ -210,8 +256,12 @@ describe('debugger server', () => { expect(result.statusCode).toBe(200); expect(result.contentType).toBe('text/html; charset=utf-8'); expect(result.body).toContain('Valdi Debugger'); + expect(debuggerServer.apiToken).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(debuggerServer.url).not.toContain(debuggerServer.apiToken); + expect(result.body).toContain(`name="valdi-debugger-api-token" content="${debuggerServer.apiToken}"`); expect(result.contentSecurityPolicy).toContain("script-src 'self'"); expect(result.contentSecurityPolicy).toContain("frame-ancestors 'none'"); + expect(result.referrerPolicy).toBe('no-referrer'); expect(result.xFrameOptions).toBe('DENY'); }); @@ -225,7 +275,7 @@ describe('debugger server', () => { debuggerServer = serverToClose; const response = await new Promise((resolve, reject) => { - const outgoingRequest = http.get(new URL('/api/debugger/events', serverToClose.url).toString(), resolve); + const outgoingRequest = http.get(eventSourceUrl(serverToClose, '/api/debugger/events'), resolve); outgoingRequest.once('error', reject); }); response.resume(); @@ -288,6 +338,174 @@ describe('debugger server', () => { ).toBeRejectedWithError(/between 1 and 65535/); }); + it('validates the distinct UI and runtime service port environments', async () => { + process.env['VALDI_DEBUGGER_UI_PORT'] = '9100'; + process.env['VALDI_DEBUGGER_SERVICE_PORT'] = '14000'; + expect(resolveDebuggerUiPort()).toBe(9100); + expect(resolveDebuggerServicePort()).toBe(14000); + + process.env['VALDI_DEBUGGER_UI_PORT'] = '9100suffix'; + expect(() => resolveDebuggerUiPort()).toThrowError(/VALDI_DEBUGGER_UI_PORT must be an integer/); + process.env['VALDI_DEBUGGER_UI_PORT'] = '9100'; + process.env['VALDI_DEBUGGER_SERVICE_PORT'] = '0'; + expect(() => resolveDebuggerServicePort()).toThrowError(/VALDI_DEBUGGER_SERVICE_PORT must be an integer/); + + process.env['VALDI_DEBUGGER_SERVICE_PORT'] = 'invalid'; + await expectAsync( + startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }), + ).toBeRejectedWithError(/VALDI_DEBUGGER_SERVICE_PORT must be an integer/); + delete process.env['VALDI_DEBUGGER_SERVICE_PORT']; + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + }); + + it('uses the configured runtime service port for target discovery', async () => { + const servicePort = await getFreePort(); + process.env['VALDI_DEBUGGER_SERVICE_PORT'] = String(servicePort); + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + + const result = await requestApi(debuggerServer, '/api/status', GET_REQUEST_OPTIONS); + const responseBody = JSON.parse(result.body) as { defaultPort: number; ports: Array<{ port: number }> }; + + expect(result.statusCode).toBe(200); + expect(responseBody.defaultPort).toBe(servicePort); + expect(responseBody.ports.map(status => status.port)).toEqual([servicePort]); + }); + + it('allows only one debugger server per process and resets the guard after close', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + + await expectAsync( + startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }), + ).toBeRejectedWithError(/already starting or running/); + expect((await request(debuggerServer.url, GET_REQUEST_OPTIONS)).statusCode).toBe(200); + + await debuggerServer.close(); + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + expect((await request(debuggerServer.url, GET_REQUEST_OPTIONS)).statusCode).toBe(200); + }); + + it('requires the per-server token for API GET, POST, and event streams', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + + const missingGet = await request(new URL('/api/status', debuggerServer.url).toString(), GET_REQUEST_OPTIONS); + const wrongGet = await request(new URL('/api/status', debuggerServer.url).toString(), { + ...GET_REQUEST_OPTIONS, + headers: { [debuggerServer.apiTokenHeader]: 'wrong-token' }, + }); + const queryOnNonStream = new URL('/api/status', debuggerServer.url); + queryOnNonStream.searchParams.set('valdiDebuggerToken', debuggerServer.apiToken); + const queryGet = await request(queryOnNonStream.toString(), GET_REQUEST_OPTIONS); + const missingPost = await request(new URL('/api/debugger/actions', debuggerServer.url).toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'setAutoRefresh', params: { enabled: true } }), + }); + const missingStream = await request( + new URL('/api/debugger/events', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + const wrongStream = new URL('/api/debugger/events', debuggerServer.url); + wrongStream.searchParams.set('valdiDebuggerToken', 'wrong-token'); + const wrongStreamResult = await request(wrongStream.toString(), GET_REQUEST_OPTIONS); + + expect(missingGet.statusCode).toBe(401); + expect(wrongGet.statusCode).toBe(401); + expect(queryGet.statusCode).toBe(401); + expect(missingPost.statusCode).toBe(401); + expect(missingStream.statusCode).toBe(401); + expect(wrongStreamResult.statusCode).toBe(401); + expect(missingGet.body).not.toContain(debuggerServer.apiToken); + + const validPost = await requestApi(debuggerServer, '/api/debugger/actions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'setAutoRefresh', params: { enabled: true } }), + }); + const readyEvents = await readSseEvents( + eventSourceUrl(debuggerServer, '/api/debugger/events'), + 'ready', + 1, + () => undefined, + ); + expect(validPost.statusCode).toBe(200); + expect(readyEvents).toHaveSize(1); + }); + + it('lets an explicit stop interrupt a timed operation and shares one completion', async () => { + let completionCount = 0; + const operation = createInterruptibleOperation(60_000, async () => { + completionCount += 1; + return 'stopped'; + }); + + const [stopResult, timedResult] = await Promise.all([operation.stop(), operation.result]); + + expect(stopResult).toBe('stopped'); + expect(timedResult).toBe('stopped'); + expect(completionCount).toBe(1); + }); + + it('redacts source URLs from CPU profile summaries without changing the raw profile', () => { + const profile: CpuProfile = { + nodes: [ + { + id: 1, + callFrame: { + functionName: 'renderSecret', + scriptId: '1', + url: 'file:///Users/example/private/app.ts', + lineNumber: 42, + columnNumber: 1, + }, + hitCount: 1, + }, + ], + startTime: 1_000, + endTime: 2_000, + samples: [1], + }; + + const summary = summarizeCpuProfile(profile) as { topFunctions: Array> }; + + expect(summary.topFunctions[0]).toEqual({ name: 'renderSecret', lineNumber: 42, sampleCount: 1 }); + expect(profile.nodes[0]?.callFrame.url).toBe('file:///Users/example/private/app.ts'); + }); + it('rejects non-loopback Host headers', async () => { debuggerServer = await startDebuggerServer({ assetRoot, @@ -375,6 +593,7 @@ describe('debugger server', () => { headers: { 'Content-Type': 'application/json', Origin: new URL(debuggerServer.url).origin, + [debuggerServer.apiTokenHeader]: debuggerServer.apiToken, }, body: JSON.stringify({ action: 'setAutoRefresh', params: { enabled: true } }), }); @@ -395,12 +614,18 @@ describe('debugger server', () => { const unknown = await request(actionsUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + [debuggerServer.apiTokenHeader]: debuggerServer.apiToken, + }, body: JSON.stringify({ action: 'typoAction', params: {} }), }); const invalidPort = await request(actionsUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + [debuggerServer.apiTokenHeader]: debuggerServer.apiToken, + }, body: JSON.stringify({ action: 'setPort', params: { port: 70_000 } }), }); @@ -423,6 +648,7 @@ describe('debugger server', () => { expect(characterOffset).toBeGreaterThan(0); const streaming = startStreamingRequest(new URL('/api/debugger/actions', debuggerServer.url).toString(), 'POST', { 'Content-Type': 'application/json', + [debuggerServer.apiTokenHeader]: debuggerServer.apiToken, }); streaming.request.write(body.subarray(0, characterOffset + 1)); @@ -442,13 +668,19 @@ describe('debugger server', () => { strictPort: true, }); const profileUrl = new URL('/api/performance/profile/start', debuggerServer.url).toString(); - const first = startStreamingRequest(profileUrl, 'POST', { 'Content-Type': 'application/json' }); + const first = startStreamingRequest(profileUrl, 'POST', { + 'Content-Type': 'application/json', + [debuggerServer.apiTokenHeader]: debuggerServer.apiToken, + }); first.request.write('{'); await new Promise(resolve => setTimeout(resolve, 20)); const second = await request(profileUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + [debuggerServer.apiTokenHeader]: debuggerServer.apiToken, + }, body: '{}', }); first.request.end('invalid'); @@ -480,7 +712,7 @@ describe('debugger server', () => { strictPort: true, }); - const result = await request(new URL('/api/unknown', debuggerServer.url).toString(), GET_REQUEST_OPTIONS); + const result = await requestApi(debuggerServer, '/api/unknown', GET_REQUEST_OPTIONS); expect(result.statusCode).toBe(404); expect(result.contentType).toBe('application/json; charset=utf-8'); @@ -510,8 +742,9 @@ describe('debugger server', () => { strictPort: true, }); - const result = await request( - new URL('/api/runtime-logs?applicationId=example&platform=ios', debuggerServer.url).toString(), + const result = await requestApi( + debuggerServer, + '/api/runtime-logs?applicationId=example&platform=ios', GET_REQUEST_OPTIONS, ); const responseBody = JSON.parse(result.body) as { @@ -524,6 +757,26 @@ describe('debugger server', () => { expect(responseBody.logs.map(log => log.message)).toEqual(['exact application']); }); + it('does not expose absolute log paths in filesystem error responses', async () => { + const logsDirectory = path.join(assetRoot, 'missing', 'logs'); + const consoleWarn = spyOn(console, 'warn'); + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + logsDirectory, + port: await getFreePort(), + strictPort: true, + }); + + const result = await requestApi(debuggerServer, '/api/runtime-logs', GET_REQUEST_OPTIONS); + const responseBody = JSON.parse(result.body) as { error: string }; + + expect(result.statusCode).toBe(500); + expect(responseBody.error).toBe('A local filesystem operation failed. See the debugger server output for details.'); + expect(result.body).not.toContain(logsDirectory); + expect(consoleWarn).toHaveBeenCalledWith(jasmine.stringContaining(logsDirectory)); + }); + it('reads the logs directory through the shared YAML config parser', async () => { const testHome = path.join(assetRoot, 'home'); const logsDirectory = path.join(testHome, 'logs#debug'); @@ -547,8 +800,9 @@ describe('debugger server', () => { strictPort: true, }); - const result = await request( - new URL('/api/runtime-logs?applicationId=configured&platform=ios', debuggerServer.url).toString(), + const result = await requestApi( + debuggerServer, + '/api/runtime-logs?applicationId=configured&platform=ios', GET_REQUEST_OPTIONS, ); const responseBody = JSON.parse(result.body) as { logFile: string; logs: Array<{ message: string }> }; @@ -573,7 +827,7 @@ describe('debugger server', () => { }); const payloads = await readSseEvents( - new URL('/api/runtime-logs/stream?applicationId=example&platform=ios&limit=2', debuggerServer.url).toString(), + eventSourceUrl(debuggerServer, '/api/runtime-logs/stream?applicationId=example&platform=ios&limit=2'), 'logs', 2, (_payload, index) => { diff --git a/npm_modules/cli/src/debugger/server.ts b/npm_modules/cli/src/debugger/server.ts index b02c1b5a9..8a05b68df 100644 --- a/npm_modules/cli/src/debugger/server.ts +++ b/npm_modules/cli/src/debugger/server.ts @@ -1,6 +1,7 @@ import { watch as watchFileSystem } from 'node:fs'; import type { FSWatcher, Stats } from 'node:fs'; import fs from 'node:fs/promises'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; import type { IncomingMessage, Server, ServerResponse } from 'node:http'; import http from 'node:http'; import net from 'node:net'; @@ -17,10 +18,13 @@ import { getUserConfig, resolveFilePath } from '../utils/fileUtils'; import { type CpuProfile, HERMES_PORT, HermesConnection, listHermesDevices } from '../utils/hermesClient'; const DEFAULT_HOST = process.env['VALDI_DEBUGGER_HOST'] || '127.0.0.1'; -const DEFAULT_PORT = Number.parseInt(process.env['VALDI_DEBUGGER_PORT'] || '8765', 10); +const DEFAULT_UI_PORT = 8765; const HOT_RELOAD_PROXY_PORT = Number.parseInt(process.env['VALDI_HOT_RELOAD_PROXY_PORT'] || '9010', 10); const PORT_SEARCH_LIMIT = 50; const MAX_RUNTIME_LOG_READ_BYTES = 1024 * 1024; +const API_TOKEN_HEADER = 'X-Valdi-Debugger-Token'; +const API_TOKEN_QUERY_PARAMETER = 'valdiDebuggerToken'; +const API_TOKEN_META_NAME = 'valdi-debugger-api-token'; const MIME_TYPES: Record = { '.html': 'text/html; charset=utf-8', @@ -68,6 +72,11 @@ interface ActiveProfileSession { startedAtEpochMs: number; } +export interface InterruptibleOperation { + result: Promise; + stop: () => Promise; +} + interface DebuggerActionRecord { action: string; params: Record; @@ -112,6 +121,8 @@ export interface DebuggerServerInfo { host: string; port: number; url: string; + apiToken: string; + apiTokenHeader: string; requestedPort: number; portWasAutoSelected: boolean; } @@ -146,9 +157,13 @@ let devReloadTimer: NodeJS.Timeout | null = null; let activeHost = DEFAULT_HOST; let assetRoot = getDefaultAssetRoot(); let activeLogsDirectory: string | null = null; +let activeServicePort: number | undefined; let activeProfileSession: ActiveProfileSession | null = null; +let activeProfileCapture: InterruptibleOperation> | null = null; let profileTransitionInProgress = false; -const debuggerUiState = createDebuggerUiState(); +let debuggerServerLifecycleActive = false; +let debuggerServerClosing = false; +let debuggerUiState = createDebuggerUiState(STANDALONE_PORT); function getDefaultAssetRoot(): string { // The published CLI is emitted as CommonJS, so __dirname is the reliable package-relative anchor. @@ -156,6 +171,35 @@ function getDefaultAssetRoot(): string { return path.resolve(__dirname, '..', '..', 'debugger'); } +function readEnvironmentPort(name: string): number | undefined { + const value = process.env[name]; + if (value === undefined || value === '') return undefined; + if (!/^\d+$/.test(value)) { + throw new Error(`${name} must be an integer between 1 and 65535.`); + } + const port = Number(value); + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { + throw new Error(`${name} must be an integer between 1 and 65535.`); + } + return port; +} + +export function resolveDebuggerUiPort(): number { + return readEnvironmentPort('VALDI_DEBUGGER_UI_PORT') ?? DEFAULT_UI_PORT; +} + +export function resolveDebuggerServicePort(): number | undefined { + return readEnvironmentPort('VALDI_DEBUGGER_SERVICE_PORT'); +} + +function defaultServicePort(): number { + return activeServicePort ?? STANDALONE_PORT; +} + +function discoveryServicePorts(): number[] { + return activeServicePort === undefined ? [STANDALONE_PORT, MOBILE_PORT] : [activeServicePort]; +} + function normalizedHostname(hostname: string): string { return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; } @@ -222,6 +266,27 @@ function isAllowedApiFetchSite(fetchSiteHeader: string | undefined): boolean { return fetchSiteHeader === undefined || fetchSiteHeader === 'same-origin' || fetchSiteHeader === 'none'; } +function isEventStreamPath(pathname: string): boolean { + return ( + pathname === '/api/dev-events' || pathname === '/api/debugger/events' || pathname === '/api/runtime-logs/stream' + ); +} + +function tokenMatches(expectedToken: string, suppliedToken: string | undefined): boolean { + if (suppliedToken === undefined) return false; + const expected = Buffer.from(expectedToken); + const supplied = Buffer.from(suppliedToken); + return expected.length === supplied.length && timingSafeEqual(expected, supplied); +} + +function hasValidApiToken(request: IncomingMessage, url: URL, apiToken: string): boolean { + const header = request.headers[API_TOKEN_HEADER.toLowerCase()]; + const suppliedHeader = Array.isArray(header) ? undefined : header; + if (tokenMatches(apiToken, suppliedHeader)) return true; + if (!isEventStreamPath(url.pathname)) return false; + return tokenMatches(apiToken, url.searchParams.get(API_TOKEN_QUERY_PARAMETER) ?? undefined); +} + function probeTcpPort(port: number, timeoutMs: number): Promise { return new Promise(resolve => { const socket = connectToTcpPort(port); @@ -254,6 +319,22 @@ function errorPayload(error: unknown): { error: string } { }; } +function isFileSystemError(error: unknown): error is NodeJS.ErrnoException { + if (!(error instanceof Error)) return false; + const fileSystemError = error as NodeJS.ErrnoException; + return ( + typeof fileSystemError.code === 'string' && + typeof fileSystemError.path === 'string' && + typeof fileSystemError.syscall === 'string' + ); +} + +function clientErrorPayload(error: unknown): { error: string } { + if (!isFileSystemError(error)) return errorPayload(error); + console.warn(`Debugger filesystem error: ${errorPayload(error).error}`); + return { error: 'A local filesystem operation failed. See the debugger server output for details.' }; +} + function isValidSnapshotBase64(value: string): boolean { if (!value || value.length % 4 === 1) return false; const firstPaddingIndex = value.indexOf('='); @@ -359,13 +440,29 @@ function clampNumber(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } -function delay(durationMs: number): Promise { - return new Promise(resolve => { - setTimeout(resolve, durationMs); +export function createInterruptibleOperation( + durationMs: number, + complete: () => Promise, +): InterruptibleOperation { + let signalCompletion: (() => void) | undefined; + const signal = new Promise(resolve => { + signalCompletion = resolve; }); + const timer = setTimeout(() => signalCompletion?.(), durationMs); + let completion: Promise | undefined; + const stop = () => { + clearTimeout(timer); + signalCompletion?.(); + completion ??= Promise.resolve().then(complete); + return completion; + }; + return { + result: signal.then(stop), + stop, + }; } -function createDebuggerUiState(): DebuggerUiState { +function createDebuggerUiState(port: number): DebuggerUiState { return { revision: 0, selectedNodeId: null, @@ -375,7 +472,7 @@ function createDebuggerUiState(): DebuggerUiState { overlayMode: 'live', autoRefresh: false, followLatestTarget: true, - port: STANDALONE_PORT, + port, updatedAt: new Date().toISOString(), lastAction: null, }; @@ -841,7 +938,7 @@ async function streamRuntimeLogs( if (nextLogs.length > 0) sendSse(response, 'logs', { logs: nextLogs }); } catch (error) { - sendSse(response, 'stream-error', errorPayload(error)); + sendSse(response, 'stream-error', clientErrorPayload(error)); } } @@ -899,7 +996,7 @@ async function collectClientContexts( try { contexts = await conn.listContexts(client.client_id); } catch (error) { - contextError = errorPayload(error).error; + contextError = clientErrorPayload(error).error; } } @@ -938,14 +1035,14 @@ async function inspectPort(port: number): Promise<{ portName: portName(port), connected: false, clients: [], - error: errorPayload(error).error, + error: clientErrorPayload(error).error, }; } } async function inspectStatus(searchParams: URLSearchParams): Promise> { const explicitPort = searchParams.get('port'); - const ports = explicitPort ? [readNumber(searchParams, 'port', MOBILE_PORT)] : [STANDALONE_PORT, MOBILE_PORT]; + const ports = explicitPort ? [readNumber(searchParams, 'port', defaultServicePort())] : discoveryServicePorts(); const results: Array>> = []; for (const port of ports) { @@ -958,7 +1055,7 @@ async function inspectStatus(searchParams: URLSearchParams): Promise> { - const port = readNumber(searchParams, 'port', STANDALONE_PORT); + const port = readNumber(searchParams, 'port', defaultServicePort()); return await withConnection(port, async conn => { const { clients, client, contexts, context } = await resolveTarget(searchParams, conn); const tree = await conn.getContextTree(client.client_id, context.id, true); @@ -1076,7 +1173,7 @@ async function inspectSnapshot(searchParams: URLSearchParams): Promise> { - const port = readNumber(searchParams, 'port', STANDALONE_PORT); + const port = readNumber(searchParams, 'port', defaultServicePort()); const elementId = searchParams.get('elementId'); if (!elementId) { throw new Error('Missing required elementId query parameter.'); @@ -1104,7 +1201,7 @@ async function inspectHeap(request: IncomingMessage, searchParams: URLSearchPara } const body = await readJsonBody(request); - const port = readNumber(searchParams, 'port', STANDALONE_PORT); + const port = readNumber(searchParams, 'port', defaultServicePort()); const performGC = body['performGC'] === true; return await withConnection(port, async conn => { const { client } = await resolveTarget(searchParams, conn); @@ -1134,6 +1231,7 @@ async function startCpuProfile( if (request.method !== 'POST') { throw new Error('CPU profile start requires POST.'); } + assertNoActiveProfile(); return await runProfileTransition(async () => { assertNoActiveProfile(); const body = await readJsonBody(request); @@ -1147,6 +1245,8 @@ async function stopCpuProfile(request: IncomingMessage): Promise { + assertNoActiveProfile(); + const durationMs = await runProfileTransition(async () => { assertNoActiveProfile(); const body = await readJsonBody(request); - const durationMs = clampNumber(readBodyNumber(body, 'durationMs', 5000), 100, 60_000); + const requestedDurationMs = clampNumber(readBodyNumber(body, 'durationMs', 5000), 100, 60_000); activeProfileSession = await createProfileSession(searchParams, body); - await delay(durationMs); - return await stopActiveProfileSession(); + return requestedDurationMs; + }); + + let capture: InterruptibleOperation>; + capture = createInterruptibleOperation(durationMs, async () => { + try { + return await runProfileTransition(stopActiveProfileSession); + } finally { + if (activeProfileCapture === capture) activeProfileCapture = null; + } }); + activeProfileCapture = capture; + if (debuggerServerClosing) void capture.stop(); + try { + return await capture.result; + } finally { + if (activeProfileCapture === capture) activeProfileCapture = null; + } } async function runProfileTransition(transition: () => Promise): Promise { @@ -1266,7 +1382,7 @@ function profileStatusPayload(): Record { }; } -function summarizeCpuProfile(profile: CpuProfile): Record { +export function summarizeCpuProfile(profile: CpuProfile): Record { const samples = profile.samples ?? []; const sampleCountByNodeId = new Map(); for (const sample of samples) { @@ -1276,7 +1392,6 @@ function summarizeCpuProfile(profile: CpuProfile): Record { const topFunctions = profile.nodes .map(node => ({ name: node.callFrame.functionName || '(anonymous)', - url: node.callFrame.url, lineNumber: node.callFrame.lineNumber, sampleCount: sampleCountByNodeId.get(node.id) ?? node.hitCount ?? 0, })) @@ -1375,11 +1490,24 @@ async function handleApi(request: IncomingMessage, response: ServerResponse, url sendJson(response, 404, { error: `Unknown API route ${url.pathname}` }); } catch (error) { - sendJson(response, error instanceof ApiRequestError ? error.statusCode : 500, errorPayload(error)); + sendJson(response, error instanceof ApiRequestError ? error.statusCode : 500, clientErrorPayload(error)); } } -async function serveStatic(_request: IncomingMessage, response: ServerResponse, url: URL): Promise { +function injectApiTokenBootstrap(html: string, apiToken: string): string { + const bootstrap = ``; + const headEnd = html.indexOf(''); + if (headEnd !== -1) return `${html.slice(0, headEnd)}${bootstrap}${html.slice(headEnd)}`; + const doctypeEnd = html.toLowerCase().startsWith('') ? html.indexOf('>') + 1 : 0; + return `${html.slice(0, doctypeEnd)}${bootstrap}${html.slice(doctypeEnd)}`; +} + +async function serveStatic( + _request: IncomingMessage, + response: ServerResponse, + url: URL, + apiToken: string, +): Promise { let requestedPath: string; try { requestedPath = decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname); @@ -1400,6 +1528,7 @@ async function serveStatic(_request: IncomingMessage, response: ServerResponse, try { const data = await fs.readFile(resolvedPath); const ext = path.extname(resolvedPath); + const responseData = ext === '.html' ? injectApiTokenBootstrap(data.toString('utf8'), apiToken) : data; response.writeHead(200, { 'Content-Type': MIME_TYPES[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-store', @@ -1407,15 +1536,16 @@ async function serveStatic(_request: IncomingMessage, response: ServerResponse, "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'no-referrer', }); - response.end(data); + response.end(responseData); } catch { response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); response.end(`Not found: ${url.pathname}`); } } -function createDebuggerHttpServer(host: string, port: number): Server { +function createDebuggerHttpServer(host: string, port: number, apiToken: string): Server { return http.createServer(async (request, response) => { const url = new URL(request.url || '/', `http://${hostForUrl(host)}:${port}`); const apiFetchSiteAllowed = @@ -1431,11 +1561,15 @@ function createDebuggerHttpServer(host: string, port: number): Server { } if (url.pathname.startsWith('/api/')) { + if (!hasValidApiToken(request, url, apiToken)) { + sendJson(response, 401, { error: 'Debugger API authentication is required.' }); + return; + } await handleApi(request, response, url); return; } - await serveStatic(request, response, url); + await serveStatic(request, response, url, apiToken); }); } @@ -1497,69 +1631,115 @@ async function closeHttpServer(server: Server): Promise { } async function closeDebuggerServer(server: Server): Promise { - for (const closeStream of Array.from(eventStreamClosers)) { - closeStream(); - } - closeAssetWatchers(); - if (devReloadTimer) { - clearTimeout(devReloadTimer); - devReloadTimer = null; - } - // Wait for in-flight profile setup/capture requests before inspecting the - // active session so shutdown cannot orphan a session created late. - await closeHttpServer(server); - if (activeProfileSession) { - try { - await stopActiveProfileSession(); - } catch (error) { - console.warn(`Could not stop the active Hermes profile during shutdown: ${errorPayload(error).error}`); + debuggerServerClosing = true; + try { + for (const closeStream of Array.from(eventStreamClosers)) { + closeStream(); + } + closeAssetWatchers(); + if (devReloadTimer) { + clearTimeout(devReloadTimer); + devReloadTimer = null; + } + if (activeProfileCapture) { + try { + await activeProfileCapture.stop(); + } catch (error) { + console.warn(`Could not stop the active Hermes capture during shutdown: ${errorPayload(error).error}`); + } } + // Wait for in-flight profile setup requests before inspecting the active + // session so shutdown cannot orphan a session created late. + await closeHttpServer(server); + if (activeProfileSession) { + try { + await stopActiveProfileSession(); + } catch (error) { + console.warn(`Could not stop the active Hermes profile during shutdown: ${errorPayload(error).error}`); + } + } + } finally { + activeHost = DEFAULT_HOST; + assetRoot = getDefaultAssetRoot(); + activeLogsDirectory = null; + activeServicePort = undefined; + activeProfileSession = null; + activeProfileCapture = null; + profileTransitionInProgress = false; + devEventClients.clear(); + debuggerEventClients.clear(); + eventStreamClosers.clear(); + devRevision = 0; + debuggerEventRevision = 0; + debuggerUiState = createDebuggerUiState(STANDALONE_PORT); + debuggerServerClosing = false; + debuggerServerLifecycleActive = false; } } export async function startDebuggerServer(options: DebuggerServerOptions): Promise { - activeHost = options.host ?? DEFAULT_HOST; - if (!isLoopbackHost(activeHost)) { - throw new Error(`The debugger server only binds to loopback hosts; received '${activeHost}'.`); + if (debuggerServerLifecycleActive) { + throw new Error('A debugger server is already starting or running in this process.'); } + debuggerServerLifecycleActive = true; - assetRoot = options.assetRoot ?? getDefaultAssetRoot(); - activeLogsDirectory = options.logsDirectory ?? null; - const preferredPort = options.port ?? DEFAULT_PORT; - if (!Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65_535) { - throw new Error(`Debugger port must be an integer between 1 and 65535; received '${String(preferredPort)}'.`); - } - const strictPort = Boolean(options.strictPort); - const maxAttempts = strictPort ? 1 : PORT_SEARCH_LIMIT; + try { + const host = options.host ?? DEFAULT_HOST; + if (!isLoopbackHost(host)) { + throw new Error(`The debugger server only binds to loopback hosts; received '${host}'.`); + } - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const port = preferredPort + attempt; - const server = createDebuggerHttpServer(activeHost, port); - try { - await listen(server, activeHost, port); - closeAssetWatchers(); - await watchDebuggerAssets(assetRoot); - let closePromise: Promise | undefined; - return { - server, - close: () => { - closePromise ??= closeDebuggerServer(server); - return closePromise; - }, - host: activeHost, - port, - url: `http://${hostForUrl(activeHost)}:${port}/`, - requestedPort: preferredPort, - portWasAutoSelected: port !== preferredPort, - }; - } catch (error) { - server.close(); - const err = error as NodeJS.ErrnoException; - if (strictPort || err.code !== 'EADDRINUSE') { - throw error; + const preferredPort = options.port ?? resolveDebuggerUiPort(); + if (!Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65_535) { + throw new Error(`Debugger port must be an integer between 1 and 65535; received '${String(preferredPort)}'.`); + } + const servicePort = resolveDebuggerServicePort(); + const strictPort = Boolean(options.strictPort); + const maxAttempts = strictPort ? 1 : PORT_SEARCH_LIMIT; + const apiToken = randomBytes(32).toString('base64url'); + + activeHost = host; + assetRoot = options.assetRoot ?? getDefaultAssetRoot(); + activeLogsDirectory = options.logsDirectory ?? null; + activeServicePort = servicePort; + debuggerUiState = createDebuggerUiState(defaultServicePort()); + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const port = preferredPort + attempt; + const server = createDebuggerHttpServer(activeHost, port, apiToken); + try { + await listen(server, activeHost, port); + closeAssetWatchers(); + await watchDebuggerAssets(assetRoot); + let closePromise: Promise | undefined; + return { + server, + close: () => { + closePromise ??= closeDebuggerServer(server); + return closePromise; + }, + host: activeHost, + port, + url: `http://${hostForUrl(activeHost)}:${port}/`, + apiToken, + apiTokenHeader: API_TOKEN_HEADER, + requestedPort: preferredPort, + portWasAutoSelected: port !== preferredPort, + }; + } catch (error) { + server.close(); + const err = error as NodeJS.ErrnoException; + if (strictPort || err.code !== 'EADDRINUSE') { + throw error; + } } } - } - throw new Error(`No available port found in ${preferredPort}-${preferredPort + maxAttempts - 1}.`); + throw new Error(`No available port found in ${preferredPort}-${preferredPort + maxAttempts - 1}.`); + } catch (error) { + closeAssetWatchers(); + debuggerServerClosing = false; + debuggerServerLifecycleActive = false; + throw error; + } } diff --git a/src/valdi_modules/src/valdi/valdi_core/test/IRenderedVirtualNodeData.spec.ts b/src/valdi_modules/src/valdi/valdi_core/test/IRenderedVirtualNodeData.spec.ts index 6419fdbae..5c5a0cf6d 100644 --- a/src/valdi_modules/src/valdi/valdi_core/test/IRenderedVirtualNodeData.spec.ts +++ b/src/valdi_modules/src/valdi/valdi_core/test/IRenderedVirtualNodeData.spec.ts @@ -190,6 +190,39 @@ describe('IRenderedVirtualNodeData', () => { expect(data.component?.viewModel).toContain('Set('); }); + it('replaces values at the maximum component debug depth with an omission marker', () => { + const data = createDetailedData( + createComponentNode( + { + level1: { level2: { level3: { level4: 'too-deep' } } }, + sibling: 'visible', + }, + {}, + ), + ); + + expect(data.component?.viewModel).toContain('level4: ...'); + expect(data.component?.viewModel).not.toContain('too-deep'); + expect(data.component?.viewModel).toContain('sibling: "visible"'); + }); + + it('limits arrays and objects to 50 serialized items with omission markers', () => { + const items = Array.from({ length: 52 }, (_value, index) => `item-${index}`); + const properties: Record = {}; + for (let index = 0; index < 52; index++) { + properties[`property${index}`] = index; + } + + const data = createDetailedData(createComponentNode({ items, properties }, {})); + + expect(data.component?.viewModel).toContain('"item-49"'); + expect(data.component?.viewModel).not.toContain('"item-50"'); + expect(data.component?.viewModel).toContain('... 2 more item(s) ...'); + expect(data.component?.viewModel).toContain('property49: 49'); + expect(data.component?.viewModel).not.toContain('property50: 50'); + expect(data.component?.viewModel).toContain('... more properties ...'); + }); + it('truncates one serialized component field to its character cap', () => { const data = createDetailedData(createComponentNode('x'.repeat(70_000), {}));