diff --git a/npm_modules/cli/debugger/README.md b/npm_modules/cli/debugger/README.md index 7a3619ae..bcbca836 100644 --- a/npm_modules/cli/debugger/README.md +++ b/npm_modules/cli/debugger/README.md @@ -16,6 +16,8 @@ the CLI package. - `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`: renderer trace and Hermes CPU profile controls. +- `debugger-providers.js`: provider discovery plus read-only Storage and SQL inspector rendering. +- `debugger-settings.js`: published debug-setting discovery, rendering, validation, and mutation requests. - `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. @@ -36,7 +38,10 @@ Important routes: - `/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/input`: validates and forwards bounded input requests to the selected Valdi target. - `/api/performance/trace/*`: start, stop, capture, and export native renderer traces. +- `/api/debugger/providers` and `/api/debugger/providers/request`: discover and query target-owned debugger providers. +- `/api/debugger/settings`: discover and update target-published debug settings. - `/api/performance/profile/*`: list Hermes contexts and capture CPU profiles. - `/api/devtools/target`: matches the inspected Chromium page to the exact configured preview origin and path. - `/api/devtools/snapshot`, `/api/devtools/highlight`, and `/api/devtools/evaluate`: proxy the explicit web debugger bridge contract through loopback CDP. @@ -48,14 +53,44 @@ One-shot captures are limited to 15 seconds so the debugger handler can retain the result before the native recorder's independent safety timeout, and exported JSON can be opened in Perfetto. Native bounds report dropped event counts, and retained timeout/retry results expire after one minute. -Hermes CPU profiling uses the existing inspector transport. 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 depends on the target page explicitly exposing +Hermes CPU profiling uses the existing inspector transport. + +The native and synthetic previews forward capability, query, tap, focus, text, +key, and scroll requests through the selected target's bounded input contract. +Web-renderer inspection uses the first-party bridge exposed as `window.__VALDI_WEB_DEBUGGER__` with `getSnapshot()`, `highlightNode()`, and -`clearHighlight()`. The renderer-side adapter is intentionally outside this -CLI/DevTools core. +`clearHighlight()`. The DevTools panel proxies inspection through the exact +configured loopback Chromium target. + +The Data section discovers target-owned providers through a generic custom +message contract. This slice includes read-only Storage and SQL presentation, +but it does not register either backend. A later thin registration layer can +adapt a bounded Storage snapshot callback or an existing SQL API without +changing this contract. Until then, the UI reports both surfaces as unavailable +instead of synthesizing sample data. Network and key-value integrations are not +part of this slice and remain unavailable unless a future runtime provider is +registered. + +Runtime adapters cross the provider boundary with an already serialized JSON +object document, not an arbitrary object graph. They should call +`createDebuggerProviderOwner(module, 'stable/adapter/module/key')`, register +their provider through that owner, and return +`createDebuggerProviderResult(JSON.stringify(snapshot))` from `handleRequest`. +Core caps action documents at 48 KiB of UTF-8, validates their depth, +collection sizes, strings, and total value count, and then enforces 128 KiB on +the complete serialized custom-response body including metadata. Owners bind +to the creating module object's hot-reload callback automatically, including +webpack modules where `module.path` is absent. Adapters call +`owner.dispose()` only when stopping before a reload. A newer registration for +an existing provider ID permanently invalidates the older registration. This +is the integration point for the later thin Storage/SQL registration layer. +On native runtimes the owner observes `module.path`; the explicit stable key is +replacement identity only. Web runtimes fall back to observing that stable key +because webpack does not provide `module.path`. + +Published settings are application-owned controls registered only in debug +runtimes. Values are limited to declared toggle, select, text, and number +settings; the server does not expose arbitrary runtime property mutation. Detailed debugger snapshots explicitly opt in to component ViewModel and state serialization. That data can be sensitive, is bounded by a per-field and @@ -110,7 +145,8 @@ 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. +settings, plus the active provider and published-settings group. Provider and +settings payloads are cleared when the selected target changes. ## Validation diff --git a/npm_modules/cli/debugger/debugger-actions.js b/npm_modules/cli/debugger/debugger-actions.js index f76a1d15..7c704c79 100644 --- a/npm_modules/cli/debugger/debugger-actions.js +++ b/npm_modules/cli/debugger/debugger-actions.js @@ -30,6 +30,7 @@ function setActiveSection(section) { if (normalized === UI_SECTION && state.autoRefresh && state.source === 'daemon' && isDebuggerAttached()) { window.setTimeout(() => refreshLiveSnapshot(), 0); } + maybeRefreshDebuggerTools(normalized); } function shouldAutoRefresh() { @@ -154,6 +155,7 @@ function detachDebuggerView() { state.manualDetach = true; state.rootSnapshotImage = null; state.rootSnapshotRequestId++; + resetDebuggerToolsForTarget(); stopRuntimeLogStream(); addLog('warn', 'proxy', 'Detached debugger view from live daemon data.'); render(); @@ -173,6 +175,7 @@ async function setTargetPort(port, nextTarget) { state.followLatestTarget = true; state.rootSnapshotImage = null; state.rootSnapshotRequestId++; + resetDebuggerToolsForTarget(); stopRuntimeLogStream(); render(); } @@ -182,7 +185,7 @@ function clearDebuggerLogs() { renderLogs(); } -function applyDebuggerAction(action, params = {}) { +function applyDebuggerAction(action, params = {}, result) { if (action === 'selectNode') { const nodeId = actionString(params, 'id', 'nodeId'); if (nodeId) selectNode(nodeId); @@ -291,6 +294,24 @@ function applyDebuggerAction(action, params = {}) { return; } + if (action === 'refreshDebuggerProviders') { + if (result) applyDebuggerProvidersResult(result, params); + else void refreshDebuggerProviders(); + return; + } + + if (action === 'refreshDebugSettings') { + if (result) applyDebugSettingsResult(result, params); + else void refreshDebugSettings(); + return; + } + + if (action === 'setDebugSetting' || action === 'resetDebugSetting') { + if (result) applyDebugSettingsResult(result, params); + else void refreshDebugSettings({ silent: true }); + return; + } + addLog('warn', 'debugger', `Unknown debugger action: ${action}.`); } @@ -303,10 +324,16 @@ function runCommand(rawCommand) { addLog( 'info', 'console', - 'Commands: help, section , path, issues, select , connect, refresh, reload, status, snapshot, heap, trace, profile, auto on, auto off, clear.', + 'Commands: help, section , data, settings, path, issues, select , connect, refresh, reload, status, snapshot, heap, trace, profile, auto on, auto off, clear.', ); } else if (command.startsWith('section ')) { void requestDebuggerAction('setActiveSection', { section: command.slice('section '.length).trim() }); + } else if (command === 'data') { + void requestDebuggerAction('setActiveSection', { section: 'data' }); + void requestDebuggerAction('refreshDebuggerProviders', getSelectedTargetParams()); + } else if (command === 'settings') { + void requestDebuggerAction('setActiveSection', { section: 'settings' }); + void requestDebuggerAction('refreshDebugSettings', getSelectedTargetParams()); } else if (command === 'path') { const path = getPathToNode(state.selectedNodeId) .map(node => `${node.tag}#${getNodeId(node)}`) diff --git a/npm_modules/cli/debugger/debugger-api.js b/npm_modules/cli/debugger/debugger-api.js index e9c83bc7..4d7c07db 100644 --- a/npm_modules/cli/debugger/debugger-api.js +++ b/npm_modules/cli/debugger/debugger-api.js @@ -142,19 +142,25 @@ function applyDebuggerActionPayload(payload) { const revision = Number(payload?.state?.revision) || 0; if (revision && revision <= state.lastDebuggerRevision) return; if (payload?.action) { - applyDebuggerAction(payload.action, payload.params || {}); + applyDebuggerAction(payload.action, payload.params || {}, payload.result); } if (revision) state.lastDebuggerRevision = revision; } async function requestDebuggerAction(action, params = {}) { - const shouldApplyLocally = !state.debuggerEventsConnected; + const runtimeActions = new Set([ + 'refreshDebuggerProviders', + 'refreshDebugSettings', + 'setDebugSetting', + 'resetDebugSetting', + ]); + const shouldApplyLocally = !state.debuggerEventsConnected && !runtimeActions.has(action); if (shouldApplyLocally) { applyDebuggerAction(action, params); } try { - await apiPost( + const response = await apiPost( '/api/debugger/actions', {}, { @@ -164,11 +170,18 @@ async function requestDebuggerAction(action, params = {}) { }, { timeoutMs: 5000 }, ); + const revision = Number(response?.state?.revision) || 0; + if (response?.result && (!revision || revision > state.lastDebuggerRevision)) { + applyDebuggerAction(action, params, response.result); + if (revision) state.lastDebuggerRevision = revision; + } + return response; } catch (error) { if (!shouldApplyLocally) { applyDebuggerAction(action, params); } addLog('warn', 'debugger', `Debugger action bus unavailable; applied ${action} locally. ${error.message}`); + return null; } } diff --git a/npm_modules/cli/debugger/debugger-bootstrap.js b/npm_modules/cli/debugger/debugger-bootstrap.js index 3f953580..39435334 100644 --- a/npm_modules/cli/debugger/debugger-bootstrap.js +++ b/npm_modules/cli/debugger/debugger-bootstrap.js @@ -29,6 +29,23 @@ document.addEventListener('click', event => { const issue = event.target.closest('[data-issue-node]'); if (issue && issue.dataset.issueNode) void requestDebuggerAction('selectNode', { id: issue.dataset.issueNode }); + const providerTab = event.target.closest('[data-provider-tab]'); + if (providerTab) setActiveProviderTab(providerTab.dataset.providerTab); + + const resetSetting = event.target.closest('[data-reset-setting]'); + if (resetSetting && !resetSetting.disabled) { + void changeDebugSetting('reset', resetSetting.dataset.resetSetting); + } + + if (event.target.closest('#sqlPreviousButton')) { + state.providers.sqlOffset = Math.max(0, state.providers.sqlOffset - state.providers.sqlLimit); + void loadSqlTable(); + } + if (event.target.closest('#sqlNextButton')) { + state.providers.sqlOffset += state.providers.sqlLimit; + void loadSqlTable(); + } + const target = event.target.closest('.target'); if (target) void requestDebuggerAction('selectTarget', { id: target.dataset.targetId }); }); @@ -62,9 +79,17 @@ async function selectTarget(id) { } state.followLatestTarget = false; if (target.clientId && target.contextId) { + resetDebuggerToolsForTarget(); + state.snapshot.target = { + ...state.snapshot.target, + ...target, + state: 'attached', + }; + render(); loadRealSnapshot(target); return; } + resetDebuggerToolsForTarget(); state.snapshot.target = { ...state.snapshot.target, id: target.id, @@ -176,6 +201,47 @@ elements.profileStartButton.addEventListener('click', () => void requestDebugger elements.profileStopButton.addEventListener('click', () => void requestDebuggerAction('stopCpuProfile')); elements.profileCaptureButton.addEventListener('click', () => void requestDebuggerAction('captureCpuProfile')); elements.profileExportButton.addEventListener('click', exportCpuProfile); +elements.providerRefreshButton.addEventListener('click', () => { + void requestDebuggerAction('refreshDebuggerProviders', getSelectedTargetParams()); +}); +elements.settingsRefreshButton.addEventListener('click', () => { + void requestDebuggerAction('refreshDebugSettings', getSelectedTargetParams()); +}); +elements.settingsGroupSelect.addEventListener('change', () => { + state.settings.selectedGroupId = elements.settingsGroupSelect.value || null; + renderDebugSettings(); +}); + +document.addEventListener('change', event => { + if (event.target.id === 'sqlDatabaseSelect') { + state.providers.selectedDatabaseId = event.target.value; + const database = selectedSQLDatabase(); + state.providers.selectedTable = Array.isArray(database?.tables) ? database.tables[0]?.name || null : null; + state.providers.sqlOffset = 0; + state.providers.sqlTable = null; + void loadSqlTable(); + return; + } + if (event.target.id === 'sqlTableSelect') { + state.providers.selectedTable = event.target.value; + state.providers.sqlOffset = 0; + state.providers.sqlTable = null; + void loadSqlTable(); + return; + } + const setting = event.target.closest('[data-setting-id]'); + if (setting) { + const parsed = readDebugSettingInput(setting); + if (parsed.error) { + setting.setCustomValidity?.(parsed.error); + setting.reportValidity?.(); + addLog('warn', 'settings', parsed.error); + return; + } + setting.setCustomValidity?.(''); + void changeDebugSetting('set', setting.dataset.settingId, parsed.value); + } +}); document.getElementById('copyPathButton').addEventListener('click', async () => { const path = getPathToNode(state.selectedNodeId) diff --git a/npm_modules/cli/debugger/debugger-providers.js b/npm_modules/cli/debugger/debugger-providers.js new file mode 100644 index 00000000..aa7bd955 --- /dev/null +++ b/npm_modules/cli/debugger/debugger-providers.js @@ -0,0 +1,325 @@ +// Generic debugger-provider discovery plus Storage and SQL inspector surfaces. +function debuggerTargetKey(target = getSelectedTargetParams()) { + const port = Number(target?.port ?? target?.proxyPort); + const clientId = target?.clientId; + const contextId = target?.contextId; + if (!Number.isInteger(port) || port < 1 || port > 65535 || !clientId || !contextId) return null; + return JSON.stringify([port, String(clientId), String(contextId)]); +} + +function debuggerTargetRequest() { + const selected = getSelectedTargetParams(); + return { + params: { + port: Number(selected.port), + clientId: String(selected.clientId || ''), + contextId: String(selected.contextId || ''), + }, + targetKey: debuggerTargetKey(selected), + }; +} + +function beginDebuggerToolRequest(toolState) { + const target = debuggerTargetRequest(); + toolState.generation += 1; + return { ...target, generation: toolState.generation }; +} + +function debuggerToolRequestIsCurrent(toolState, request, result) { + if (!request || request.generation !== toolState.generation) return false; + if (!request.targetKey || request.targetKey !== debuggerTargetKey()) return false; + const returnedTargetKey = debuggerTargetKey(result?.target); + if (returnedTargetKey !== null && returnedTargetKey !== request.targetKey) return false; + if (result?.handled === true && returnedTargetKey === null) return false; + return true; +} + +function beginDebuggerActionResult(toolState, params, result) { + const requestedTargetKey = debuggerTargetKey(params); + if (!requestedTargetKey || requestedTargetKey !== debuggerTargetKey()) return null; + const returnedTargetKey = debuggerTargetKey(result?.target); + if (returnedTargetKey !== null && returnedTargetKey !== requestedTargetKey) return null; + if (result?.handled === true && returnedTargetKey === null) return null; + toolState.generation += 1; + return { + generation: toolState.generation, + params: { + port: Number(params.port), + clientId: String(params.clientId), + contextId: String(params.contextId), + }, + targetKey: requestedTargetKey, + }; +} + +function providerRegistryData(result) { + return result?.handled === true && result.status === 'handled' && result.data && typeof result.data === 'object' + ? result.data + : null; +} + +function registeredDebuggerProviders() { + return Array.isArray(state.providers.registry?.providers) ? state.providers.registry.providers : []; +} + +function debuggerProviderForKind(kind) { + return registeredDebuggerProviders().find(provider => provider.kind === kind) || null; +} + +function debuggerProviderUnavailableMessage(kind, label) { + if (!hasSelectedLiveTarget()) return `Attach to a live Valdi target to inspect ${label}.`; + if (state.providers.error) return state.providers.error; + const provider = debuggerProviderForKind(kind); + if (provider && provider.available === false) return provider.message || `${label} is unavailable in this target.`; + return `${label} support is not registered by this target runtime.`; +} + +async function requestDebuggerProvider(request, providerId, action, params = {}) { + return apiPost( + '/api/debugger/providers/request', + request.params, + { providerId, action, params }, + { timeoutMs: 12000 }, + ); +} + +function debuggerProviderResponseData(result) { + const envelope = providerRegistryData(result); + if (!envelope) return null; + if (envelope.stale === true) return null; + if (envelope.unavailable === true || envelope.busy === true) { + state.providers.error = envelope.message || 'The debugger provider is unavailable.'; + return null; + } + state.providers.error = null; + return envelope.data && typeof envelope.data === 'object' ? envelope.data : null; +} + +async function loadStorageProvider(options = {}, request = beginDebuggerToolRequest(state.providers)) { + const provider = debuggerProviderForKind('storage'); + if (!provider || provider.available === false) { + if (debuggerToolRequestIsCurrent(state.providers, request)) { + state.providers.storage = null; + renderDebuggerProviders(); + } + return; + } + try { + const result = await requestDebuggerProvider(request, provider.id, 'snapshot'); + if (!debuggerToolRequestIsCurrent(state.providers, request, result)) return; + state.providers.storage = debuggerProviderResponseData(result); + if (!result.handled) state.providers.error = result.message || 'Storage provider request was not handled.'; + } catch (error) { + if (!debuggerToolRequestIsCurrent(state.providers, request)) return; + state.providers.storage = null; + state.providers.error = error.message; + if (!options.silent) addLog('error', 'storage', error.message); + } + if (debuggerToolRequestIsCurrent(state.providers, request)) renderDebuggerProviders(); +} + +async function loadSqlDatabases(options = {}, request = beginDebuggerToolRequest(state.providers)) { + const provider = debuggerProviderForKind('sql'); + if (!provider || provider.available === false) { + if (debuggerToolRequestIsCurrent(state.providers, request)) { + state.providers.sql = null; + state.providers.sqlTable = null; + renderDebuggerProviders(); + } + return; + } + try { + const result = await requestDebuggerProvider(request, provider.id, 'list'); + if (!debuggerToolRequestIsCurrent(state.providers, request, result)) return; + state.providers.sql = debuggerProviderResponseData(result); + const databases = Array.isArray(state.providers.sql?.databases) ? state.providers.sql.databases : []; + const selectedDatabase = databases.find(database => database.id === state.providers.selectedDatabaseId) || databases[0]; + state.providers.selectedDatabaseId = selectedDatabase?.id || null; + const tables = Array.isArray(selectedDatabase?.tables) ? selectedDatabase.tables : []; + const selectedTable = tables.find(table => table.name === state.providers.selectedTable) || tables[0]; + state.providers.selectedTable = selectedTable?.name || null; + state.providers.sqlOffset = 0; + state.providers.sqlTable = null; + if (state.providers.selectedDatabaseId && state.providers.selectedTable) { + await loadSqlTable({ silent: true }, request); + } + } catch (error) { + if (!debuggerToolRequestIsCurrent(state.providers, request)) return; + state.providers.sql = null; + state.providers.sqlTable = null; + state.providers.error = error.message; + if (!options.silent) addLog('error', 'sql', error.message); + } + if (debuggerToolRequestIsCurrent(state.providers, request)) renderDebuggerProviders(); +} + +async function loadSqlTable(options = {}, request = beginDebuggerToolRequest(state.providers)) { + const provider = debuggerProviderForKind('sql'); + if (!provider || !state.providers.selectedDatabaseId || !state.providers.selectedTable) return; + const databaseId = state.providers.selectedDatabaseId; + const table = state.providers.selectedTable; + const offset = state.providers.sqlOffset; + state.providers.sqlLoading = true; + renderDebuggerProviders(); + try { + const result = await requestDebuggerProvider(request, provider.id, 'table', { + databaseId, + table, + limit: state.providers.sqlLimit, + offset, + }); + if (!debuggerToolRequestIsCurrent(state.providers, request, result)) return; + if ( + databaseId !== state.providers.selectedDatabaseId || + table !== state.providers.selectedTable || + offset !== state.providers.sqlOffset + ) return; + state.providers.sqlTable = debuggerProviderResponseData(result); + if (!result.handled) state.providers.error = result.message || 'SQL table request was not handled.'; + } catch (error) { + if (!debuggerToolRequestIsCurrent(state.providers, request)) return; + state.providers.sqlTable = null; + state.providers.error = error.message; + if (!options.silent) addLog('error', 'sql', error.message); + } finally { + if (debuggerToolRequestIsCurrent(state.providers, request)) { + state.providers.sqlLoading = false; + renderDebuggerProviders(); + } + } +} + +async function hydrateDebuggerProviders(options = {}, request = beginDebuggerToolRequest(state.providers)) { + if (state.providers.activeTab === 'storage') await loadStorageProvider(options, request); + else await loadSqlDatabases(options, request); +} + +async function refreshDebuggerProviders(options = {}) { + const request = beginDebuggerToolRequest(state.providers); + state.providers.targetKey = request.targetKey; + state.providers.loading = true; + state.providers.error = null; + state.providers.registry = null; + state.providers.storage = null; + state.providers.sql = null; + state.providers.sqlTable = null; + renderDebuggerProviders(); + if (!request.targetKey || !hasSelectedLiveTarget()) { + if (debuggerToolRequestIsCurrent(state.providers, request)) { + state.providers.loading = false; + state.providers.error = 'Attach to a live Valdi target before checking debugger providers.'; + renderDebuggerProviders(); + } + return; + } + try { + const result = await apiGet('/api/debugger/providers', request.params, { timeoutMs: 7000 }); + if (!debuggerToolRequestIsCurrent(state.providers, request, result)) return; + state.providers.registry = providerRegistryData(result); + if (!result.handled) state.providers.error = result.message || 'The target does not expose debugger providers.'; + if (!options.silent) addLog(result.handled ? 'info' : 'warn', 'data', result.message || 'Refreshed debugger providers.'); + } catch (error) { + if (!debuggerToolRequestIsCurrent(state.providers, request)) return; + state.providers.error = error.message; + if (!options.silent) addLog('error', 'data', error.message); + } finally { + if (debuggerToolRequestIsCurrent(state.providers, request)) state.providers.loading = false; + } + if (!debuggerToolRequestIsCurrent(state.providers, request)) return; + await hydrateDebuggerProviders(options, request); + if (debuggerToolRequestIsCurrent(state.providers, request)) renderDebuggerProviders(); +} + +function applyDebuggerProvidersResult(result, params) { + const request = beginDebuggerActionResult(state.providers, params, result); + if (!request) return false; + state.providers.registry = providerRegistryData(result); + state.providers.error = result?.handled ? null : result?.message || 'The target does not expose debugger providers.'; + state.providers.targetKey = request.targetKey; + void hydrateDebuggerProviders({ silent: true }, request); + renderDebuggerProviders(); + return true; +} + +function renderProviderStatus(provider, unavailableMessage) { + const available = provider?.available === true; + return `
${escapeHtml(available ? provider.message || 'Available' : unavailableMessage)}
`; +} + +function renderStorageEntry(entry) { + const truncated = entry.valueTruncated || entry.keyTruncated; + const metadata = [entry.encoding || 'unknown']; + if (entry.byteLength !== undefined) metadata.push(`${entry.byteLength} bytes`); + if (truncated) metadata.push('truncated'); + return `
${escapeHtml(entry.key || '')}${escapeHtml(metadata.join(' · '))}
${escapeHtml(entry.value ?? '')}
`; +} + +function renderStoragePanel() { + const provider = debuggerProviderForKind('storage'); + const unavailable = debuggerProviderUnavailableMessage('storage', 'Storage'); + if (!provider || provider.available !== true || !state.providers.storage) { + return `${renderProviderStatus(provider, unavailable)}
${escapeHtml(unavailable)}
`; + } + const stores = Array.isArray(state.providers.storage.stores) ? state.providers.storage.stores : []; + if (!stores.length) return `${renderProviderStatus(provider, unavailable)}
The registered Storage provider returned no stores.
`; + return `${renderProviderStatus(provider, unavailable)}
${stores.map(store => { + const entries = Array.isArray(store.entries) ? store.entries : []; + return `
${escapeHtml(store.name || 'Storage')}${escapeHtml(store.scope || 'unknown')} · ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}${store.error ? `
${escapeHtml(store.error)}
` : ''}${entries.length ? entries.map(renderStorageEntry).join('') : '
This store is empty.
'}${store.entriesTruncated ? '
Additional entries were omitted by the debugger snapshot budget.
' : ''}
`; + }).join('')}
`; +} + +function selectedSQLDatabase() { + const databases = Array.isArray(state.providers.sql?.databases) ? state.providers.sql.databases : []; + return databases.find(database => database.id === state.providers.selectedDatabaseId) || null; +} + +function renderSQLTable() { + const data = state.providers.sqlTable; + if (state.providers.sqlLoading) return '
Loading SQL rows…
'; + if (!data) return '
Select a database table to inspect its rows.
'; + const columns = Array.isArray(data.columns) ? data.columns : []; + const rows = Array.isArray(data.rows) ? data.rows : []; + const names = columns.map(column => column.name).filter(Boolean); + const headings = names.map(name => `${escapeHtml(name)}`).join(''); + const body = rows.map(row => `${names.map(name => `${escapeHtml(row?.[name] ?? 'NULL')}`).join('')}`).join(''); + const rowCount = Number(data.rowCount); + const hasNext = rows.length >= state.providers.sqlLimit && (!Number.isFinite(rowCount) || state.providers.sqlOffset + rows.length < rowCount); + return `
${headings}${body}
${rows.length ? '' : '
This table returned no rows.
'}
Offset ${state.providers.sqlOffset}
`; +} + +function renderSqlPanel() { + const provider = debuggerProviderForKind('sql'); + const unavailable = debuggerProviderUnavailableMessage('sql', 'SQL'); + if (!provider || provider.available !== true || !state.providers.sql) { + return `${renderProviderStatus(provider, unavailable)}
${escapeHtml(unavailable)}
`; + } + const databases = Array.isArray(state.providers.sql.databases) ? state.providers.sql.databases : []; + if (!databases.length) return `${renderProviderStatus(provider, unavailable)}
The registered SQL provider returned no databases.
`; + const database = selectedSQLDatabase() || databases[0]; + const tables = Array.isArray(database?.tables) ? database.tables : []; + return `${renderProviderStatus(provider, unavailable)}
${tables.length ? renderSQLTable() : '
This database does not expose inspectable tables.
'}`; +} + +function renderDebuggerProviders() { + if (!elements.providerContent || !elements.providerStatusPill) return; + elements.providerRefreshButton.disabled = state.providers.loading; + elements.providerStatusPill.textContent = state.providers.loading ? 'Loading' : state.providers.error ? 'Unavailable' : state.providers.registry ? 'Ready' : 'Idle'; + elements.providerStatusPill.className = `source-pill ${state.providers.registry && !state.providers.error ? 'live' : ''}`; + document.querySelectorAll('[data-provider-tab]').forEach(button => button.classList.toggle('active', button.dataset.providerTab === state.providers.activeTab)); + if (state.providers.loading && !state.providers.registry) { + elements.providerContent.innerHTML = '
Discovering target debugger providers…
'; + return; + } + const metadataNote = state.providers.registry?.metadataTruncated === true + ? `
Optional provider metadata was omitted to keep discovery within the debugger response budget${Number.isInteger(state.providers.registry.omittedMetadataFields) ? ` (${state.providers.registry.omittedMetadataFields} fields)` : ''}.
` + : ''; + const panel = state.providers.activeTab === 'storage' ? renderStoragePanel() : renderSqlPanel(); + elements.providerContent.innerHTML = metadataNote + panel; +} + +function setActiveProviderTab(tab) { + state.providers.activeTab = tab === 'sql' ? 'sql' : 'storage'; + renderDebuggerProviders(); + if (!state.providers.registry || state.providers.targetKey !== debuggerTargetKey()) void refreshDebuggerProviders({ silent: true }); + else void hydrateDebuggerProviders({ silent: true }); +} diff --git a/npm_modules/cli/debugger/debugger-render.js b/npm_modules/cli/debugger/debugger-render.js index 26037eae..c7b491d4 100644 --- a/npm_modules/cli/debugger/debugger-render.js +++ b/npm_modules/cli/debugger/debugger-render.js @@ -9,6 +9,8 @@ function render() { renderInspector(); renderLogs(); renderPerformance(); + renderDebuggerProviders(); + renderDebugSettings(); } function renderPreview() { diff --git a/npm_modules/cli/debugger/debugger-runtime.js b/npm_modules/cli/debugger/debugger-runtime.js index e7ac6dfa..8800adc4 100644 --- a/npm_modules/cli/debugger/debugger-runtime.js +++ b/npm_modules/cli/debugger/debugger-runtime.js @@ -220,12 +220,14 @@ async function loadRealSnapshot(target, options = {}) { ? { port: target.port || target.proxyPort, clientId: target.clientId, contextId: target.contextId } : getSelectedTargetParams(); const previousSelectedNodeId = state.selectedNodeId; + const previousTargetKey = debuggerTargetKey(); 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); + if (debuggerTargetKey() !== previousTargetKey) resetDebuggerToolsForTarget(); state.snapshot.logs = [...previousLogs, ...(state.snapshot.logs || [])]; trimRuntimeLogs(); state.selectedNodeId = @@ -241,6 +243,7 @@ async function loadRealSnapshot(target, options = {}) { state.lastUpdated = new Date().toLocaleTimeString(); elements.portSelect.value = String(state.snapshot.target.proxyPort || params.port); render(); + maybeRefreshDebuggerTools(state.activeSection); if (!options.silent || !state.rootSnapshotImage) { void captureRootSnapshot(params, options); } diff --git a/npm_modules/cli/debugger/debugger-session.js b/npm_modules/cli/debugger/debugger-session.js index 00dd7956..afa02e22 100644 --- a/npm_modules/cli/debugger/debugger-session.js +++ b/npm_modules/cli/debugger/debugger-session.js @@ -42,6 +42,8 @@ function debuggerSessionPayload() { traceStateUnknown: state.performance.traceStateUnknown, profileDuration: elements.profileDurationInput.value, profileContextId: elements.profileContextSelect.value, + providerTab: state.providers.activeTab, + settingsGroupId: state.settings.selectedGroupId, }; } @@ -151,6 +153,10 @@ function restoreDebuggerSessionState() { state.performance.traceStateUnknown = state.performance.activeTraceTarget !== null || persistedTraceMayBeActive; if (saved.profileDuration !== undefined) elements.profileDurationInput.value = String(saved.profileDuration); if (saved.profileContextId !== undefined) elements.profileContextSelect.value = String(saved.profileContextId); + if (saved.providerTab === 'storage' || saved.providerTab === 'sql') state.providers.activeTab = saved.providerTab; + if (saved.settingsGroupId !== undefined && saved.settingsGroupId !== null) { + state.settings.selectedGroupId = String(saved.settingsGroupId); + } return true; } diff --git a/npm_modules/cli/debugger/debugger-settings.js b/npm_modules/cli/debugger/debugger-settings.js new file mode 100644 index 00000000..92554307 --- /dev/null +++ b/npm_modules/cli/debugger/debugger-settings.js @@ -0,0 +1,207 @@ +// Application-published debug settings UI. +function debugSettingsSnapshotFromResult(result) { + return result?.handled === true && result.status === 'handled' && result.data && typeof result.data === 'object' + ? result.data + : null; +} + +function acceptDebugSettingsResult(result, request) { + if (!debuggerToolRequestIsCurrent(state.settings, request, result)) return false; + const snapshot = debugSettingsSnapshotFromResult(result); + state.settings.snapshot = snapshot; + state.settings.error = snapshot ? null : result?.message || 'The target does not publish debug settings.'; + state.settings.targetKey = request.targetKey; + const groups = Array.isArray(snapshot?.groups) ? snapshot.groups : []; + if (!groups.some(group => group.id === state.settings.selectedGroupId)) { + state.settings.selectedGroupId = groups[0]?.id || null; + } + renderDebugSettings(); + return true; +} + +function applyDebugSettingsResult(result, params) { + const request = beginDebuggerActionResult(state.settings, params, result); + if (!request) return false; + return acceptDebugSettingsResult(result, request); +} + +async function refreshDebugSettings(options = {}) { + const request = beginDebuggerToolRequest(state.settings); + state.settings.loading = true; + state.settings.error = null; + state.settings.targetKey = request.targetKey; + renderDebugSettings(); + if (!request.targetKey || !hasSelectedLiveTarget()) { + if (debuggerToolRequestIsCurrent(state.settings, request)) { + state.settings.loading = false; + state.settings.snapshot = null; + state.settings.error = 'Attach to a live Valdi target to inspect application settings.'; + renderDebugSettings(); + } + return; + } + try { + const result = await apiGet('/api/debugger/settings', request.params, { timeoutMs: 7000 }); + if (!acceptDebugSettingsResult(result, request)) return; + if (!options.silent) addLog(result.handled ? 'info' : 'warn', 'settings', result.message || 'Refreshed settings.'); + } catch (error) { + if (!debuggerToolRequestIsCurrent(state.settings, request)) return; + state.settings.snapshot = null; + state.settings.error = error.message; + if (!options.silent) addLog('error', 'settings', error.message); + } finally { + if (debuggerToolRequestIsCurrent(state.settings, request)) { + state.settings.loading = false; + renderDebugSettings(); + } + } +} + +function selectedDebugSettingsGroup() { + const groups = Array.isArray(state.settings.snapshot?.groups) ? state.settings.snapshot.groups : []; + return groups.find(group => group.id === state.settings.selectedGroupId) || null; +} + +function selectedDebugSetting(settingId) { + const group = selectedDebugSettingsGroup(); + return Array.isArray(group?.settings) ? group.settings.find(setting => setting.id === settingId) || null : null; +} + +function debugSettingOptionIndex(setting, value) { + return Array.isArray(setting.options) ? setting.options.findIndex(option => option.value === value) : -1; +} + +function isDebugSettingPrimitive(value) { + return typeof value === 'boolean' || typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)); +} + +function unsupportedDebugSettingControl(message) { + return `Unsupported ${escapeHtml(message)}`; +} + +function renderDebugSettingControl(setting) { + const attributes = `data-setting-id="${escapeHtml(setting.id)}" data-setting-kind="${escapeHtml(setting.kind)}"`; + if (setting.kind === 'toggle') { + if (typeof setting.value !== 'boolean') return unsupportedDebugSettingControl('toggle value'); + return ``; + } + if (setting.kind === 'select') { + if ( + !Array.isArray(setting.options) || + setting.options.length === 0 || + setting.options.some(option => typeof option?.label !== 'string' || !isDebugSettingPrimitive(option?.value)) + ) { + return unsupportedDebugSettingControl('select declaration'); + } + const selectedIndex = debugSettingOptionIndex(setting, setting.value); + if (selectedIndex < 0) return unsupportedDebugSettingControl('select value'); + const options = setting.options + .map((option, index) => ``) + .join(''); + return ``; + } + if (setting.kind === 'text') { + if (typeof setting.value !== 'string') return unsupportedDebugSettingControl('text value'); + return ``; + } + if (setting.kind === 'number') { + if (typeof setting.value !== 'number' || !Number.isFinite(setting.value)) { + return unsupportedDebugSettingControl('number value'); + } + return ``; + } + return `Unsupported setting kind: ${escapeHtml(setting.kind || 'unknown')}`; +} + +function renderDebugSettings() { + if (!elements.settingsContent || !elements.settingsStatusPill) return; + const snapshot = state.settings.snapshot; + const groups = Array.isArray(snapshot?.groups) ? snapshot.groups : []; + elements.settingsRefreshButton.disabled = state.settings.loading; + elements.settingsStatusPill.textContent = state.settings.loading ? 'Loading' : state.settings.error ? 'Unavailable' : snapshot ? 'Ready' : 'Idle'; + elements.settingsStatusPill.className = `source-pill ${snapshot && !state.settings.error ? 'live' : ''}`; + elements.settingsGroupSelect.innerHTML = groups + .map(group => ``) + .join(''); + elements.settingsGroupSelect.disabled = state.settings.loading || groups.length === 0; + + if (state.settings.loading && !snapshot) { + elements.settingsContent.innerHTML = '
Loading application-published settings…
'; + return; + } + if (state.settings.error) { + elements.settingsContent.innerHTML = `
${escapeHtml(state.settings.error)}
`; + return; + } + const group = selectedDebugSettingsGroup(); + if (!group) { + elements.settingsContent.innerHTML = '
The selected application has not published any debug settings.
'; + return; + } + elements.settingsContent.innerHTML = (group.settings || []) + .map(setting => { + const changed = setting.value !== setting.defaultValue; + return `
${escapeHtml(setting.label)}
${setting.description ? `
${escapeHtml(setting.description)}
` : ''}
${renderDebugSettingControl(setting)}
`; + }) + .join(''); +} + +function readDebugSettingInput(element) { + const setting = selectedDebugSetting(element.dataset.settingId); + if (!setting || setting.kind !== element.dataset.settingKind) return { error: 'The setting changed; refresh and try again.' }; + if (setting.kind === 'toggle') return { value: element.checked === true }; + if (setting.kind === 'select') { + const index = Number(element.value); + if (!Array.isArray(setting.options) || !Number.isInteger(index) || index < 0 || index >= setting.options.length) { + return { error: 'Select a declared option.' }; + } + const value = setting.options[index]?.value; + return isDebugSettingPrimitive(value) ? { value } : { error: 'Select a declared primitive option.' }; + } + if (setting.kind === 'text') return { value: element.value }; + if (setting.kind === 'number') { + if (String(element.value).trim() === '') return { error: 'Enter a finite number.' }; + const value = Number(element.value); + return Number.isFinite(value) ? { value } : { error: 'Enter a finite number.' }; + } + return { error: `Unsupported setting kind: ${String(setting.kind)}` }; +} + +async function changeDebugSetting(action, settingId, value) { + if (!state.settings.selectedGroupId) return; + await requestDebuggerAction(action === 'set' ? 'setDebugSetting' : 'resetDebugSetting', { + ...getSelectedTargetParams(), + groupId: state.settings.selectedGroupId, + settingId, + ...(action === 'set' ? { value } : {}), + }); +} + +function maybeRefreshDebuggerTools(section) { + if (section === 'data' && (!state.providers.registry || state.providers.targetKey !== debuggerTargetKey())) { + void refreshDebuggerProviders({ silent: true }); + } + if (section === 'settings' && (!state.settings.snapshot || state.settings.targetKey !== debuggerTargetKey())) { + void refreshDebugSettings({ silent: true }); + } +} + +function resetDebuggerToolsForTarget() { + state.providers.generation += 1; + state.providers.error = null; + state.providers.loading = false; + state.providers.registry = null; + state.providers.storage = null; + state.providers.sql = null; + state.providers.sqlLoading = false; + state.providers.sqlTable = null; + state.providers.selectedDatabaseId = null; + state.providers.selectedTable = null; + state.providers.sqlOffset = 0; + state.providers.targetKey = null; + state.settings.generation += 1; + state.settings.error = null; + state.settings.loading = false; + state.settings.snapshot = null; + state.settings.targetKey = null; +} diff --git a/npm_modules/cli/debugger/debugger-state.js b/npm_modules/cli/debugger/debugger-state.js index 815770f5..cf280b28 100644 --- a/npm_modules/cli/debugger/debugger-state.js +++ b/npm_modules/cli/debugger/debugger-state.js @@ -68,6 +68,30 @@ const state = { lastProfile: null, profileContexts: [], }, + providers: { + activeTab: 'storage', + error: null, + generation: 0, + loading: false, + registry: null, + selectedDatabaseId: null, + selectedTable: null, + sql: null, + sqlLimit: 50, + sqlLoading: false, + sqlOffset: 0, + sqlTable: null, + storage: null, + targetKey: null, + }, + settings: { + error: null, + generation: 0, + loading: false, + selectedGroupId: null, + snapshot: null, + targetKey: null, + }, }; const elements = { @@ -122,6 +146,13 @@ const elements = { profileExportButton: document.getElementById('profileExportButton'), profileStatusPill: document.getElementById('profileStatusPill'), profileSummary: document.getElementById('profileSummary'), + providerContent: document.getElementById('providerContent'), + providerRefreshButton: document.getElementById('providerRefreshButton'), + providerStatusPill: document.getElementById('providerStatusPill'), + settingsContent: document.getElementById('settingsContent'), + settingsGroupSelect: document.getElementById('settingsGroupSelect'), + settingsRefreshButton: document.getElementById('settingsRefreshButton'), + settingsStatusPill: document.getElementById('settingsStatusPill'), }; function hasSnapshotTree(snapshot = state.snapshot) { diff --git a/npm_modules/cli/debugger/debugger.css b/npm_modules/cli/debugger/debugger.css index e15873fc..6f6bb6fb 100644 --- a/npm_modules/cli/debugger/debugger.css +++ b/npm_modules/cli/debugger/debugger.css @@ -1036,6 +1036,217 @@ video.valdi-html-node { line-height: 1.4; } +.tool-panel { + min-height: 0; + overflow: auto; + padding: 18px; + align-content: start; + gap: 14px; +} + +.section-heading.with-actions { + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 12px; +} + +.tool-actions, +.provider-tabs, +.sql-pagination { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.tool-actions { + justify-content: flex-end; +} + +.tool-actions select { + min-width: 180px; +} + +.provider-tabs { + border-bottom: 1px solid var(--line); +} + +.provider-tabs button { + border-radius: 7px 7px 0 0; + border-bottom-color: transparent; +} + +.provider-tabs button.active { + color: var(--accent-strong); + background: var(--surface-2); + border-color: var(--line); + border-bottom-color: var(--surface-2); +} + +.tool-content { + min-width: 0; + display: grid; + gap: 12px; +} + +.provider-status { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-2); + color: var(--muted); + font-size: 12px; +} + +.provider-status.available { + border-color: var(--good-line); + background: var(--good-bg); + color: var(--text); +} + +.storage-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 10px; +} + +.storage-card, +.data-entry { + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +.storage-card > summary, +.data-entry > summary { + display: flex; + justify-content: space-between; + gap: 10px; + cursor: pointer; + padding: 10px; +} + +.storage-card > summary span, +.data-entry > summary span, +.provider-note { + color: var(--muted); + font-size: 11px; +} + +.storage-card > .data-entry { + margin: 0 10px 8px; + background: var(--surface-2); +} + +.data-entry .codebox { + margin: 0 8px 8px; + max-height: 180px; +} + +.provider-note { + padding: 0 10px 10px; + line-height: 1.45; +} + +.sql-controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +} + +.sql-controls label { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.sql-table-wrap { + overflow: auto; + max-height: 460px; + border: 1px solid var(--line); + border-radius: 8px; +} + +.sql-table { + width: 100%; + border-collapse: collapse; + font-family: var(--mono); + font-size: 11px; +} + +.sql-table th, +.sql-table td { + max-width: 320px; + padding: 7px 9px; + border-bottom: 1px solid var(--line); + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sql-table th { + position: sticky; + top: 0; + background: var(--surface-2); + z-index: 1; +} + +.sql-pagination { + justify-content: flex-end; + color: var(--muted); + font-size: 11px; +} + +.settings-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, auto); + align-items: center; + gap: 16px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +.settings-label { + font-size: 13px; + font-weight: 800; + margin-bottom: 4px; +} + +.settings-control { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.settings-control input[type='text'], +.settings-control input[type='number'], +.settings-control select { + min-width: 180px; + max-width: 300px; +} + +@media (max-width: 760px) { + .section-heading.with-actions, + .settings-row { + grid-template-columns: 1fr; + } + + .tool-actions, + .settings-control { + justify-content: flex-start; + } +} + .section-panel:not(.active) { display: none; } diff --git a/npm_modules/cli/debugger/index.html b/npm_modules/cli/debugger/index.html index e243e89b..e8131c77 100644 --- a/npm_modules/cli/debugger/index.html +++ b/npm_modules/cli/debugger/index.html @@ -64,6 +64,14 @@

Daemon Status

Performance Renderer traces, CPU profiles + + + + +
+ + +
+
+ + +
+
+
+

Settings

+

Change controls explicitly published by this debug application.

+
+
+ + Idle + +
+
+
+
+
@@ -229,6 +270,8 @@

Performance

+ + diff --git a/npm_modules/cli/src/debugger/browserTools.spec.ts b/npm_modules/cli/src/debugger/browserTools.spec.ts new file mode 100644 index 00000000..b184b80f --- /dev/null +++ b/npm_modules/cli/src/debugger/browserTools.spec.ts @@ -0,0 +1,217 @@ +import 'jasmine'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as vm from 'node:vm'; + +interface PendingRequest { + path: string; + resolve: (value: unknown) => void; +} + +interface BrowserHarness { + context: Record; + pending: PendingRequest[]; + selectTarget: (target: Record) => void; +} + +const noop = (): void => {}; + +function target(port: number, clientId: string, contextId: string): Record { + return { clientId, contextId, port, proxyPort: port }; +} + +function handled(targetValue: Record, data: Record): Record { + return { data, handled: true, status: 'handled', target: targetValue }; +} + +function createHarness(): BrowserHarness { + let selectedTarget = target(13_591, 'A', 'context-A'); + const pending: PendingRequest[] = []; + const classList = { toggle: noop }; + const element = (): Record => ({ classList, disabled: false, innerHTML: '', textContent: '' }); + const state = { + attached: true, + providers: { + activeTab: 'storage', + error: null, + generation: 0, + loading: false, + registry: null, + selectedDatabaseId: null, + selectedTable: null, + sql: null, + sqlLimit: 50, + sqlLoading: false, + sqlOffset: 0, + sqlTable: null, + storage: null, + targetKey: null, + }, + settings: { + error: null, + generation: 0, + loading: false, + selectedGroupId: null, + snapshot: null, + targetKey: null, + }, + }; + const context = vm.createContext({ + addLog: noop, + apiGet: (requestPath: string): Promise => + new Promise(resolve => pending.push({ path: requestPath, resolve })), + apiPost: (): Promise => Promise.reject(new Error('Unexpected provider request')), + document: { + querySelectorAll: (): unknown[] => [], + }, + elements: { + providerContent: element(), + providerRefreshButton: element(), + providerStatusPill: element(), + settingsContent: element(), + settingsGroupSelect: element(), + settingsRefreshButton: element(), + settingsStatusPill: element(), + }, + escapeHtml: String, + getSelectedTargetParams: (): Record => selectedTarget, + hasSelectedLiveTarget: (): boolean => true, + requestDebuggerAction: noop, + state, + }) as Record; + const debuggerRoot = path.resolve(process.cwd(), 'debugger'); + vm.runInContext(fs.readFileSync(path.join(debuggerRoot, 'debugger-providers.js'), 'utf8'), context); + vm.runInContext(fs.readFileSync(path.join(debuggerRoot, 'debugger-settings.js'), 'utf8'), context); + return { + context, + pending, + selectTarget: next => { + selectedTarget = next; + }, + }; +} + +function call(context: Record, name: string, ...args: unknown[]): T { + return (context[name] as (...values: unknown[]) => T)(...args); +} + +describe('debugger browser provider and settings tools', () => { + it('discards delayed provider and settings responses from target A after selecting target B', async () => { + const harness = createHarness(); + const targetA = target(13_591, 'A', 'context-A'); + const targetB = target(13_592, 'B', 'context-B'); + + const providerA = call>(harness.context, 'refreshDebuggerProviders', { silent: true }); + expect(harness.pending[0]?.path).toBe('/api/debugger/providers'); + harness.selectTarget(targetB); + call(harness.context, 'resetDebuggerToolsForTarget'); + const providerB = call>(harness.context, 'refreshDebuggerProviders', { silent: true }); + harness.pending[1]?.resolve(handled(targetB, { providers: [], revision: 2 })); + await providerB; + harness.pending[0]?.resolve(handled(targetA, { providers: [{ id: 'stale' }], revision: 1 })); + await providerA; + + const state = harness.context['state'] as { providers: { registry: { revision: number } } }; + expect(state.providers.registry.revision).toBe(2); + expect( + call(harness.context, 'applyDebuggerProvidersResult', handled(targetA, { revision: 3 }), targetA), + ).toBeFalse(); + + const settingsA = call>(harness.context, 'refreshDebugSettings', { silent: true }); + harness.selectTarget(targetA); + call(harness.context, 'resetDebuggerToolsForTarget'); + const settingsFromA = call>(harness.context, 'refreshDebugSettings', { silent: true }); + harness.selectTarget(targetB); + call(harness.context, 'resetDebuggerToolsForTarget'); + const settingsFromB = call>(harness.context, 'refreshDebugSettings', { silent: true }); + const settingsRequests = harness.pending.filter(item => item.path === '/api/debugger/settings'); + settingsRequests[2]?.resolve(handled(targetB, { groups: [], revision: 20 })); + await settingsFromB; + settingsRequests[1]?.resolve(handled(targetA, { groups: [], revision: 10 })); + await settingsFromA; + settingsRequests[0]?.resolve(handled(targetB, { groups: [], revision: 5 })); + await settingsA; + + const settingsState = (harness.context['state'] as { settings: { snapshot: { revision: number } } }).settings; + expect(settingsState.snapshot.revision).toBe(20); + expect( + call(harness.context, 'applyDebugSettingsResult', handled(targetA, { revision: 30 }), targetA), + ).toBeFalse(); + }); + + it('preserves typed select identities and rejects invalid number input locally', () => { + const harness = createHarness(); + const state = harness.context['state'] as { + settings: { selectedGroupId: string; snapshot: Record }; + }; + state.settings.selectedGroupId = 'general'; + state.settings.snapshot = { + groups: [ + { + id: 'general', + label: 'General', + settings: [ + { + id: 'typed', + kind: 'select', + label: 'Typed', + options: [ + { label: 'number', value: 1 }, + { label: 'string', value: '1' }, + { label: 'boolean', value: true }, + ], + value: 1, + }, + { id: 'amount', kind: 'number', label: 'Amount', value: 3 }, + { id: 'future', kind: 'future-kind', label: 'Future', value: 'x' }, + ], + }, + ], + }; + + const readSelect = (value: string): { value: unknown } => + call(harness.context, 'readDebugSettingInput', { + dataset: { settingId: 'typed', settingKind: 'select' }, + value, + }); + expect(readSelect('0').value).toBe(1); + expect(readSelect('1').value).toBe('1'); + expect(readSelect('2').value).toBeTrue(); + expect( + call(harness.context, 'readDebugSettingInput', { + dataset: { settingId: 'amount', settingKind: 'number' }, + value: ' ', + }), + ).toEqual(jasmine.objectContaining({ error: 'Enter a finite number.' })); + expect( + call(harness.context, 'readDebugSettingInput', { + dataset: { settingId: 'amount', settingKind: 'number' }, + value: 'Infinity', + }), + ).toEqual(jasmine.objectContaining({ error: 'Enter a finite number.' })); + expect( + call(harness.context, 'renderDebugSettingControl', { + id: 'future', + kind: 'future-kind', + label: 'Future', + value: 'x', + }), + ).toContain('Unsupported setting kind: future-kind'); + }); + + it('shows truthful unavailable Storage and SQL surfaces without registered providers', () => { + const harness = createHarness(); + const state = harness.context['state'] as { + providers: { activeTab: string; registry: Record }; + }; + state.providers.registry = { providers: [] }; + + expect(call(harness.context, 'renderStoragePanel')).toContain( + 'Storage support is not registered by this target runtime.', + ); + state.providers.activeTab = 'sql'; + expect(call(harness.context, 'renderSqlPanel')).toContain( + 'SQL support is not registered by this target runtime.', + ); + }); +}); diff --git a/npm_modules/cli/src/debugger/server.spec.ts b/npm_modules/cli/src/debugger/server.spec.ts index 5e50ad19..2d72cc9d 100644 --- a/npm_modules/cli/src/debugger/server.spec.ts +++ b/npm_modules/cli/src/debugger/server.spec.ts @@ -41,6 +41,117 @@ interface StreamingHttpRequest { result: Promise; } +interface MockDaemon { + close: () => Promise; + port: number; + requests: Array>; +} + +const TEST_PACKET_MAGIC = Buffer.from([0x33, 0xc6, 0x00, 0x01]); + +function encodeDaemonPacket(payload: object): Buffer { + const body = Buffer.from(JSON.stringify(payload), 'utf8'); + const header = Buffer.alloc(8); + TEST_PACKET_MAGIC.copy(header, 0); + header.writeUInt32LE(body.length, 4); + return Buffer.concat([header, body]); +} + +async function startMockDaemon(customResponseBody?: unknown): Promise { + const sockets = new Set(); + const requests: Array> = []; + let responseId = 0; + const server = net.createServer(socket => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + let buffered = Buffer.alloc(0); + socket.on('data', chunk => { + buffered = Buffer.concat([buffered, chunk]); + while (buffered.length >= 8) { + const bodyLength = buffered.readUInt32LE(4); + if (buffered.length < bodyLength + 8) return; + const packet = JSON.parse(buffered.subarray(8, bodyLength + 8).toString('utf8')) as Record; + buffered = buffered.subarray(bodyLength + 8); + const event = packet['event'] as Record | undefined; + const payload = event?.['payload_from_client'] as Record | undefined; + if (!payload) continue; + const requestBody = JSON.parse(String(payload['payload_string'])) as Record; + requests.push(requestBody); + const type = requestBody['type']; + let body: unknown; + let responseType: number; + if (type === 2) { + responseType = -2; + body = [{ id: 'mock-context', rootComponentName: 'Mock App' }]; + } else { + responseType = -1000; + const custom = requestBody['body'] as Record; + const data = custom['data'] as Record; + if (customResponseBody !== undefined) { + body = customResponseBody; + } else if (data['action'] === 'list') { + body = { + handled: true, + data: { + contractVersion: 1, + providers: [{ available: true, id: 'independent', kind: 'storage', label: 'Independent' }], + revision: 1, + }, + }; + } else { + body = { + handled: true, + data: { + contractVersion: 1, + data: { request: data['request'] }, + registrationToken: 7, + revision: 1, + }, + }; + } + } + socket.write( + encodeDaemonPacket({ + request: { + forward_client_payload: { + client_id: 1, + payload_string: JSON.stringify({ + body, + requestId: requestBody['requestId'], + type: responseType, + }), + }, + request_id: `mock-${(++responseId).toString()}`, + }, + }), + ); + } + }); + socket.write( + encodeDaemonPacket({ + request: { + configure: { application_id: 'mock.app', platform: 'test' }, + request_id: 'configure-1', + }, + }), + ); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (typeof address !== 'object' || address === null) throw new Error('Mock daemon did not bind a TCP port.'); + return { + close: async () => { + sockets.forEach(socket => socket.destroy()); + await closeServer(server); + }, + port: address.port, + requests, + }; +} + const GET_REQUEST_OPTIONS: HttpRequestOptions = { method: 'GET', headers: {}, @@ -188,6 +299,7 @@ describe('debugger server', () => { let assetRoot: string; let debuggerServer: DebuggerServerInfo | undefined; let occupiedPortServer: net.Server | undefined; + let mockDaemon: MockDaemon | undefined; let originalHome: string | undefined; beforeEach(() => { @@ -205,6 +317,10 @@ describe('debugger server', () => { await closeServer(occupiedPortServer); occupiedPortServer = undefined; } + if (mockDaemon) { + await mockDaemon.close(); + mockDaemon = undefined; + } if (originalHome === undefined) { delete process.env['HOME']; } else { @@ -745,6 +861,268 @@ describe('debugger server', () => { expect(responseBody.state.autoRefresh).toBeTrue(); }); + it('advertises debugger provider and published settings actions', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + + const result = await request(new URL('/api/debugger/state', debuggerServer.url).toString(), GET_REQUEST_OPTIONS); + const responseBody = JSON.parse(result.body) as { capabilities: { actions: string[] } }; + + expect(result.statusCode).toBe(200); + expect(responseBody.capabilities.actions).toContain('refreshDebuggerProviders'); + expect(responseBody.capabilities.actions).toContain('refreshDebugSettings'); + expect(responseBody.capabilities.actions).toContain('setDebugSetting'); + expect(responseBody.capabilities.actions).toContain('resetDebugSetting'); + }); + + it('reports debugger providers and settings as unavailable when no target backend exists', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + const unavailableTargetPort = await getFreePort(); + + const providers = await request( + new URL( + `/api/debugger/providers?port=${unavailableTargetPort}&clientId=1&contextId=test`, + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + const settings = await request( + new URL( + `/api/debugger/settings?port=${unavailableTargetPort}&clientId=1&contextId=test`, + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + const providersBody = JSON.parse(providers.body) as { + handled: boolean; + identifier: string; + target: unknown; + data: Record; + error: string | null; + status: string; + }; + const settingsBody = JSON.parse(settings.body) as typeof providersBody; + + expect(providers.statusCode).toBe(200); + expect(providersBody.handled).toBeFalse(); + expect(providersBody.identifier).toBe('ValdiDebuggerProviders'); + expect(providersBody.target).toBeNull(); + expect(providersBody.data).toEqual({}); + expect(providersBody.error).not.toBeNull(); + expect(providersBody.status).toBe('unavailable'); + expect(settings.statusCode).toBe(200); + expect(settingsBody.handled).toBeFalse(); + expect(settingsBody.identifier).toBe('ValdiDebuggerSettings'); + expect(settingsBody.target).toBeNull(); + expect(settingsBody.data).toEqual({}); + expect(settingsBody.error).not.toBeNull(); + expect(settingsBody.status).toBe('unavailable'); + }); + + it('lists and dispatches an independently registered generic provider through the daemon wire contract', async () => { + mockDaemon = await startMockDaemon(); + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + const targetQuery = `port=${mockDaemon.port.toString()}&clientId=1&contextId=mock-context`; + + const list = await request( + new URL(`/api/debugger/providers?${targetQuery}`, debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + const dispatch = await request( + new URL(`/api/debugger/providers/request?${targetQuery}`, debuggerServer.url).toString(), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'snapshot', + params: { action: 'cannot-override', limit: 25 }, + providerId: 'independent', + }), + }, + ); + const listBody = JSON.parse(list.body) as { + data: { providers: Array<{ id: string }> }; + status: string; + }; + const dispatchBody = JSON.parse(dispatch.body) as { + data: { data: { request: { action: string; limit: number } } }; + status: string; + }; + + expect(list.statusCode).toBe(200); + expect(listBody.status).toBe('handled'); + expect(listBody.data.providers).toEqual([jasmine.objectContaining({ id: 'independent' })]); + expect(dispatch.statusCode).toBe(200); + expect(dispatchBody.status).toBe('handled'); + expect(dispatchBody.data.data.request).toEqual({ action: 'snapshot', limit: 25 }); + expect(mockDaemon.requests.filter(item => item['type'] === 1000).length).toBe(2); + }); + + it('rejects unbounded or ambiguous generic provider request fields before daemon dispatch', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + const targetQuery = 'port=13591&clientId=1&contextId=mock-context'; + const oversizedAction = await request( + new URL(`/api/debugger/providers/request?${targetQuery}`, debuggerServer.url).toString(), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'x'.repeat(129), params: {}, providerId: 'independent' }), + }, + ); + const tooManyParams = await request( + new URL(`/api/debugger/providers/request?${targetQuery}`, debuggerServer.url).toString(), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'snapshot', + params: Object.fromEntries(Array.from({ length: 33 }, (_, index) => [`key${index.toString()}`, index])), + providerId: 'independent', + }), + }, + ); + const invalidTarget = await request( + new URL('/api/debugger/providers?port=65536&clientId=1&contextId=mock-context', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + + expect(oversizedAction.statusCode).toBe(400); + expect(tooManyParams.statusCode).toBe(400); + expect(invalidTarget.statusCode).toBe(400); + }); + + it('distinguishes a malformed custom response from an unavailable target', async () => { + mockDaemon = await startMockDaemon({ handled: 'yes', data: {} }); + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + const result = await request( + new URL( + `/api/debugger/providers?port=${mockDaemon.port.toString()}&clientId=1&contextId=mock-context`, + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + const body = JSON.parse(result.body) as { error: string; handled: boolean; status: string }; + + expect(result.statusCode).toBe(200); + expect(body.handled).toBeFalse(); + expect(body.status).toBe('protocol-error'); + expect(body.error).toContain('boolean handled field'); + }); + + it('reports a truthful unsupported status when the target declines the generic contract', async () => { + mockDaemon = await startMockDaemon({ handled: false }); + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + const result = await request( + new URL( + `/api/debugger/providers?port=${mockDaemon.port.toString()}&clientId=1&contextId=mock-context`, + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + const body = JSON.parse(result.body) as { + data: Record; + handled: boolean; + status: string; + target: Record; + }; + + expect(result.statusCode).toBe(200); + expect(body.handled).toBeFalse(); + expect(body.status).toBe('unsupported'); + expect(body.data).toEqual({}); + expect(body.target).toEqual(jasmine.objectContaining({ clientId: '1', contextId: 'mock-context' })); + }); + + it('rejects malformed debugger provider and published settings requests', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + + const providerRequest = await request(new URL('/api/debugger/providers/request', debuggerServer.url).toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ providerId: 'storage' }), + }); + const settingsRequest = await request(new URL('/api/debugger/settings', debuggerServer.url).toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'writeArbitraryData' }), + }); + + expect(providerRequest.statusCode).toBe(400); + expect((JSON.parse(providerRequest.body) as { error: string }).error).toContain('action must be'); + expect(settingsRequest.statusCode).toBe(400); + expect((JSON.parse(settingsRequest.body) as { error: string }).error).toContain('at most 16 characters'); + }); + + it('enforces provider HTTP methods and shared JSON body status codes', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + }); + const targetQuery = 'port=13591&clientId=1&contextId=test'; + const listPost = await request(new URL(`/api/debugger/providers?${targetQuery}`, debuggerServer.url).toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + const malformed = await request( + new URL(`/api/debugger/providers/request?${targetQuery}`, debuggerServer.url).toString(), + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{not-json}' }, + ); + const nonObject = await request( + new URL(`/api/debugger/providers/request?${targetQuery}`, debuggerServer.url).toString(), + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '[]' }, + ); + const oversized = await request( + new URL(`/api/debugger/providers/request?${targetQuery}`, debuggerServer.url).toString(), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: `{"value":"${'x'.repeat(1024 * 1024)}"}`, + }, + ); + + expect(listPost.statusCode).toBe(405); + expect(malformed.statusCode).toBe(400); + expect(nonObject.statusCode).toBe(400); + expect(oversized.statusCode).toBe(413); + }); + it('rejects unknown debugger actions and invalid ports', async () => { debuggerServer = await startDebuggerServer({ assetRoot, @@ -764,11 +1142,23 @@ describe('debugger server', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'setPort', params: { port: 70_000 } }), }); + const coercedPort = await request(actionsUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'setPort', params: { port: '13591' } }), + }); + const invalidParams = await request(actionsUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'refreshSnapshot', params: [] }), + }); 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'); + expect(coercedPort.statusCode).toBe(400); + expect(invalidParams.statusCode).toBe(400); }); it('rejects non-integer input element identifiers before connecting to a daemon', async () => { @@ -877,7 +1267,7 @@ describe('debugger server', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ padding: 'a'.repeat(1024 * 1024) }), }); - expect(oversizedBody.statusCode).toBe(400); + expect(oversizedBody.statusCode).toBe(413); expect((JSON.parse(oversizedBody.body) as { error: string }).error).toBe('Request body is too large.'); }); diff --git a/npm_modules/cli/src/debugger/server.ts b/npm_modules/cli/src/debugger/server.ts index 44455c2e..eb9e4911 100644 --- a/npm_modules/cli/src/debugger/server.ts +++ b/npm_modules/cli/src/debugger/server.ts @@ -9,6 +9,7 @@ import { TextDecoder } from 'node:util'; import { type DaemonConnectedClient, type DaemonConnection, + DaemonProtocolError, MOBILE_PORT, type RemoteContext, STANDALONE_PORT, @@ -59,6 +60,8 @@ const TRACE_CAPTURE_TARGET_STRING_KEYS = [ 'contextId', 'applicationId', ] as const; +const DEBUGGER_PROVIDERS_IDENTIFIER = 'ValdiDebuggerProviders'; +const DEBUG_SETTINGS_IDENTIFIER = 'ValdiDebuggerSettings'; const MIME_TYPES: Record = { '.html': 'text/html; charset=utf-8', @@ -212,6 +215,16 @@ interface WebPreviewDebuggerTarget { sessionId: string; } +interface TargetCustomRequestResult { + handled: boolean; + identifier: string; + status: 'handled' | 'protocol-error' | 'unavailable' | 'unsupported'; + target: Record | null; + data: Record; + error: string | null; + message: string | null; +} + class ApiRequestError extends Error { readonly statusCode: number; @@ -257,6 +270,10 @@ const debuggerActions = [ 'startCpuProfile', 'stopCpuProfile', 'captureCpuProfile', + 'refreshDebuggerProviders', + 'refreshDebugSettings', + 'setDebugSetting', + 'resetDebugSetting', ]; let devRevision = 0; let debuggerEventRevision = 0; @@ -951,7 +968,7 @@ async function readJsonBody(request: IncomingMessage): Promise 1024 * 1024) { - throw new ApiRequestError(400, 'Request body is too large.'); + throw new ApiRequestError(413, 'Request body is too large.'); } chunks.push(buffer); } @@ -1013,23 +1030,99 @@ function readBodyString(body: Record, key: string): string | un 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 { +function readDebuggerExactString(record: Record, key: string, maximumLength: number): string; +function readDebuggerExactString( + record: Record, + key: string, + maximumLength: number, + required: true, +): string; +function readDebuggerExactString( + record: Record, + key: string, + maximumLength: number, + required: false, +): string | undefined; +function readDebuggerExactString( + record: Record, + key: string, + maximumLength: number, + required = true, +): string | undefined { const value = record[key]; - if (value === undefined || value === null || value === '') return undefined; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; + if (value === undefined && !required) return undefined; + if (typeof value !== 'string' || value.trim().length === 0 || value.length > maximumLength) { + throw new ApiRequestError( + 400, + `${key} must be a non-empty string of at most ${maximumLength.toString()} characters.`, + ); + } + return value; +} + +function readDebuggerBoundedRecord( + record: Record, + key: string, + maximumKeys: number, + maximumBytes: number, +): Record { + const value = record[key]; + if (value === undefined) return {}; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new ApiRequestError(400, `${key} must be an object.`); + } + const output = value as Record; + if (Object.keys(output).length > maximumKeys || Buffer.byteLength(JSON.stringify(output), 'utf8') > maximumBytes) { + throw new ApiRequestError(400, `${key} exceeds debugger request bounds.`); + } + return output; +} + +function readDebuggerPort(value: unknown, key = 'port'): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65_535) { + throw new ApiRequestError(400, `${key} must be an integer between 1 and 65535.`); + } + return value; +} + +function readDebuggerTargetSearchParams(record: Record): URLSearchParams { + const searchParams = new URLSearchParams(); + searchParams.set('port', readDebuggerPort(record['port']).toString()); + searchParams.set('clientId', readDebuggerExactString(record, 'clientId', 256)); + searchParams.set('contextId', readDebuggerExactString(record, 'contextId', 256)); + return searchParams; +} + +function validateDebuggerSearchParams(searchParams: URLSearchParams): URLSearchParams { + const rawPort = searchParams.get('port'); + if (rawPort === null || !/^\d{1,5}$/.test(rawPort)) { + throw new ApiRequestError(400, 'port must be an integer between 1 and 65535.'); + } + const record: Record = { + port: Number(rawPort), + clientId: searchParams.get('clientId'), + contextId: searchParams.get('contextId'), + }; + return readDebuggerTargetSearchParams(record); +} + +function readDebuggerSettingValue(value: unknown): boolean | number | string { + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new ApiRequestError(400, 'Debug setting numbers must be finite.'); + return value; + } + if (typeof value === 'string') { + if (value.length > 4096) throw new ApiRequestError(400, 'Debug setting strings cannot exceed 4096 characters.'); + return value; + } + if (typeof value === 'boolean') return value; + throw new ApiRequestError(400, 'Debug setting values must be boolean, number, or string primitives.'); } function readRecordBoolean(record: Record, key: string): boolean | undefined { @@ -1599,10 +1692,7 @@ function applyDebuggerUiAction(action: string, params: Record, 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.'); - } + const port = readDebuggerPort(nextParams['port'], 'setPort port'); debuggerUiState.port = port; break; @@ -1635,18 +1725,21 @@ function applyDebuggerUiAction(action: string, params: Record, async function handleDebuggerAction(request: IncomingMessage): Promise> { const body = await readJsonBody(request); - const action = readBodyString(body, 'action') ?? readBodyString(body, 'type'); - if (!action) { + const action = + readDebuggerExactString(body, 'action', 128, false) ?? readDebuggerExactString(body, 'type', 128, false); + if (action === undefined) { 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 params = readDebuggerBoundedRecord(body, 'params', 64, 128 * 1024); + const source = readDebuggerExactString(body, 'source', 128, false) ?? 'agent'; + const result = await executeDebuggerRuntimeAction(action, params); const record = applyDebuggerUiAction(action, params, source); const payload = { ...record, + ...(result === undefined ? {} : { result }), state: cloneDebuggerUiState(), }; broadcastDebuggerEvent('debugger-action', payload); @@ -1657,6 +1750,30 @@ async function handleDebuggerAction(request: IncomingMessage): Promise, +): Promise { + if (action === 'refreshDebuggerProviders') { + return await inspectDebuggerProviders(readDebuggerTargetSearchParams(params)); + } + if (action === 'refreshDebugSettings') { + return await inspectDebugSettings(readDebuggerTargetSearchParams(params), { action: 'list' }); + } + if (action === 'setDebugSetting' || action === 'resetDebugSetting') { + const searchParams = readDebuggerTargetSearchParams(params); + const groupId = readDebuggerExactString(params, 'groupId', 128); + const settingId = readDebuggerExactString(params, 'settingId', 128); + return await inspectDebugSettings(searchParams, { + action: action === 'setDebugSetting' ? 'set' : 'reset', + groupId, + settingId, + ...(action === 'setDebugSetting' ? { value: readDebuggerSettingValue(params['value']) } : {}), + }); + } + return undefined; +} + async function streamRuntimeLogs( request: IncomingMessage, response: ServerResponse, @@ -1917,6 +2034,7 @@ function createDaemonTargetPayload( platform: client.platform || portName(port), transport: `daemon:${port}`, state: 'attached', + proxyPort: port, port, clientId: client.client_id, contextId: context.id, @@ -1924,6 +2042,102 @@ function createDaemonTargetPayload( }; } +async function sendTargetCustomRequest( + searchParams: URLSearchParams, + identifier: string, + data: Record, + timeoutMs: number, +): Promise { + const validatedSearchParams = validateDebuggerSearchParams(searchParams); + const port = Number(validatedSearchParams.get('port')); + try { + return await withConnection(port, async conn => { + const { client, context } = await resolveTarget(validatedSearchParams, conn); + const target = createDaemonTargetPayload(port, client, context); + const customBody = await conn.customRequest( + client.client_id, + identifier, + { ...data, contextId: context.id }, + timeoutMs, + ); + const handled = customBody.handled; + return { + handled, + identifier, + status: handled ? 'handled' : 'unsupported', + target, + data: customBody.data ?? {}, + error: null, + message: handled ? null : `The target runtime did not handle ${identifier}.`, + }; + }); + } catch (error) { + const payload = clientErrorPayload(error); + return { + handled: false, + identifier, + status: error instanceof DaemonProtocolError ? 'protocol-error' : 'unavailable', + target: null, + data: {}, + error: payload.error, + message: payload.error, + }; + } +} + +async function inspectDebuggerProviders(searchParams: URLSearchParams): Promise { + return await sendTargetCustomRequest(searchParams, DEBUGGER_PROVIDERS_IDENTIFIER, { action: 'list' }, 5000); +} + +async function requestDebuggerProvider( + request: IncomingMessage, + searchParams: URLSearchParams, +): Promise { + if (request.method !== 'POST') throw new ApiRequestError(405, 'Debugger provider requests require POST.'); + const body = await readJsonBody(request); + const providerId = readDebuggerExactString(body, 'providerId', 128); + const action = readDebuggerExactString(body, 'action', 128); + const params = readDebuggerBoundedRecord(body, 'params', 32, 64 * 1024); + return await sendTargetCustomRequest( + searchParams, + DEBUGGER_PROVIDERS_IDENTIFIER, + { + action: 'request', + providerId, + request: { ...params, action }, + }, + 10_000, + ); +} + +async function inspectDebugSettings( + searchParams: URLSearchParams, + data: Record, +): Promise { + return await sendTargetCustomRequest(searchParams, DEBUG_SETTINGS_IDENTIFIER, data, 5000); +} + +async function handleDebugSettings( + request: IncomingMessage, + searchParams: URLSearchParams, +): Promise { + if (request.method === 'GET') return await inspectDebugSettings(searchParams, { action: 'list' }); + if (request.method !== 'POST') throw new ApiRequestError(405, 'Debug settings support GET and POST only.'); + const body = await readJsonBody(request); + const action = readDebuggerExactString(body, 'action', 16); + if (action !== 'set' && action !== 'reset') { + throw new ApiRequestError(400, "Debug settings action must be 'set' or 'reset'."); + } + const groupId = readDebuggerExactString(body, 'groupId', 128); + const settingId = readDebuggerExactString(body, 'settingId', 128); + return await inspectDebugSettings(searchParams, { + action, + groupId, + settingId, + ...(action === 'set' ? { value: readDebuggerSettingValue(body['value']) } : {}), + }); +} + function flattenTargets( port: number, clients: ClientWithContexts[], @@ -2770,6 +2984,22 @@ async function handleApi(request: IncomingMessage, response: ServerResponse, url return; } + if (url.pathname === '/api/debugger/providers') { + if (request.method !== 'GET') throw new ApiRequestError(405, 'Debugger provider discovery requires GET.'); + sendJson(response, 200, await inspectDebuggerProviders(url.searchParams)); + return; + } + + if (url.pathname === '/api/debugger/providers/request') { + sendJson(response, 200, await requestDebuggerProvider(request, url.searchParams)); + return; + } + + if (url.pathname === '/api/debugger/settings') { + sendJson(response, 200, await handleDebugSettings(request, url.searchParams)); + return; + } + if (url.pathname === '/api/performance/profile/status') { sendJson(response, 200, profileStatusPayload()); return; diff --git a/npm_modules/cli/src/utils/daemonClient.spec.ts b/npm_modules/cli/src/utils/daemonClient.spec.ts index 0e45cf2c..3267c774 100644 --- a/npm_modules/cli/src/utils/daemonClient.spec.ts +++ b/npm_modules/cli/src/utils/daemonClient.spec.ts @@ -4,6 +4,7 @@ import type { Socket } from 'node:net'; import { DaemonConnection, DaemonMsgType, + DaemonProtocolError, MAX_DAEMON_INNER_PAYLOAD_BYTES, MAX_DAEMON_PACKET_PAYLOAD_BYTES, MAX_DAEMON_TRACE_PAYLOAD_BYTES, @@ -21,8 +22,16 @@ function encodeTestPacket(payload: object): Buffer { // net.Socket uses EventEmitter semantics, which this protocol test mirrors. // eslint-disable-next-line unicorn/prefer-event-target -class ErrorResponseSocket extends EventEmitter { +class TestResponseSocket extends EventEmitter { readonly remotePort = 13_591; + readonly requests: Array> = []; + + constructor( + private readonly responseType: number, + private readonly responseBody: unknown, + ) { + super(); + } write(data: Buffer, callback?: (error?: Error) => void): boolean { const packet = JSON.parse(data.subarray(8).toString('utf8')) as Record; @@ -30,14 +39,15 @@ class ErrorResponseSocket extends EventEmitter { const payloadFromClient = event?.['payload_from_client'] as Record | undefined; if (payloadFromClient) { const request = JSON.parse(String(payloadFromClient['payload_string'])) as Record; + this.requests.push(request); const errorResponse = { request: { forward_client_payload: { client_id: 1, payload_string: JSON.stringify({ - type: -1, + type: this.responseType, requestId: request['requestId'], - body: { message: 'Heap dump failed.' }, + body: this.responseBody, }), }, request_id: 'device-response-1', @@ -198,7 +208,7 @@ function makeTraceStopResponse(trace: Record): (messageType: nu describe('DaemonConnection', () => { it('surfaces runtime error responses from debugger requests', async () => { - const socket = new ErrorResponseSocket(); + const socket = new TestResponseSocket(-1, { message: 'Heap dump failed.' }); const connection = new DaemonConnection(socket as unknown as Socket); try { @@ -230,6 +240,21 @@ describe('DaemonConnection', () => { } }); + it('accepts only well-formed handled custom responses', async () => { + const socket = new TestResponseSocket(-1000, { handled: true, data: { providers: [] } }); + const connection = new DaemonConnection(socket as unknown as Socket); + + try { + await expectAsync(connection.customRequest('1', 'ValdiDebuggerProviders', { action: 'list' })).toBeResolvedTo({ + handled: true, + data: { providers: [] }, + }); + expect(socket.requests[0]?.['type']).toBe(1000); + } finally { + connection.close(); + } + }); + it('routes renderer trace status, start, and stop through the runtime protocol', async () => { const socket = new TraceResponseSocket(); const connection = new DaemonConnection(socket as unknown as Socket); @@ -259,6 +284,18 @@ describe('DaemonConnection', () => { } }); + it('accepts a well-formed unhandled custom response without fabricated data', async () => { + const socket = new TestResponseSocket(-1000, { handled: false }); + const connection = new DaemonConnection(socket as unknown as Socket); + try { + await expectAsync(connection.customRequest('1', 'ValdiDebuggerProviders', { action: 'list' })).toBeResolvedTo({ + handled: false, + }); + } finally { + connection.close(); + } + }); + it('rejects a trace response with the wrong discriminator', async () => { const socket = new TraceResponseSocket(() => ({ type: DaemonMsgType.PERFORMANCE_TRACE_STOP_RESPONSE, @@ -346,6 +383,44 @@ describe('DaemonConnection', () => { } }); + [ + { body: { handled: true, data: {} }, label: 'response type', type: -2 }, + { body: null, label: 'response body', type: -1000 }, + { body: { handled: 'yes', data: {} }, label: 'handled field', type: -1000 }, + { body: { handled: true, data: [] }, label: 'handled response data', type: -1000 }, + { body: { handled: false, data: 'invalid' }, label: 'optional response data', type: -1000 }, + { body: { handled: true, data: { value: 'x'.repeat(128 * 1024) } }, label: 'oversized response data', type: -1000 }, + ].forEach(testCase => { + it(`rejects malformed custom ${testCase.label}`, async () => { + const socket = new TestResponseSocket(testCase.type, testCase.body); + const connection = new DaemonConnection(socket as unknown as Socket); + try { + await expectAsync( + connection.customRequest('1', 'ValdiDebuggerProviders', { action: 'list' }), + ).toBeRejectedWithError(DaemonProtocolError); + } finally { + connection.close(); + } + }); + }); + + it('rejects unbounded identifiers and request data before transport', async () => { + const socket = new TestResponseSocket(-1000, { handled: true, data: {} }); + const connection = new DaemonConnection(socket as unknown as Socket); + try { + await expectAsync(connection.customRequest('1', 'x'.repeat(129), {})).toBeRejectedWithError( + DaemonProtocolError, + /1 to 128/, + ); + await expectAsync( + connection.customRequest('1', 'bounded', { value: 'x'.repeat(128 * 1024) }), + ).toBeRejectedWithError(DaemonProtocolError, /exceeds 128 KiB/); + expect(socket.requests).toEqual([]); + } finally { + connection.close(); + } + }); + it('accepts a valid heap response larger than the trace-specific payload limit', async () => { const heapDumpJSON = 'x'.repeat(MAX_DAEMON_TRACE_PAYLOAD_BYTES + 1024); const socket = new TraceResponseSocket(messageType => ({ diff --git a/npm_modules/cli/src/utils/daemonClient.ts b/npm_modules/cli/src/utils/daemonClient.ts index 8f00b88b..e8b2a568 100644 --- a/npm_modules/cli/src/utils/daemonClient.ts +++ b/npm_modules/cli/src/utils/daemonClient.ts @@ -126,6 +126,18 @@ export interface DaemonPerformanceTraceStopBody extends DaemonPerformanceTraceSt timedOut: boolean; } +export interface CustomRequestResponse { + data?: Record; + handled: boolean; +} + +export class DaemonProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = 'DaemonProtocolError'; + } +} + // ─── Packet encoding ───────────────────────────────────────────────────────── const MAGIC = Buffer.from([0x33, 0xc6, 0x00, 0x01]); @@ -308,16 +320,6 @@ export class DaemonConnection { socket.on('error', err => this.rejectAllPending(err)); } - async customRequest( - clientId: string, - identifier: string, - data: Record, - timeoutMs: number, - ): Promise> { - const resp = await this.forwardAndWait(clientId, DaemonMsgType.CUSTOM_REQUEST, { identifier, data }, timeoutMs); - return (resp['body'] ?? {}) as Record; - } - private rejectAllPending(err: Error): void { this.configureReady?.reject(err); this.configureReady = null; @@ -615,9 +617,11 @@ export class DaemonConnection { throw new Error(message === undefined ? 'The Valdi runtime rejected the debugger request.' : String(message)); } if (response['type'] !== -msgType) { - throw new Error( - `The Valdi runtime returned debugger response type ${String(response['type'])}; expected ${-msgType}.`, - ); + const message = `The Valdi runtime returned debugger response type ${String(response['type'])}; expected ${-msgType}.`; + if (msgType === DaemonMsgType.CUSTOM_REQUEST) { + throw new DaemonProtocolError(message); + } + throw new Error(message); } return response; } @@ -675,6 +679,73 @@ export class DaemonConnection { return validatePerformanceTraceStopBody(resp['body']); } + // eslint-disable-next-line @typescript-eslint/member-ordering -- follows the existing public daemon operation grouping + async customRequest( + clientId: string, + identifier: string, + data: Record, + timeoutMs = 15_000, + ): Promise { + if (typeof identifier !== 'string' || identifier.trim().length === 0 || identifier.length > 128) { + throw new DaemonProtocolError('Custom debugger request identifiers must contain 1 to 128 characters.'); + } + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + throw new DaemonProtocolError('Custom debugger request data must be an object.'); + } + let serializedData: string; + try { + serializedData = JSON.stringify(data); + } catch { + throw new DaemonProtocolError('Custom debugger request data must be JSON serializable.'); + } + if (typeof serializedData !== 'string') { + throw new DaemonProtocolError('Custom debugger request data must be JSON serializable.'); + } + if (Buffer.byteLength(serializedData, 'utf8') > 128 * 1024) { + throw new DaemonProtocolError('Custom debugger request data exceeds 128 KiB.'); + } + const resp = await this.forwardAndWait(clientId, DaemonMsgType.CUSTOM_REQUEST, { identifier, data }, timeoutMs); + const body = resp['body']; + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + throw new DaemonProtocolError('The Valdi runtime returned a non-object custom debugger response body.'); + } + const response = body as Record; + if (typeof response['handled'] !== 'boolean') { + throw new DaemonProtocolError('The Valdi runtime custom debugger response requires a boolean handled field.'); + } + const responseData = response['data']; + if ( + response['handled'] === true && + (typeof responseData !== 'object' || responseData === null || Array.isArray(responseData)) + ) { + throw new DaemonProtocolError('Handled custom debugger responses require object data.'); + } + if ( + responseData !== undefined && + (typeof responseData !== 'object' || responseData === null || Array.isArray(responseData)) + ) { + throw new DaemonProtocolError('Custom debugger response data must be an object when present.'); + } + if (responseData !== undefined) { + let serializedResponseData: string; + try { + serializedResponseData = JSON.stringify(responseData); + } catch { + throw new DaemonProtocolError('Custom debugger response data must be JSON serializable.'); + } + if (typeof serializedResponseData !== 'string') { + throw new DaemonProtocolError('Custom debugger response data must be JSON serializable.'); + } + if (Buffer.byteLength(serializedResponseData, 'utf8') > 128 * 1024) { + throw new DaemonProtocolError('Custom debugger response data exceeds 128 KiB.'); + } + } + return { + handled: response['handled'], + ...(responseData === undefined ? {} : { data: responseData as Record }), + }; + } + close(): void { this.socket.destroy(); } diff --git a/src/valdi_modules/src/valdi/valdi_core/src/debugging/DebuggerProvider.ts b/src/valdi_modules/src/valdi/valdi_core/src/debugging/DebuggerProvider.ts new file mode 100644 index 00000000..dc25fdbb --- /dev/null +++ b/src/valdi_modules/src/valdi/valdi_core/src/debugging/DebuggerProvider.ts @@ -0,0 +1,918 @@ +import { jsx } from '../JSXBootstrap'; +import { getModuleLoader } from '../ModuleLoaderGlobal'; +import type { ValdiRuntime } from '../ValdiRuntime'; +import type { CustomMessageHandler } from './CustomMessageHandler'; + +declare const module: { readonly path?: string }; +declare const runtime: ValdiRuntime; + +const DEBUGGER_PROVIDERS_IDENTIFIER = 'ValdiDebuggerProviders'; +const DEBUGGER_PROVIDERS_CONTRACT_VERSION = 1; +const MAX_DEBUGGER_CUSTOM_RESPONSE_BYTES = 128 * 1024; +const MAX_DEBUGGER_ACTION_JSON_BYTES = 48 * 1024; +const MAX_DEBUGGER_JSON_DEPTH = 8; +const MAX_DEBUGGER_JSON_VALUES = 10_000; +const MAX_DEBUGGER_JSON_COLLECTION_ITEMS = 100; +const MAX_DEBUGGER_JSON_PROPERTY_NAME_CHARACTERS = 1024; +const MAX_DEBUGGER_JSON_STRING_CHARACTERS = 32 * 1024; +const MAX_DEBUGGER_PROVIDERS = 100; +const MAX_PROVIDER_CONCURRENCY = 4; +const MAX_PROVIDER_PROTOTYPE_DEPTH = 4; +const MAX_PROVIDER_ID_CHARACTERS = 128; +const MAX_PROVIDER_LABEL_CHARACTERS = 256; +const MAX_PROVIDER_DESCRIPTION_CHARACTERS = 4096; +const MAX_PROVIDER_ACTION_CHARACTERS = 128; +const PROVIDER_BRIDGE_GLOBAL_KEY = '__VALDI_DEBUGGER_PROVIDERS_V1__'; + +export enum DebuggerProviderKind { + Storage = 'storage', + Sql = 'sql', + KeyValue = 'key-value', + Network = 'network', +} + +export interface DebuggerProviderAvailability { + readonly available: boolean; + readonly message?: string; +} + +/** A bounded request object supplied by the debugger transport. */ +export interface DebuggerProviderRequest { + readonly action: string; + readonly [key: string]: unknown; +} + +/** + * Providers serialize their own result before crossing the core boundary. + * The document must contain one JSON object and fit the 48 KiB action-document budget. + */ +export interface DebuggerProviderResult { + readonly json: string; +} + +export interface DebuggerProvider { + readonly id: string; + readonly kind: DebuggerProviderKind; + readonly label: string; + readonly description?: string; + readonly availability?: () => boolean | DebuggerProviderAvailability; + readonly handleRequest: ( + request: DebuggerProviderRequest, + ) => Promise | DebuggerProviderResult; +} + +export interface DebuggerProviderSnapshot { + readonly available: boolean; + readonly description?: string; + readonly id: string; + readonly kind: DebuggerProviderKind; + readonly label: string; + readonly message?: string; + readonly registrationToken: number; +} + +export interface DebuggerProvidersSnapshot { + readonly contractVersion: number; + readonly metadataTruncated?: boolean; + readonly omittedMetadataFields?: number; + readonly providers: readonly DebuggerProviderSnapshot[]; + readonly revision: number; +} + +export interface DebuggerProviderRegistration { + dispose(): void; + notifyChange(): void; +} + +export interface DebuggerProviderModule { + readonly path?: string; +} + +/** Owns adapter registrations and automatically disposes them when its creating module reloads. */ +export interface DebuggerProviderOwner { + dispose(): void; + register(provider: DebuggerProvider): DebuggerProviderRegistration; +} + +interface DebuggerProvidersMessage { + readonly action?: string; + readonly providerId?: string; + readonly request?: unknown; +} + +interface DataPropertyResult { + readonly found: boolean; + readonly ok: boolean; + readonly value?: unknown; +} + +interface RegisteredDebuggerProvider { + readonly availability?: () => boolean | DebuggerProviderAvailability; + readonly description?: string; + disposed: boolean; + readonly generation: number; + readonly handleRequest: ( + request: DebuggerProviderRequest, + ) => Promise | DebuggerProviderResult; + readonly id: string; + inFlight: number; + readonly kind: DebuggerProviderKind; + readonly label: string; + readonly token: number; +} + +interface RegisteredDebuggerProviderOwner { + readonly dispose: () => void; + readonly token: number; +} + +interface ProviderBridgeState { + activeRegistrationCount: number; + delegate?: (identifier: string, data: unknown) => Promise | undefined; + generation: number; + readonly handler: CustomMessageHandler; + handlerInstalled: boolean; + ownerToken: number; + readonly owners: Map; + registrationToken: number; + readonly registrations: Map; + revision: number; +} + +interface ProviderBridgeGlobal { + [PROVIDER_BRIDGE_GLOBAL_KEY]?: ProviderBridgeState; +} + +class BoundedJsonParser { + private index = 0; + private valueCount = 0; + + constructor(private readonly source: string) {} + + parseObjectDocument(): Record { + this.skipWhitespace(); + const value = this.parseValue(0); + this.skipWhitespace(); + if (this.index !== this.source.length) this.fail('contains trailing data'); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + this.fail('must contain a JSON object'); + } + return value as Record; + } + + private parseValue(depth: number): unknown { + if (depth > MAX_DEBUGGER_JSON_DEPTH) this.fail('exceeds the maximum depth'); + this.valueCount++; + if (this.valueCount > MAX_DEBUGGER_JSON_VALUES) this.fail('contains too many values'); + const character = this.source[this.index]; + if (character === '{') return this.parseObject(depth); + if (character === '[') return this.parseArray(depth); + if (character === '"') { + return this.parseString(MAX_DEBUGGER_JSON_STRING_CHARACTERS); + } + if (character === 't') return this.parseLiteral('true', true); + if (character === 'f') return this.parseLiteral('false', false); + if (character === 'n') return this.parseLiteral('null', null); + if (character === '-' || this.isDigit(character)) return this.parseNumber(); + return this.fail('contains an invalid value'); + } + + private parseObject(depth: number): Record { + const output = Object.create(null) as Record; + this.index++; + this.skipWhitespace(); + if (this.source[this.index] === '}') { + this.index++; + return output; + } + let propertyCount = 0; + for (;;) { + propertyCount++; + if (propertyCount > MAX_DEBUGGER_JSON_COLLECTION_ITEMS) this.fail('contains too many object properties'); + const propertyName = this.parseString(MAX_DEBUGGER_JSON_PROPERTY_NAME_CHARACTERS); + this.skipWhitespace(); + if (this.source[this.index] !== ':') this.fail('contains an object property without a colon'); + this.index++; + this.skipWhitespace(); + output[propertyName] = this.parseValue(depth + 1); + this.skipWhitespace(); + const separator = this.source[this.index]; + if (separator === '}') { + this.index++; + return output; + } + if (separator !== ',') this.fail('contains an invalid object separator'); + this.index++; + this.skipWhitespace(); + } + } + + private parseArray(depth: number): unknown[] { + const output: unknown[] = []; + this.index++; + this.skipWhitespace(); + if (this.source[this.index] === ']') { + this.index++; + return output; + } + let itemCount = 0; + for (;;) { + itemCount++; + if (itemCount > MAX_DEBUGGER_JSON_COLLECTION_ITEMS) this.fail('contains too many array items'); + output.push(this.parseValue(depth + 1)); + this.skipWhitespace(); + const separator = this.source[this.index]; + if (separator === ']') { + this.index++; + return output; + } + if (separator !== ',') this.fail('contains an invalid array separator'); + this.index++; + this.skipWhitespace(); + } + } + + private parseString(maximumCharacters: number): string { + if (this.source[this.index] !== '"') this.fail('contains an invalid JSON string'); + const start = this.index; + this.index++; + let characters = 0; + while (this.index < this.source.length) { + const character = this.source[this.index]!; + this.index++; + if (character === '"') return JSON.parse(this.source.slice(start, this.index)) as string; + if (character === '\\') { + const escape = this.source[this.index]; + this.index++; + if (escape === 'u') { + for (let offset = 0; offset < 4; offset++) { + const code = this.source.charCodeAt(this.index + offset); + const hexadecimal = (code >= 48 && code <= 57) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102); + if (!hexadecimal) this.fail('contains an invalid Unicode escape'); + } + this.index += 4; + } else if (!escape || !'"\\/bfnrt'.includes(escape)) { + this.fail('contains an invalid string escape'); + } + } else if (character.charCodeAt(0) <= 0x1f) { + this.fail('contains an unescaped control character'); + } + characters++; + if (characters > maximumCharacters) this.fail('contains an oversized string'); + } + this.fail('contains an unterminated string'); + } + + private parseNumber(): number { + const start = this.index; + if (this.source[this.index] === '-') this.index++; + if (this.source[this.index] === '0') this.index++; + else { + if (!this.isNonZeroDigit(this.source[this.index])) this.fail('contains an invalid number'); + while (this.isDigit(this.source[this.index])) this.index++; + } + if (this.source[this.index] === '.') { + this.index++; + if (!this.isDigit(this.source[this.index])) this.fail('contains an invalid number fraction'); + while (this.isDigit(this.source[this.index])) this.index++; + } + const exponent = this.source[this.index]; + if (exponent === 'e' || exponent === 'E') { + this.index++; + const sign = this.source[this.index]; + if (sign === '+' || sign === '-') this.index++; + if (!this.isDigit(this.source[this.index])) this.fail('contains an invalid number exponent'); + while (this.isDigit(this.source[this.index])) this.index++; + } + const value = Number(this.source.slice(start, this.index)); + if (!Number.isFinite(value)) this.fail('contains a non-finite number'); + return value; + } + + private parseLiteral(literal: string, value: T): T { + if (this.source.slice(this.index, this.index + literal.length) !== literal) { + this.fail('contains an invalid literal'); + } + this.index += literal.length; + return value; + } + + private skipWhitespace(): void { + while (this.index < this.source.length) { + const code = this.source.charCodeAt(this.index); + if (code !== 0x20 && code !== 0x0a && code !== 0x0d && code !== 0x09) return; + this.index++; + } + } + + private isDigit(character: string | undefined): boolean { + return character !== undefined && character >= '0' && character <= '9'; + } + + private isNonZeroDigit(character: string | undefined): boolean { + return character !== undefined && character >= '1' && character <= '9'; + } + + private fail(reason: string): never { + throw new Error(`Debugger provider JSON ${reason}`); + } +} + +function utf8ByteLength(value: string): number { + let bytes = 0; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x7f) bytes += 1; + else if (code <= 0x7ff) bytes += 2; + else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + index++; + } else bytes += 3; + } else bytes += 3; + } + return bytes; +} + +function validateAndParseProviderJson(json: unknown): Record { + if (typeof json !== 'string') throw new Error('Debugger provider results require a JSON string'); + if (json.length > MAX_DEBUGGER_ACTION_JSON_BYTES || utf8ByteLength(json) > MAX_DEBUGGER_ACTION_JSON_BYTES) { + throw new Error('Debugger provider action JSON exceeds 48 KiB'); + } + return new BoundedJsonParser(json).parseObjectDocument(); +} + +function finalCustomResponseByteLength(data: unknown): number { + return utf8ByteLength(JSON.stringify({ handled: true, data })); +} + +function finalCustomResponseFits(data: unknown): boolean { + return finalCustomResponseByteLength(data) <= MAX_DEBUGGER_CUSTOM_RESPONSE_BYTES; +} + +function boundedFinalCustomResponse(data: T): T { + if (!finalCustomResponseFits(data)) { + throw new Error('Debugger provider final response exceeds 128 KiB'); + } + return data; +} + +/** Validates a serialized provider result without inspecting a provider-owned object graph. */ +export function createDebuggerProviderResult(json: string): DebuggerProviderResult { + validateAndParseProviderJson(json); + return { json }; +} + +function objectPrototypeChain(value: object, label: string): object[] { + const chain: object[] = []; + let current: object | null = value; + for (let depth = 0; current !== null; depth++) { + if (depth > MAX_PROVIDER_PROTOTYPE_DEPTH) throw new Error(`${label} prototype chain is too deep`); + chain.push(current); + try { + current = Object.getPrototypeOf(current) as object | null; + } catch { + throw new Error(`${label} prototype chain could not be inspected safely`); + } + } + return chain; +} + +function knownDataProperty(chain: readonly object[], key: PropertyKey, label: string): DataPropertyResult { + for (const object of chain) { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(object, key); + } catch { + return { found: false, ok: false }; + } + if (descriptor === undefined) continue; + if (!('value' in descriptor)) throw new Error(`${label} must be a data property`); + return { found: true, ok: true, value: descriptor.value }; + } + return { found: false, ok: true }; +} + +function exactString( + value: unknown, + label: string, + maximumLength: number, + optional = false, + maximumJsonBytes?: number, +): string | undefined { + if (value === undefined && optional) return undefined; + if (typeof value !== 'string' || value.trim().length === 0 || value.length > maximumLength) { + throw new Error(`${label} must be a non-empty string of at most ${maximumLength.toString()} characters`); + } + if (maximumJsonBytes !== undefined && utf8ByteLength(JSON.stringify(value)) - 2 > maximumJsonBytes) { + throw new Error(`${label} must serialize to at most ${maximumJsonBytes.toString()} JSON bytes`); + } + return value; +} + +function exactProviderKind(value: unknown): DebuggerProviderKind { + switch (value) { + case DebuggerProviderKind.Storage: + case DebuggerProviderKind.Sql: + case DebuggerProviderKind.KeyValue: + case DebuggerProviderKind.Network: + return value; + default: + throw new Error('Debugger provider kind is unsupported'); + } +} + +function captureProvider(provider: DebuggerProvider, generation: number, token: number): RegisteredDebuggerProvider { + if (typeof provider !== 'object' || provider === null) throw new Error('Debugger provider must be an object'); + const chain = objectPrototypeChain(provider, 'Debugger provider'); + const idValue = knownDataProperty(chain, 'id', 'Debugger provider id'); + const kindValue = knownDataProperty(chain, 'kind', 'Debugger provider kind'); + const labelValue = knownDataProperty(chain, 'label', 'Debugger provider label'); + const descriptionValue = knownDataProperty(chain, 'description', 'Debugger provider description'); + const availabilityValue = knownDataProperty(chain, 'availability', 'Debugger provider availability'); + const handleRequestValue = knownDataProperty(chain, 'handleRequest', 'Debugger provider handleRequest'); + if ( + !idValue.ok || + !kindValue.ok || + !labelValue.ok || + !descriptionValue.ok || + !availabilityValue.ok || + !handleRequestValue.ok + ) { + throw new Error('Debugger provider properties could not be inspected safely'); + } + const id = exactString( + idValue.value, + 'Debugger provider id', + MAX_PROVIDER_ID_CHARACTERS, + false, + MAX_PROVIDER_ID_CHARACTERS, + )!; + const label = exactString( + labelValue.value, + 'Debugger provider label', + MAX_PROVIDER_LABEL_CHARACTERS, + false, + MAX_PROVIDER_LABEL_CHARACTERS, + )!; + const description = exactString( + descriptionValue.value, + 'Debugger provider description', + MAX_PROVIDER_DESCRIPTION_CHARACTERS, + true, + MAX_PROVIDER_DESCRIPTION_CHARACTERS, + ); + if (availabilityValue.found && typeof availabilityValue.value !== 'function') { + throw new Error('Debugger provider availability must be a function'); + } + if (!handleRequestValue.found || typeof handleRequestValue.value !== 'function') { + throw new Error('Debugger provider handleRequest must be a function'); + } + return { + availability: availabilityValue.found + ? (availabilityValue.value as () => boolean | DebuggerProviderAvailability).bind(provider) + : undefined, + description, + disposed: false, + generation, + handleRequest: (handleRequestValue.value as DebuggerProvider['handleRequest']).bind(provider), + id, + inFlight: 0, + kind: exactProviderKind(kindValue.value), + label, + token, + }; +} + +function providerAvailability(provider: RegisteredDebuggerProvider): DebuggerProviderAvailability { + try { + const availability = provider.availability?.() ?? true; + if (typeof availability === 'boolean') return { available: availability }; + if (typeof availability !== 'object' || availability === null) { + return { available: false, message: 'Provider returned invalid availability.' }; + } + const chain = objectPrototypeChain(availability, 'Provider availability'); + const availableValue = knownDataProperty(chain, 'available', 'Provider availability available'); + const messageValue = knownDataProperty(chain, 'message', 'Provider availability message'); + if (!availableValue.ok || !availableValue.found || typeof availableValue.value !== 'boolean' || !messageValue.ok) { + return { available: false, message: 'Provider returned invalid availability.' }; + } + const message = exactString(messageValue.value, 'Provider availability message', 1024, true, 1024); + return { available: availableValue.value, ...(message === undefined ? {} : { message }) }; + } catch { + return { available: false, message: 'Provider availability check failed.' }; + } +} + +function providerSnapshot( + provider: RegisteredDebuggerProvider, + availability = providerAvailability(provider), +): DebuggerProviderSnapshot { + return { + available: availability.available, + ...(provider.description === undefined ? {} : { description: provider.description }), + id: provider.id, + kind: provider.kind, + label: provider.label, + ...(availability.message === undefined ? {} : { message: availability.message }), + registrationToken: provider.token, + }; +} + +function clearRegistrations(bridge: ProviderBridgeState): void { + bridge.registrations.forEach(registrations => + registrations.forEach(registration => { + registration.disposed = true; + }), + ); + bridge.registrations.clear(); + bridge.activeRegistrationCount = 0; + bridge.revision++; +} + +function createProviderBridge(): ProviderBridgeState { + let bridge: ProviderBridgeState; + const handler: CustomMessageHandler = { + messageReceived(identifier: string, data: unknown): Promise | undefined { + return bridge.delegate?.(identifier, data); + }, + }; + bridge = { + activeRegistrationCount: 0, + generation: 0, + handler, + handlerInstalled: false, + ownerToken: 0, + owners: new Map(), + registrationToken: 0, + registrations: new Map(), + revision: 0, + }; + return bridge; +} + +function globalProviderBridge(): ProviderBridgeState { + const globals = globalThis as unknown as ProviderBridgeGlobal; + let bridge = globals[PROVIDER_BRIDGE_GLOBAL_KEY]; + if (bridge === undefined) { + bridge = createProviderBridge(); + globals[PROVIDER_BRIDGE_GLOBAL_KEY] = bridge; + } else if (bridge.owners === undefined) { + bridge.ownerToken = 0; + (bridge as { owners: Map }).owners = new Map(); + } + return bridge; +} + +const providerBridge = globalProviderBridge(); +let moduleGeneration = claimProviderBridgeOwner(); + +function claimProviderBridgeOwner(): number { + if (providerBridge.generation !== 0) { + clearProviderOwners(providerBridge); + clearRegistrations(providerBridge); + } + providerBridge.generation++; + const generation = providerBridge.generation; + providerBridge.delegate = (identifier, data) => { + if (identifier !== DEBUGGER_PROVIDERS_IDENTIFIER) return undefined; + return handleMessage(providerBridge, generation, data); + }; + return generation; +} + +function releaseProviderBridgeOwner(generation: number): void { + if (providerBridge.generation !== generation) return; + clearProviderOwners(providerBridge); + clearRegistrations(providerBridge); + providerBridge.delegate = undefined; + if (providerBridge.handlerInstalled) { + jsx.removeCustomMessageHandler(providerBridge.handler); + providerBridge.handlerInstalled = false; + } +} + +function clearProviderOwners(bridge: ProviderBridgeState): void { + const owners = Array.from(bridge.owners.values()); + bridge.owners.clear(); + owners.forEach(owner => owner.dispose()); +} + +function registerModuleDisposal(generation: number): void { + try { + if (typeof module === 'undefined' || typeof module.path !== 'string') return; + getModuleLoader().onHotReload(module as { path: string }, module.path, () => { + releaseProviderBridgeOwner(generation); + }); + } catch { + // Some standalone test hosts do not install the Valdi module loader. + } +} + +registerModuleDisposal(moduleGeneration); + +function installRegistryBridge(): void { + if (providerBridge.handlerInstalled) return; + jsx.addCustomMessageHandler(providerBridge.handler); + providerBridge.handlerInstalled = true; +} + +function removeRegistryBridgeIfUnused(): void { + if (providerBridge.activeRegistrationCount !== 0 || !providerBridge.handlerInstalled) return; + jsx.removeCustomMessageHandler(providerBridge.handler); + providerBridge.handlerInstalled = false; +} + +function activeProvider(bridge: ProviderBridgeState, providerId: string): RegisteredDebuggerProvider | undefined { + const registrations = bridge.registrations.get(providerId); + const provider = registrations?.[registrations.length - 1]; + return provider?.generation === bridge.generation && !provider.disposed ? provider : undefined; +} + +function requiredProviderSnapshot(provider: DebuggerProviderSnapshot): DebuggerProviderSnapshot { + return { + available: provider.available, + id: provider.id, + kind: provider.kind, + label: provider.label, + registrationToken: provider.registrationToken, + }; +} + +function debuggerProvidersSnapshot(bridge: ProviderBridgeState): DebuggerProvidersSnapshot { + const providers: DebuggerProviderSnapshot[] = []; + bridge.registrations.forEach(registrations => { + const provider = registrations[registrations.length - 1]; + if (provider !== undefined && !provider.disposed && provider.generation === bridge.generation) { + providers.push(providerSnapshot(provider)); + } + }); + providers.sort((left, right) => left.kind.localeCompare(right.kind) || left.label.localeCompare(right.label)); + const complete: DebuggerProvidersSnapshot = { + contractVersion: DEBUGGER_PROVIDERS_CONTRACT_VERSION, + providers, + revision: bridge.revision, + }; + if (finalCustomResponseFits(complete)) return complete; + + const boundedProviders = providers.map(requiredProviderSnapshot); + let omittedMetadataFields = 0; + for (let index = 0; index < providers.length; index++) { + const completeProvider = providers[index]!; + const boundedProvider = boundedProviders[index]! as { + description?: string; + message?: string; + }; + for (const key of ['description', 'message'] as const) { + const value = completeProvider[key]; + if (value === undefined) continue; + boundedProvider[key] = value; + const candidate: DebuggerProvidersSnapshot = { + contractVersion: DEBUGGER_PROVIDERS_CONTRACT_VERSION, + metadataTruncated: true, + omittedMetadataFields: providers.length * 2, + providers: boundedProviders, + revision: bridge.revision, + }; + if (!finalCustomResponseFits(candidate)) { + delete boundedProvider[key]; + omittedMetadataFields++; + } + } + } + const bounded: DebuggerProvidersSnapshot = { + contractVersion: DEBUGGER_PROVIDERS_CONTRACT_VERSION, + metadataTruncated: true, + omittedMetadataFields, + providers: boundedProviders, + revision: bridge.revision, + }; + return boundedFinalCustomResponse(bounded); +} + +function baseProviderResponse( + bridge: ProviderBridgeState, + provider: RegisteredDebuggerProvider, + availability: DebuggerProviderAvailability, +): Record { + return { + contractVersion: DEBUGGER_PROVIDERS_CONTRACT_VERSION, + provider: providerSnapshot(provider, availability), + registrationToken: provider.token, + revision: bridge.revision, + }; +} + +async function dispatchProviderRequest( + bridge: ProviderBridgeState, + providerId: string, + request: DebuggerProviderRequest, +): Promise { + const provider = activeProvider(bridge, providerId); + if (provider === undefined) throw new Error(`Unknown debugger provider: ${providerId}`); + const availability = providerAvailability(provider); + const base = baseProviderResponse(bridge, provider, availability); + if (!availability.available) { + return boundedFinalCustomResponse({ + ...base, + message: availability.message ?? 'Provider is unavailable.', + unavailable: true, + }); + } + if (provider.inFlight >= MAX_PROVIDER_CONCURRENCY) { + return boundedFinalCustomResponse({ + ...base, + busy: true, + message: 'Provider request concurrency limit reached.', + }); + } + + const capturedGeneration = bridge.generation; + const capturedRevision = bridge.revision; + const capturedToken = provider.token; + provider.inFlight++; + try { + const result = await provider.handleRequest(request); + if ( + provider.disposed || + bridge.generation !== capturedGeneration || + bridge.revision !== capturedRevision || + activeProvider(bridge, providerId)?.token !== capturedToken + ) { + return boundedFinalCustomResponse({ + contractVersion: DEBUGGER_PROVIDERS_CONTRACT_VERSION, + registrationToken: capturedToken, + revision: bridge.revision, + stale: true, + }); + } + if (typeof result !== 'object' || result === null) { + throw new Error('Debugger provider results require an object with a JSON data property'); + } + const resultChain = objectPrototypeChain(result, 'Debugger provider result'); + const jsonValue = knownDataProperty(resultChain, 'json', 'Debugger provider result json'); + if (!jsonValue.ok || !jsonValue.found) { + throw new Error('Debugger provider results require a JSON data property'); + } + return boundedFinalCustomResponse({ ...base, data: validateAndParseProviderJson(jsonValue.value) }); + } finally { + provider.inFlight--; + } +} + +function messageValue(message: object, key: PropertyKey, label: string): unknown { + const value = knownDataProperty(objectPrototypeChain(message, label), key, label); + if (!value.ok) throw new Error(`${label} could not be inspected safely`); + return value.value; +} + +async function handleMessage(bridge: ProviderBridgeState, generation: number, data: unknown): Promise { + if (bridge.generation !== generation) throw new Error('Debugger provider module instance is stale'); + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + throw new Error('Debugger provider message must be an object'); + } + const actionValue = messageValue(data, 'action', 'Debugger provider message action') ?? 'list'; + const action = exactString(actionValue, 'Debugger provider action', MAX_PROVIDER_ACTION_CHARACTERS)!; + if (action === 'list') return debuggerProvidersSnapshot(bridge); + if (action !== 'request') throw new Error(`Unsupported debugger provider action: ${action}`); + const providerId = exactString( + messageValue(data, 'providerId', 'Debugger provider message providerId'), + 'Debugger provider id', + MAX_PROVIDER_ID_CHARACTERS, + )!; + const requestValue = messageValue(data, 'request', 'Debugger provider message request'); + if (typeof requestValue !== 'object' || requestValue === null || Array.isArray(requestValue)) { + throw new Error('Debugger provider requests require an object request'); + } + const requestAction = exactString( + messageValue(requestValue, 'action', 'Debugger provider request action'), + 'Debugger provider request action', + MAX_PROVIDER_ACTION_CHARACTERS, + )!; + if (requestAction !== messageValue(requestValue, 'action', 'Debugger provider request action')) { + throw new Error('Debugger provider request action changed during validation'); + } + return dispatchProviderRequest(bridge, providerId, requestValue as DebuggerProviderRequest); +} + +const inactiveRegistration: DebuggerProviderRegistration = { + dispose(): void {}, + notifyChange(): void {}, +}; + +/** Registers a debugger capability for debug runtimes. Dispose the returned token when the provider owner reloads. */ +export function registerDebuggerProvider(provider: DebuggerProvider): DebuggerProviderRegistration { + if (!runtime.isDebugEnabled) return inactiveRegistration; + if (providerBridge.generation !== moduleGeneration || providerBridge.delegate === undefined) { + throw new Error('Debugger provider module instance is stale'); + } + const registration = captureProvider(provider, moduleGeneration, ++providerBridge.registrationToken); + const replacedRegistrations = providerBridge.registrations.get(registration.id) ?? []; + const replacedLiveCount = replacedRegistrations.reduce((count, replaced) => count + (replaced.disposed ? 0 : 1), 0); + if (providerBridge.activeRegistrationCount - replacedLiveCount >= MAX_DEBUGGER_PROVIDERS) { + throw new Error('Debugger provider registry exceeds the supported live registration count'); + } + replacedRegistrations.forEach(replaced => { + replaced.disposed = true; + }); + providerBridge.activeRegistrationCount -= replacedLiveCount; + installRegistryBridge(); + providerBridge.registrations.set(registration.id, [registration]); + providerBridge.activeRegistrationCount++; + providerBridge.revision++; + + return { + dispose(): void { + if (registration.disposed) return; + registration.disposed = true; + const current = providerBridge.registrations.get(registration.id); + const index = current?.indexOf(registration) ?? -1; + if (current !== undefined && index >= 0) { + current.splice(index, 1); + providerBridge.activeRegistrationCount--; + if (current.length === 0) providerBridge.registrations.delete(registration.id); + providerBridge.revision++; + } + removeRegistryBridgeIfUnused(); + }, + notifyChange(): void { + if ( + !registration.disposed && + registration.generation === providerBridge.generation && + providerBridge.registrations.get(registration.id)?.includes(registration) + ) { + providerBridge.revision++; + } + }, + }; +} + +/** + * Creates an owner whose registrations are automatically removed when the supplied adapter module reloads. + * ownerKey must be the adapter's stable module identifier and is required because webpack module.path is absent. + */ +export function createDebuggerProviderOwner( + ownerModule: DebuggerProviderModule, + ownerKey: string, +): DebuggerProviderOwner { + if (typeof ownerModule !== 'object' || ownerModule === null) { + throw new Error('Debugger provider owners require their creating module'); + } + const stableOwnerKey = exactString(ownerKey, 'Debugger provider owner key', 256, false, 256)!; + const ownerModuleChain = objectPrototypeChain(ownerModule, 'Debugger provider owner module'); + const modulePathValue = knownDataProperty(ownerModuleChain, 'path', 'Debugger provider owner module path'); + if (!modulePathValue.ok) { + throw new Error('Debugger provider owner module path could not be inspected safely'); + } + const reloadPath = + modulePathValue.found && modulePathValue.value !== undefined + ? exactString(modulePathValue.value, 'Debugger provider owner module path', 1024)! + : stableOwnerKey; + const generation = moduleGeneration; + const ownerToken = ++providerBridge.ownerToken; + const registrations: DebuggerProviderRegistration[] = []; + let disposed = false; + let removeReloadCallback: (() => void) | undefined; + const owner: DebuggerProviderOwner = { + dispose(): void { + if (disposed) return; + disposed = true; + const removeCallback = removeReloadCallback; + removeReloadCallback = undefined; + removeCallback?.(); + if (providerBridge.owners.get(stableOwnerKey)?.token === ownerToken) { + providerBridge.owners.delete(stableOwnerKey); + } + registrations.forEach(registration => registration.dispose()); + registrations.length = 0; + }, + register(provider: DebuggerProvider): DebuggerProviderRegistration { + if (disposed) throw new Error('Debugger provider owner is disposed'); + if (generation !== providerBridge.generation) + throw new Error('Debugger provider owner belongs to a stale module'); + if (providerBridge.owners.get(stableOwnerKey)?.token !== ownerToken) { + throw new Error('Debugger provider owner was replaced'); + } + const registration = registerDebuggerProvider(provider); + registrations.push(registration); + return registration; + }, + }; + providerBridge.owners.get(stableOwnerKey)?.dispose(); + providerBridge.owners.set(stableOwnerKey, { dispose: () => owner.dispose(), token: ownerToken }); + try { + removeReloadCallback = getModuleLoader().onHotReload(ownerModule as { path: string }, reloadPath, () => + owner.dispose(), + ); + } catch { + owner.dispose(); + throw new Error('Debugger provider owner could not bind to its module hot-reload lifecycle'); + } + return owner; +} + +/** @internal Test-only simulation of a DebuggerProvider module replacement. */ +export function reloadDebuggerProviderModuleForTesting(): void { + moduleGeneration = claimProviderBridgeOwner(); +} diff --git a/src/valdi_modules/src/valdi/valdi_core/test/DebuggerProvider.spec.ts b/src/valdi_modules/src/valdi/valdi_core/test/DebuggerProvider.spec.ts new file mode 100644 index 00000000..1e887a6f --- /dev/null +++ b/src/valdi_modules/src/valdi/valdi_core/test/DebuggerProvider.spec.ts @@ -0,0 +1,558 @@ +import 'jasmine/src/jasmine'; +import { jsx } from '../src/JSXBootstrap'; +import { ModuleLoader } from '../src/ModuleLoader'; +import { getModuleLoader } from '../src/ModuleLoaderGlobal'; +import type { IModuleLoader } from '../src/IModuleLoader'; +import type { ValdiRuntime } from '../src/ValdiRuntime'; +import { + createDebuggerProviderOwner, + createDebuggerProviderResult, + DebuggerProviderKind, + registerDebuggerProvider, + reloadDebuggerProviderModuleForTesting, +} from '../src/debugging/DebuggerProvider'; +import type { CustomMessageHandler } from '../src/debugging/CustomMessageHandler'; +import type { + DebuggerProvider, + DebuggerProviderRegistration, + DebuggerProviderResult, +} from '../src/debugging/DebuggerProvider'; + +declare const runtime: ValdiRuntime; +declare const global: { moduleLoader: IModuleLoader }; + +function jsonResult(value: string): DebuggerProviderResult { + return createDebuggerProviderResult(value); +} + +function utf8ByteLength(value: string): number { + let bytes = 0; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x7f) bytes++; + else if (code <= 0x7ff) bytes += 2; + else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + index++; + } else bytes += 3; + } else bytes += 3; + } + return bytes; +} + +function finalResponseBytes(data: unknown): number { + return utf8ByteLength(JSON.stringify({ handled: true, data })); +} + +describe('DebuggerProvider', () => { + let originalDebugEnabled: boolean; + let registrations: DebuggerProviderRegistration[]; + + beforeEach(() => { + originalDebugEnabled = runtime.isDebugEnabled; + runtime.isDebugEnabled = true; + registrations = []; + }); + + afterEach(() => { + registrations.forEach(registration => registration.dispose()); + runtime.isDebugEnabled = originalDebugEnabled; + }); + + it('lists and handles independently registered providers through serialized JSON results', async () => { + const addHandler = spyOn(jsx, 'addCustomMessageHandler').and.callThrough(); + registrations.push( + registerDebuggerProvider({ + handleRequest: request => + jsonResult(JSON.stringify({ action: request.action, stores: [{ name: 'preferences' }] })), + id: 'test-storage', + kind: DebuggerProviderKind.Storage, + label: 'Test storage', + }), + ); + registrations.push( + registerDebuggerProvider({ + handleRequest: request => jsonResult(JSON.stringify({ action: request.action, databases: [{ id: 'main' }] })), + id: 'test-sql', + kind: DebuggerProviderKind.Sql, + label: 'Test SQL', + }), + ); + const handler = addHandler.calls.mostRecent().args[0] as CustomMessageHandler; + + const list = await handler.messageReceived('ValdiDebuggerProviders', { action: 'list' }); + const response = await handler.messageReceived('ValdiDebuggerProviders', { + action: 'request', + providerId: 'test-storage', + request: { action: 'snapshot' }, + }); + + expect(list.providers).toEqual( + jasmine.arrayWithExactContents([ + jasmine.objectContaining({ available: true, id: 'test-storage', kind: 'storage' }), + jasmine.objectContaining({ available: true, id: 'test-sql', kind: 'sql' }), + ]), + ); + expect(response.data).toEqual({ action: 'snapshot', stores: [{ name: 'preferences' }] }); + expect(typeof response.registrationToken).toBe('number'); + expect(handler.messageReceived('UnrelatedIdentifier', {})).toBeUndefined(); + }); + + it('never enumerates a provider result Proxy that would return 500,000 keys', async () => { + let ownKeysCalls = 0; + const result = new Proxy( + { json: '{"__proto__":{"polluted":true},"nested":{"safe":true}}' }, + { + ownKeys: () => { + ownKeysCalls++; + return Array.from({ length: 500_000 }, (_, index) => `key-${index.toString()}`); + }, + }, + ); + const addHandler = spyOn(jsx, 'addCustomMessageHandler').and.callThrough(); + registrations.push( + registerDebuggerProvider({ + handleRequest: () => result, + id: 'proxy-result', + kind: DebuggerProviderKind.Storage, + label: 'Proxy result', + }), + ); + const handler = addHandler.calls.mostRecent().args[0] as CustomMessageHandler; + + const response = await handler.messageReceived('ValdiDebuggerProviders', { + action: 'request', + providerId: 'proxy-result', + request: { action: 'snapshot' }, + }); + + expect(response.data.nested).toEqual({ safe: true }); + expect(Object.getPrototypeOf(response.data)).toBeNull(); + expect(Object.getPrototypeOf(response.data.nested)).toBeNull(); + expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); + expect(ownKeysCalls).toBe(0); + }); + + it('applies the action-document budget before final response transport', () => { + const tooManyProperties = `{${Array.from({ length: 101 }, (_, index) => `"k${index.toString()}":${index.toString()}`).join(',')}}`; + const tooManyItems = `[${Array.from({ length: 101 }, (_, index) => index.toString()).join(',')}]`; + const tooDeep = '{"a":'.repeat(10) + 'true' + '}'.repeat(10); + const exactByteLimit = JSON.stringify({ + a: 'x'.repeat(32 * 1024), + b: 'x'.repeat(16_369), + }); + const overByteLimit = `${exactByteLimit.slice(0, -2)}x"}`; + const oversized = JSON.stringify({ + a: 'x'.repeat(32 * 1024), + b: 'x'.repeat(16_370), + }); + + expect(() => jsonResult('{"valid":[1,true,null,"text"]}')).not.toThrow(); + expect(exactByteLimit.length).toBe(48 * 1024); + expect(() => jsonResult(exactByteLimit)).not.toThrow(); + expect(() => jsonResult(overByteLimit)).toThrowError(/exceeds 48 KiB/); + expect(() => jsonResult(tooManyProperties)).toThrowError(/too many object properties/); + expect(() => jsonResult(`{"items":${tooManyItems}}`)).toThrowError(/too many array items/); + expect(() => jsonResult(tooDeep)).toThrowError(/maximum depth/); + expect(() => jsonResult('{"number":1e400}')).toThrowError(/non-finite/); + expect(() => jsonResult('[1,2,3]')).toThrowError(/must contain a JSON object/); + expect(() => jsonResult('{invalid}')).toThrowError(/invalid JSON string/); + expect(() => jsonResult(oversized)).toThrowError(/exceeds 48 KiB/); + }); + + it('bounds the complete exact-limit action response after metadata and reserialization', async () => { + const exactDocument = JSON.stringify({ + a: 'x'.repeat(32 * 1024), + b: 'x'.repeat(16_369), + }); + const addHandler = spyOn(jsx, 'addCustomMessageHandler').and.callThrough(); + registrations.push( + registerDebuggerProvider({ + availability: () => ({ available: true, message: 'm'.repeat(1024) }), + description: 'd'.repeat(4096), + handleRequest: () => jsonResult(exactDocument), + id: 'i'.repeat(128), + kind: DebuggerProviderKind.Storage, + label: 'l'.repeat(256), + }), + ); + const handler = addHandler.calls.mostRecent().args[0] as CustomMessageHandler; + + const response = await handler.messageReceived('ValdiDebuggerProviders', { + action: 'request', + providerId: 'i'.repeat(128), + request: { action: 'snapshot' }, + }); + + expect(exactDocument.length).toBe(48 * 1024); + expect(finalResponseBytes(response)).toBeLessThanOrEqual(128 * 1024); + expect(response.data.b.length).toBe(16_369); + }); + + it('bounds lone-surrogate expansion in the final action response', async () => { + const rawSurrogates = '\ud800'.repeat(16_000); + const document = `{"value":"${rawSurrogates}"}`; + const addHandler = spyOn(jsx, 'addCustomMessageHandler').and.callThrough(); + registrations.push( + registerDebuggerProvider({ + handleRequest: () => jsonResult(document), + id: 'surrogates', + kind: DebuggerProviderKind.Storage, + label: 'Surrogates', + }), + ); + const handler = addHandler.calls.mostRecent().args[0] as CustomMessageHandler; + + const response = await handler.messageReceived('ValdiDebuggerProviders', { + action: 'request', + providerId: 'surrogates', + request: { action: 'snapshot' }, + }); + const reserialized = JSON.stringify({ handled: true, data: response }); + + expect(utf8ByteLength(document)).toBeLessThanOrEqual(48 * 1024); + expect(reserialized.length).toBeGreaterThan(document.length * 5); + expect(utf8ByteLength(reserialized)).toBeLessThanOrEqual(128 * 1024); + }); + + it('retains all providers while bounding discovery with maximal optional metadata', async () => { + const addHandler = spyOn(jsx, 'addCustomMessageHandler').and.callThrough(); + for (let index = 0; index < 100; index++) { + registrations.push( + registerDebuggerProvider({ + availability: () => ({ available: true, message: 'm'.repeat(1024) }), + description: 'd'.repeat(4096), + handleRequest: () => jsonResult('{"ok":true}'), + id: `provider-${index.toString().padStart(3, '0')}`, + kind: DebuggerProviderKind.Storage, + label: `Provider ${index.toString().padStart(3, '0')} ${'l'.repeat(240)}`, + }), + ); + } + const handler = addHandler.calls.mostRecent().args[0] as CustomMessageHandler; + + const response = await handler.messageReceived('ValdiDebuggerProviders', { action: 'list' }); + + expect(response.providers.length).toBe(100); + expect(response.metadataTruncated).toBe(true); + expect(response.omittedMetadataFields).toBeGreaterThan(0); + expect(finalResponseBytes(response)).toBeLessThanOrEqual(128 * 1024); + }); + + it('checks availability immediately before dispatch and isolates throwing checks', async () => { + let available = true; + const handleRequest = jasmine.createSpy('handleRequest').and.returnValue(jsonResult('{"ok":true}')); + const addHandler = spyOn(jsx, 'addCustomMessageHandler').and.callThrough(); + registrations.push( + registerDebuggerProvider({ + availability: () => available, + handleRequest, + id: 'conditional', + kind: DebuggerProviderKind.KeyValue, + label: 'Conditional', + }), + ); + registrations.push( + registerDebuggerProvider({ + availability: () => { + throw new Error('availability failure'); + }, + handleRequest, + id: 'throwing', + kind: DebuggerProviderKind.Network, + label: 'Throwing', + }), + ); + const handler = addHandler.calls.mostRecent().args[0] as CustomMessageHandler; + const list = await handler.messageReceived('ValdiDebuggerProviders', { action: 'list' }); + available = false; + + const conditional = await handler.messageReceived('ValdiDebuggerProviders', { + action: 'request', + providerId: 'conditional', + request: { action: 'list' }, + }); + const throwing = await handler.messageReceived('ValdiDebuggerProviders', { + action: 'request', + providerId: 'throwing', + request: { action: 'list' }, + }); + + expect(list.providers.find((provider: { id: string }) => provider.id === 'throwing')).toEqual( + jasmine.objectContaining({ available: false, message: 'Provider availability check failed.' }), + ); + expect(conditional).toEqual(jasmine.objectContaining({ unavailable: true })); + expect(throwing).toEqual(jasmine.objectContaining({ unavailable: true })); + expect(handleRequest).not.toHaveBeenCalled(); + }); + + it('bounds concurrency and marks completions stale after replacement or disposal', async () => { + const resolvers: Array<(value: DebuggerProviderResult) => void> = []; + const addHandler = spyOn(jsx, 'addCustomMessageHandler').and.callThrough(); + const first = registerDebuggerProvider({ + handleRequest: () => new Promise(resolve => resolvers.push(resolve)), + id: 'replaceable', + kind: DebuggerProviderKind.Sql, + label: 'First', + }); + registrations.push(first); + const handler = addHandler.calls.mostRecent().args[0] as CustomMessageHandler; + const pending = Array.from({ length: 4 }, () => + handler.messageReceived('ValdiDebuggerProviders', { + action: 'request', + providerId: 'replaceable', + request: { action: 'list' }, + }), + ); + await Promise.resolve(); + + const busy = await handler.messageReceived('ValdiDebuggerProviders', { + action: 'request', + providerId: 'replaceable', + request: { action: 'list' }, + }); + const replacement = registerDebuggerProvider({ + handleRequest: () => jsonResult('{"source":"replacement"}'), + id: 'replaceable', + kind: DebuggerProviderKind.Sql, + label: 'Replacement', + }); + registrations.push(replacement); + replacement.dispose(); + resolvers.forEach(resolve => resolve(jsonResult('{"source":"first"}'))); + const completed = await Promise.all(pending); + + expect(busy).toEqual(jasmine.objectContaining({ busy: true })); + completed.forEach(result => expect(result).toEqual(jasmine.objectContaining({ stale: true }))); + }); + + it('caps total distinct live registrations and permanently replaces repeated IDs', async () => { + const addHandler = spyOn(jsx, 'addCustomMessageHandler').and.callThrough(); + const old = registerDebuggerProvider({ + handleRequest: () => jsonResult('{"generation":"old"}'), + id: 'same-id', + kind: DebuggerProviderKind.Storage, + label: 'Old', + }); + registrations.push(old); + const replacement = registerDebuggerProvider({ + handleRequest: () => jsonResult('{"generation":"new"}'), + id: 'same-id', + kind: DebuggerProviderKind.Storage, + label: 'New', + }); + registrations.push(replacement); + const handler = addHandler.calls.mostRecent().args[0] as CustomMessageHandler; + replacement.dispose(); + + const afterReplacementDispose = await handler.messageReceived('ValdiDebuggerProviders', { action: 'list' }); + expect(afterReplacementDispose.providers).toEqual([]); + old.notifyChange(); + expect((await handler.messageReceived('ValdiDebuggerProviders', { action: 'list' })).providers).toEqual([]); + + for (let index = 0; index < 100; index++) { + registrations.push( + registerDebuggerProvider({ + handleRequest: () => jsonResult('{"ok":true}'), + id: `provider-${index.toString()}`, + kind: DebuggerProviderKind.Storage, + label: `Registration ${index.toString()}`, + }), + ); + } + + expect(() => + registerDebuggerProvider({ + handleRequest: () => jsonResult('{"ok":true}'), + id: 'provider-overflow', + kind: DebuggerProviderKind.Storage, + label: 'One too many', + }), + ).toThrowError(/live registration count/); + }); + + it('supports structural class providers by binding prototype callbacks to their instance', async () => { + class ClassProvider implements DebuggerProvider { + readonly id = 'class-provider'; + readonly kind = DebuggerProviderKind.Sql; + readonly label = 'Class provider'; + private readonly prefix = 'instance'; + + availability(): boolean { + return this.prefix === 'instance'; + } + + handleRequest(request: { readonly action: string }): DebuggerProviderResult { + return jsonResult(JSON.stringify({ source: this.prefix, action: request.action })); + } + } + const addHandler = spyOn(jsx, 'addCustomMessageHandler').and.callThrough(); + registrations.push(registerDebuggerProvider(new ClassProvider())); + const handler = addHandler.calls.mostRecent().args[0] as CustomMessageHandler; + + const response = await handler.messageReceived('ValdiDebuggerProviders', { + action: 'request', + providerId: 'class-provider', + request: { action: 'tables' }, + }); + + expect(response.data).toEqual({ source: 'instance', action: 'tables' }); + }); + + it('rejects accessors and prototype chains beyond the fixed structural bound', () => { + let getterCalls = 0; + const accessorProvider = { + get id(): string { + getterCalls++; + return 'unsafe'; + }, + handleRequest: () => jsonResult('{"ok":true}'), + kind: DebuggerProviderKind.Storage, + label: 'Unsafe', + }; + let prototype: object | null = null; + for (let depth = 0; depth < 7; depth++) prototype = Object.create(prototype) as object; + const deepProvider = Object.create(prototype) as DebuggerProvider; + Object.defineProperties(deepProvider, { + handleRequest: { value: () => jsonResult('{"ok":true}') }, + id: { value: 'deep' }, + kind: { value: DebuggerProviderKind.Storage }, + label: { value: 'Deep' }, + }); + + expect(() => registerDebuggerProvider(accessorProvider)).toThrowError(/must be a data property/); + expect(getterCalls).toBe(0); + expect(() => registerDebuggerProvider(deepProvider)).toThrowError(/prototype chain is too deep/); + }); + + it('automatically disposes owners from module-loader callbacks without reviving replaced providers', async () => { + const reloadCallbacks = new Map void>(); + const removedCallbacks: string[] = []; + const observedModules = new Map(); + spyOn(getModuleLoader(), 'onHotReload').and.callFake((ownerModule, path, callback) => { + observedModules.set(path, ownerModule); + reloadCallbacks.set(path, callback); + return () => { + removedCallbacks.push(path); + reloadCallbacks.delete(path); + }; + }); + const addHandler = spyOn(jsx, 'addCustomMessageHandler').and.callThrough(); + const oldWebModule: { path?: string } = {}; + const oldOwner = createDebuggerProviderOwner(oldWebModule, 'adapters/storage'); + const oldRegistration = oldOwner.register({ + handleRequest: () => jsonResult('{"owner":"old"}'), + id: 'owned', + kind: DebuggerProviderKind.Storage, + label: 'Old', + }); + registrations.push(oldRegistration); + const newWebModule: { path?: string } = {}; + const newOwner = createDebuggerProviderOwner(newWebModule, 'adapters/storage'); + const newRegistration = newOwner.register({ + handleRequest: () => jsonResult('{"owner":"new"}'), + id: 'owned', + kind: DebuggerProviderKind.Storage, + label: 'New', + }); + registrations.push(newRegistration); + const handler = addHandler.calls.mostRecent().args[0] as CustomMessageHandler; + + expect((await handler.messageReceived('ValdiDebuggerProviders', { action: 'list' })).providers).toEqual([ + jasmine.objectContaining({ id: 'owned', label: 'New' }), + ]); + expect(observedModules.get('adapters/storage')).toBe(newWebModule); + expect(newWebModule.path).toBeUndefined(); + expect(() => + oldOwner.register({ + handleRequest: () => jsonResult('{"owner":"stale"}'), + id: 'owned', + kind: DebuggerProviderKind.Storage, + label: 'Stale', + }), + ).toThrowError(/owner is disposed/); + reloadCallbacks.get('adapters/storage')!(); + const afterNewOwnerReload = await handler.messageReceived('ValdiDebuggerProviders', { action: 'list' }); + + expect(afterNewOwnerReload.providers).toEqual([]); + oldRegistration.notifyChange(); + expect((await handler.messageReceived('ValdiDebuggerProviders', { action: 'list' })).providers).toEqual([]); + expect(removedCallbacks.filter(key => key === 'adapters/storage').length).toBe(2); + }); + + it('observes the native module path when it differs from the stable replacement key', async () => { + const nativePath = 'test/native/StorageAdapter'; + const stableOwnerKey = 'storage-owner'; + const nativeLoader = new ModuleLoader(() => undefined, undefined, undefined, false); + nativeLoader.registerModule(nativePath, () => ({})); + nativeLoader.load(nativePath); + const originalModuleLoader = global.moduleLoader; + global.moduleLoader = nativeLoader; + const addHandler = spyOn(jsx, 'addCustomMessageHandler').and.callThrough(); + const owner = createDebuggerProviderOwner({ path: nativePath }, stableOwnerKey); + const registration = owner.register({ + handleRequest: () => jsonResult('{"owner":"native"}'), + id: 'native-owned', + kind: DebuggerProviderKind.Storage, + label: 'Native owner', + }); + registrations.push(registration); + const handler = addHandler.calls.mostRecent().args[0] as CustomMessageHandler; + let listAfterUnload: { providers: unknown[] } | undefined; + + try { + expect(nativeLoader.unload([stableOwnerKey], true, false)).toEqual([]); + expect((await handler.messageReceived('ValdiDebuggerProviders', { action: 'list' })).providers.length).toBe(1); + expect(nativeLoader.unload([nativePath], true, false)).toContain(nativePath); + listAfterUnload = await handler.messageReceived('ValdiDebuggerProviders', { action: 'list' }); + } finally { + global.moduleLoader = originalModuleLoader; + } + + expect(listAfterUnload!.providers).toEqual([]); + expect(() => + owner.register({ + handleRequest: () => jsonResult('{"owner":"stale"}'), + id: 'native-owned', + kind: DebuggerProviderKind.Storage, + label: 'Stale native owner', + }), + ).toThrowError(/owner is disposed/); + }); + + it('keeps exactly one bridge and invalidates old registrations across provider-module reload', async () => { + const addHandler = spyOn(jsx, 'addCustomMessageHandler').and.callThrough(); + const oldRegistration = registerDebuggerProvider({ + handleRequest: () => jsonResult('{"module":"old"}'), + id: 'module-provider', + kind: DebuggerProviderKind.Sql, + label: 'Old module', + }); + registrations.push(oldRegistration); + const handler = addHandler.calls.mostRecent().args[0] as CustomMessageHandler; + + reloadDebuggerProviderModuleForTesting(); + oldRegistration.notifyChange(); + oldRegistration.dispose(); + const newRegistration = registerDebuggerProvider({ + handleRequest: () => jsonResult('{"module":"new"}'), + id: 'module-provider', + kind: DebuggerProviderKind.Sql, + label: 'New module', + }); + registrations.push(newRegistration); + + const list = await handler.messageReceived('ValdiDebuggerProviders', { action: 'list' }); + const response = await handler.messageReceived('ValdiDebuggerProviders', { + action: 'request', + providerId: 'module-provider', + request: { action: 'list' }, + }); + + expect(addHandler).toHaveBeenCalledTimes(1); + expect(list.providers).toEqual([jasmine.objectContaining({ id: 'module-provider', label: 'New module' })]); + expect(response.data).toEqual({ module: 'new' }); + }); +});