From 3f4b923e94df4b98cb648ea3cb6e62dc523d13ae Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sat, 24 Oct 2026 20:39:28 +0800 Subject: [PATCH 1/2] feat: copy a report of each panel for an agent - Each panel has a copy button that puts a Markdown report on the clipboard, for an agent such as Claude Code to read. - The error report has the message, the stack mapped to the original source, and the source around the first frames in the app. Runs of frames in dependencies are one line. - The server function report has the status, time, headers and bodies of the call. Streamed bodies are decoded to text. - The ownership and reactivity reports have the selected node, its values, and the nodes around it. - Values with sensitive names, such as cookie, token or password, are redacted. - The clipboard falls back to execCommand on pages that are not a secure context. Co-Authored-By: Claude Opus 5.5 --- .changeset/copy-for-agent.md | 10 + README.md | 20 + .../error-viewer/create-stack-frame.ts | 161 ++++---- src/dev-toolbar/error-viewer/index.tsx | 9 + src/dev-toolbar/functions/SerovalViewer.tsx | 323 +-------------- src/dev-toolbar/functions/index.tsx | 12 + src/dev-toolbar/functions/seroval-node.ts | 326 +++++++++++++++ src/dev-toolbar/icons.tsx | 40 ++ src/dev-toolbar/ownership/index.tsx | 14 + src/dev-toolbar/reactivity/index.tsx | 21 +- src/dev-toolbar/report/CopyReportButton.tsx | 84 ++++ src/dev-toolbar/report/error-report.ts | 170 ++++++++ src/dev-toolbar/report/function-report.ts | 147 +++++++ src/dev-toolbar/report/markdown.ts | 79 ++++ src/dev-toolbar/report/owner-report.ts | 76 ++++ src/dev-toolbar/report/reactive-report.ts | 57 +++ src/dev-toolbar/report/redact.ts | 72 ++++ src/dev-toolbar/report/report.test.ts | 379 ++++++++++++++++++ src/dev-toolbar/report/seroval-text.ts | 201 ++++++++++ src/dev-toolbar/report/styles.css | 25 ++ src/dev-toolbar/report/value.ts | 104 +++++ tests/e2e/devtools.spec.ts | 48 +++ 22 files changed, 1985 insertions(+), 393 deletions(-) create mode 100644 .changeset/copy-for-agent.md create mode 100644 src/dev-toolbar/functions/seroval-node.ts create mode 100644 src/dev-toolbar/report/CopyReportButton.tsx create mode 100644 src/dev-toolbar/report/error-report.ts create mode 100644 src/dev-toolbar/report/function-report.ts create mode 100644 src/dev-toolbar/report/markdown.ts create mode 100644 src/dev-toolbar/report/owner-report.ts create mode 100644 src/dev-toolbar/report/reactive-report.ts create mode 100644 src/dev-toolbar/report/redact.ts create mode 100644 src/dev-toolbar/report/report.test.ts create mode 100644 src/dev-toolbar/report/seroval-text.ts create mode 100644 src/dev-toolbar/report/styles.css create mode 100644 src/dev-toolbar/report/value.ts diff --git a/.changeset/copy-for-agent.md b/.changeset/copy-for-agent.md new file mode 100644 index 0000000..ac345b1 --- /dev/null +++ b/.changeset/copy-for-agent.md @@ -0,0 +1,10 @@ +--- +'@solidjs/start-devtools': minor +--- + +Each panel has a button that copies a Markdown report for an agent such as Claude Code. + +- The error report has the message, the mapped stack and the source around the frames in the app. +- The server function report has the status, time, headers and bodies of the call. +- The ownership and reactivity reports have the selected node with its values and the nodes around it. +- Values with sensitive names, such as `cookie`, `token` or `password`, are redacted. diff --git a/README.md b/README.md index 4a04885..b6c225f 100644 --- a/README.md +++ b/README.md @@ -77,4 +77,24 @@ extra setup. Clicking it asks the dev server to open the file in your editor. The panel reads the tree through the development hooks in `solid-js`, so it is empty in a production build of the runtime. It only watches while it is open. +## Copy for an agent + +Each panel has a copy button that puts a Markdown report on the clipboard. Paste it into +an agent such as Claude Code, so it sees what the panel shows. + +- Errors. The message, the stack mapped to the original source, and the lines of source + around the first frames in the app. Runs of frames in dependencies are folded into one line. +- Server functions. The function, the status and time, and the headers and body of the + request and the response. A streamed body is read for half a second. +- Ownership. The component with its declared location, owner path, prop names, signals, + memos, effects and children. +- Reactivity. The signal, memo or effect with its state and value, what it reads, and what + reads it. + +Values with sensitive names are replaced with `[redacted]` before the report is copied. +This covers headers such as `cookie` and `authorization`, and keys, fields and signals +whose names contain words such as `password`, `token`, `secret` or `session`. Check a +report before you share it outside your machine, since a secret under another name is +copied as is. + For more inspection tools, see [Solid Devtools](https://github.com/thetarnav/solid-devtools). diff --git a/src/dev-toolbar/error-viewer/create-stack-frame.ts b/src/dev-toolbar/error-viewer/create-stack-frame.ts index 1c7ac3e..67a36d5 100644 --- a/src/dev-toolbar/error-viewer/create-stack-frame.ts +++ b/src/dev-toolbar/error-viewer/create-stack-frame.ts @@ -1,4 +1,4 @@ -import { originalPositionFor, sourceContentFor } from '@jridgewell/trace-mapping'; +import { originalPositionFor, sourceContentFor, type TraceMap } from '@jridgewell/trace-mapping'; import type { StackFrameLite } from 'error-stack-parser-es/lite'; import { type Accessor, createMemo } from 'solid-js'; import getSourceMap from './get-source-map.js'; @@ -28,85 +28,106 @@ function getActualFileSource(path: string): string { return path; } -export function createStackFrame(stackframe: StackFrameLite, isCompiled: () => boolean) { - const data = createMemo(async () => { - const source = { - fileName: stackframe.file, - line: stackframe.line, - column: stackframe.col, - functionName: stackframe.function, - }; - if (!source.fileName) { +export interface LoadedStackFrame { + source: { + fileName: string; + line: number | undefined; + column: number | undefined; + functionName: string | undefined; + }; + content: string; + sourceMap: TraceMap | null; + isServer: boolean; +} + +/** Fetches the file of a frame and its source map. `null` when the file is unreachable. */ +export async function loadStackFrame(stackframe: StackFrameLite): Promise { + const fileName = stackframe.file; + if (!fileName) { + return null; + } + const source = { + fileName, + line: stackframe.line, + column: stackframe.col, + functionName: stackframe.function, + }; + // Sources can be unreachable — node internals, extension scripts, + // files outside the dev server's allowlist. Treat any failure as + // "no source" instead of throwing into the error boundary. + try { + const url = getActualFileSource(fileName); + const response = await fetch(url); + if (!response.ok) { return null; } - // Sources can be unreachable — node internals, extension scripts, - // files outside the dev server's allowlist. Treat any failure as - // "no source" instead of throwing into the error boundary. - try { - const url = getActualFileSource(source.fileName); - const response = await fetch(url); - if (!response.ok) { - return null; + const content = await response.text(); + const sourceMap = await getSourceMap(url, content); + return { + source, + content, + sourceMap, + isServer: isServerSource(fileName), + }; + } catch (error) { + console.warn('[solid dev toolbar] failed to load source for stack frame', error); + return null; + } +} + +/** The position and content of a loaded frame, in the original source unless `isCompiled`. */ +export function mapStackFrame(current: LoadedStackFrame, isCompiled: boolean): StackFrameSource { + const { source, content, sourceMap, isServer } = current; + + if (!isCompiled && source.line && source.column && sourceMap) { + if (isServer) { + // The position is already original; only the original content needs + // to be pulled out of the source map. + const originalContent = + sourceMap.sources.length && sourceMap.sources[0] != null + ? sourceContentFor(sourceMap, sourceMap.sources[0]) + : null; + if (originalContent) { + return { + source: source.fileName, + line: source.line, + column: source.column, + name: source.functionName, + content: originalContent, + } as StackFrameSource; + } + } else { + const result = originalPositionFor(sourceMap, { + line: source.line, + column: source.column, + }); + if (result.source) { + return { + ...result, + content: sourceContentFor(sourceMap, result.source), + } as StackFrameSource; } - const content = await response.text(); - const sourceMap = await getSourceMap(url, content); - return { - source, - content, - sourceMap, - isServer: isServerSource(source.fileName), - }; - } catch (error) { - console.warn('[solid dev toolbar] failed to load source for stack frame', error); - return null; } - }); + } + + return { + source: source.fileName, + line: source.line, + column: source.column, + name: source.functionName, + content, + } as StackFrameSource; +} + +export function createStackFrame(stackframe: StackFrameLite, isCompiled: () => boolean) { + const data = createMemo(() => loadStackFrame(stackframe)); const info = createMemo(() => { const current = data(); if (!current) { return undefined; } - const { source, content, sourceMap, isServer } = current; - - if (!isCompiled() && source.line && source.column && sourceMap) { - if (isServer) { - // The position is already original; only the original content needs - // to be pulled out of the source map. - const originalContent = - sourceMap.sources.length && sourceMap.sources[0] != null - ? sourceContentFor(sourceMap, sourceMap.sources[0]) - : null; - if (originalContent) { - return { - source: source.fileName, - line: source.line, - column: source.column, - name: source.functionName, - content: originalContent, - } as StackFrameSource; - } - } else { - const result = originalPositionFor(sourceMap, { - line: source.line, - column: source.column, - }); - if (result.source) { - return { - ...result, - content: sourceContentFor(sourceMap, result.source), - } as StackFrameSource; - } - } - } - - return { - source: source.fileName, - line: source.line, - column: source.column, - name: source.functionName, - content, - } as StackFrameSource; + return mapStackFrame(current, isCompiled()); }); return info as Accessor; diff --git a/src/dev-toolbar/error-viewer/index.tsx b/src/dev-toolbar/error-viewer/index.tsx index 91f939d..3e0e7f3 100644 --- a/src/dev-toolbar/error-viewer/index.tsx +++ b/src/dev-toolbar/error-viewer/index.tsx @@ -17,6 +17,9 @@ import { ViewCompiledIcon, ViewOriginalIcon, } from '../icons.js'; +import { CopyReportButton } from '../report/CopyReportButton.js'; +import { errorReport, resolveErrorFrames } from '../report/error-report.js'; +import { pageRoute } from '../report/markdown.js'; import { CodeView } from './CodeView.js'; import { createStackFrame, type StackFrameSource } from './create-stack-frame.js'; import download from './download.js'; @@ -271,6 +274,11 @@ export default function ErrorViewer(props: ErrorViewerProps): JSX.Element { window.open(url, '_blank')!.focus(); } + async function copyErrorReport(): Promise { + const error = props.errors[truncated() - 1]; + return errorReport({ error, frames: await resolveErrorFrames(error), route: pageRoute() }); + } + function redirectToDiscord() { window.open(DISCORD_INVITE, '_blank')!.focus(); } @@ -304,6 +312,7 @@ export default function ErrorViewer(props: ErrorViewerProps): JSX.Element {
+ diff --git a/src/dev-toolbar/functions/SerovalViewer.tsx b/src/dev-toolbar/functions/SerovalViewer.tsx index d0303f2..0fc8b64 100644 --- a/src/dev-toolbar/functions/SerovalViewer.tsx +++ b/src/dev-toolbar/functions/SerovalViewer.tsx @@ -6,6 +6,7 @@ import { ChunkReader } from '@solidjs/web/server-functions/client'; import { Badge } from '../../ui/Badge.js'; import { HexViewer } from './HexViewer.js'; import { TreeBranch, TreeKey, TreeLeaf, TreeMark, ValueToken } from '../../ui/ValueTree.js'; +import { getConstantValue, getNodeType, getSymbolValue, traverse } from './seroval-node.js'; import './SerovalViewer.css'; @@ -32,328 +33,6 @@ export interface SerovalViewerProps { stream: Request | Response; } -function getNodeType(node: SerovalNode) { - switch (node.t) { - // Number = 0, - case 0: - return 'number'; - // String = 1, - case 1: - return 'string'; - // Constant = 2, - case 2: - switch (node.s) { - case 0: - return 'null'; - case 1: - return 'undefined'; - case 2: - return 'true'; - case 3: - return 'false'; - case 4: - return '-0'; - case 5: - return 'Infinity'; - case 6: - return '-Infinity'; - case 7: - return 'NaN'; - } - break; - // BigInt = 3, - case 3: - return 'bigint'; - // Date = 5, - case 5: - return 'Date'; - // RegExp = 6, - case 6: - return 'RegExp'; - // Set = 7, - case 7: - return 'Set'; - // Map = 8, - case 8: - return 'Map'; - // Array = 9, - case 9: - return 'Array'; - // Object = 10, - case 10: - // NullConstructor = 11, - case 11: - return 'Object'; - // Promise = 12, - case 12: - return 'Promise'; - // Error = 13, - case 13: - switch (node.s) { - case 0: - return 'Error'; - case 1: - return 'EvalError'; - case 2: - return 'RangeError'; - case 3: - return 'ReferenceError'; - case 4: - return 'SyntaxError'; - case 5: - return 'TypeError'; - case 6: - return 'URIError'; - } - return 'Error'; - // AggregateError = 14, - case 14: - return 'AggregateError'; - // TypedArray = 15, - case 15: - // BigIntTypedArray = 16, - case 16: - return node.c; - // WKSymbol = 17, - case 17: - return 'symbol'; - // ArrayBuffer = 19, - case 19: - return 'ArrayBuffer'; - // DataView = 20, - case 20: - return 'DataView'; - // Boxed = 21, - case 21: - return 'Boxed'; - // PromiseConstructor = 22, - case 22: - return 'Promise'; - // Plugin = 25, - case 25: - return 'Plugin'; - // IteratorFactoryInstance = 28, - case 28: - return 'Iterator'; - // AsyncIteratorFactoryInstance = 30, - case 30: - return 'AsyncIterator'; - // StreamConstructor = 31, - case 31: - return 'Stream'; - case 35: - return 'Sequence'; - } - return 'unknown'; -} - -function traverse(node: SerovalNode, handler: (node: SerovalNode) => void): void { - handler(node); - switch (node.t) { - // Number = 0, - case 0: - // String = 1, - case 1: - // Constant = 2, - case 2: - // BigInt = 3, - case 3: - // IndexedValue = 4, - case 4: - // Date = 5, - case 5: - // RegExp = 6, - case 6: - break; - // Set = 7, - case 7: - for (const child of node.a) { - traverse(child, handler); - } - break; - // Map = 8, - case 8: - for (const key of node.e.k) { - traverse(key, handler); - } - for (const value of node.e.v) { - traverse(value, handler); - } - break; - // Array = 9, - case 9: - for (const child of node.a) { - if (child) { - traverse(child, handler); - } - } - break; - // Object = 10, - case 10: - // NullConstructor = 11, - case 11: - for (const child of node.p.k) { - if (typeof child !== 'string') { - traverse(child, handler); - } - } - for (const child of node.p.v) { - traverse(child, handler); - } - break; - // Promise = 12, - case 12: - traverse(node.f, handler); - break; - // Error = 13, - case 13: - // AggregateError = 14, - case 14: - if (node.p) { - for (const child of node.p.k) { - if (typeof child !== 'string') { - traverse(child, handler); - } - } - for (const child of node.p.v) { - traverse(child, handler); - } - } - break; - // TypedArray = 15, - case 15: - // BigIntTypedArray = 16, - case 16: - traverse(node.f, handler); - break; - // WKSymbol = 17, - case 17: - // Reference = 18, - case 18: - break; - // ArrayBuffer = 19, - case 19: - // DataView = 20, - case 20: - // Boxed = 21, - case 21: - traverse(node.f, handler); - break; - // PromiseConstructor = 22, - case 22: - break; - // PromiseSuccess = 23, - case 23: - // PromiseFailure = 24, - case 24: - traverse(node.a[1], handler); - break; - // Plugin = 25, - case 25: - for (const key in node.s) { - const current = node.s[key]; - if (current) { - traverse(current, handler); - } - } - break; - // SpecialReference = 26, - case 26: - break; - // IteratorFactory = 27, - case 27: - traverse(node.f, handler); - break; - // IteratorFactoryInstance = 28, - case 28: - traverse(node.a[0], handler); - traverse(node.a[1], handler); - break; - // AsyncIteratorFactory = 29, - case 29: - traverse(node.a[1], handler); - break; - // AsyncIteratorFactoryInstance = 30, - case 30: - traverse(node.a[0], handler); - traverse(node.a[1], handler); - break; - // StreamConstructor = 31, - case 31: - for (const child of node.a) { - traverse(child, handler); - } - break; - // StreamNext = 32, - case 32: - // StreamThrow = 33, - case 33: - // StreamReturn = 34 - case 34: - traverse(node.f, handler); - break; - case 35: - for (const child of node.a) { - if (child) { - traverse(child, handler); - } - } - break; - } -} - -function getConstantValue(value: number) { - switch (value) { - case 0: - return 'null'; - case 1: - return 'undefined'; - case 2: - return 'true'; - case 3: - return 'false'; - case 4: - return '-0'; - case 5: - return 'Infinity'; - case 6: - return '-Infinity'; - case 7: - return 'NaN'; - } - return ''; -} - -function getSymbolValue(value: number) { - switch (value) { - case 0: - return 'Symbol.asyncIterator'; - case 1: - return 'Symbol.hasInstance'; - case 2: - return 'Symbol.isConcatSpreadable'; - case 3: - return 'Symbol.iterator'; - case 4: - return 'Symbol.match'; - case 5: - return 'Symbol.matchAll'; - case 6: - return 'Symbol.replace'; - case 7: - return 'Symbol.search'; - case 8: - return 'Symbol.species'; - case 9: - return 'Symbol.toPrimitive'; - case 10: - return 'Symbol.toStringTag'; - case 11: - return 'Symbol.unscopables'; - } - return ''; -} - function getObjectFlag(value: number) { switch (value) { case 1: diff --git a/src/dev-toolbar/functions/index.tsx b/src/dev-toolbar/functions/index.tsx index a3e51a7..43477b2 100644 --- a/src/dev-toolbar/functions/index.tsx +++ b/src/dev-toolbar/functions/index.tsx @@ -9,6 +9,9 @@ import { Select, SelectOption } from '../../ui/Select.js'; import { Tab, TabGroup, TabList, TabPanel } from '../../ui/Tabs.js'; import { Text } from '../../ui/Text.js'; import { TrashIcon } from '../icons.js'; +import { CopyReportButton } from '../report/CopyReportButton.js'; +import { readReportBody, serverFunctionReport } from '../report/function-report.js'; +import { pageRoute } from '../report/markdown.js'; import { BlobViewer } from './BlobViewer.js'; import { FormDataViewer } from './FormDataViewer.js'; import { HeadersViewer } from './HeadersViewer.js'; @@ -284,6 +287,14 @@ interface ServerFunctionInstanceViewerProps { onDelete: () => void; } +async function copyCallReport(instance: ServerFunctionInstance): Promise { + const [requestBody, responseBody] = await Promise.all([ + readReportBody(instance.request.source), + instance.response ? readReportBody(instance.response.source) : undefined, + ]); + return serverFunctionReport({ ...instance, requestBody, responseBody, route: pageRoute() }); +} + function ServerFunctionInstanceViewer(props: ServerFunctionInstanceViewerProps): JSX.Element { const [tab, setTab] = createSignal<'request' | 'response'>('request'); return ( @@ -304,6 +315,7 @@ function ServerFunctionInstanceViewer(props: ServerFunctionInstanceViewerProps): )} + copyCallReport(props.instance)} /> diff --git a/src/dev-toolbar/functions/seroval-node.ts b/src/dev-toolbar/functions/seroval-node.ts new file mode 100644 index 0000000..e63be8c --- /dev/null +++ b/src/dev-toolbar/functions/seroval-node.ts @@ -0,0 +1,326 @@ +import type { SerovalNode } from 'seroval'; + +// Readers of the JSON nodes of a seroval stream, shared by the body viewer and +// the reports. + +export function getNodeType(node: SerovalNode) { + switch (node.t) { + // Number = 0, + case 0: + return 'number'; + // String = 1, + case 1: + return 'string'; + // Constant = 2, + case 2: + switch (node.s) { + case 0: + return 'null'; + case 1: + return 'undefined'; + case 2: + return 'true'; + case 3: + return 'false'; + case 4: + return '-0'; + case 5: + return 'Infinity'; + case 6: + return '-Infinity'; + case 7: + return 'NaN'; + } + break; + // BigInt = 3, + case 3: + return 'bigint'; + // Date = 5, + case 5: + return 'Date'; + // RegExp = 6, + case 6: + return 'RegExp'; + // Set = 7, + case 7: + return 'Set'; + // Map = 8, + case 8: + return 'Map'; + // Array = 9, + case 9: + return 'Array'; + // Object = 10, + case 10: + // NullConstructor = 11, + case 11: + return 'Object'; + // Promise = 12, + case 12: + return 'Promise'; + // Error = 13, + case 13: + switch (node.s) { + case 0: + return 'Error'; + case 1: + return 'EvalError'; + case 2: + return 'RangeError'; + case 3: + return 'ReferenceError'; + case 4: + return 'SyntaxError'; + case 5: + return 'TypeError'; + case 6: + return 'URIError'; + } + return 'Error'; + // AggregateError = 14, + case 14: + return 'AggregateError'; + // TypedArray = 15, + case 15: + // BigIntTypedArray = 16, + case 16: + return node.c; + // WKSymbol = 17, + case 17: + return 'symbol'; + // ArrayBuffer = 19, + case 19: + return 'ArrayBuffer'; + // DataView = 20, + case 20: + return 'DataView'; + // Boxed = 21, + case 21: + return 'Boxed'; + // PromiseConstructor = 22, + case 22: + return 'Promise'; + // Plugin = 25, + case 25: + return 'Plugin'; + // IteratorFactoryInstance = 28, + case 28: + return 'Iterator'; + // AsyncIteratorFactoryInstance = 30, + case 30: + return 'AsyncIterator'; + // StreamConstructor = 31, + case 31: + return 'Stream'; + case 35: + return 'Sequence'; + } + return 'unknown'; +} + +export function traverse(node: SerovalNode, handler: (node: SerovalNode) => void): void { + handler(node); + switch (node.t) { + // Number = 0, + case 0: + // String = 1, + case 1: + // Constant = 2, + case 2: + // BigInt = 3, + case 3: + // IndexedValue = 4, + case 4: + // Date = 5, + case 5: + // RegExp = 6, + case 6: + break; + // Set = 7, + case 7: + for (const child of node.a) { + traverse(child, handler); + } + break; + // Map = 8, + case 8: + for (const key of node.e.k) { + traverse(key, handler); + } + for (const value of node.e.v) { + traverse(value, handler); + } + break; + // Array = 9, + case 9: + for (const child of node.a) { + if (child) { + traverse(child, handler); + } + } + break; + // Object = 10, + case 10: + // NullConstructor = 11, + case 11: + for (const child of node.p.k) { + if (typeof child !== 'string') { + traverse(child, handler); + } + } + for (const child of node.p.v) { + traverse(child, handler); + } + break; + // Promise = 12, + case 12: + traverse(node.f, handler); + break; + // Error = 13, + case 13: + // AggregateError = 14, + case 14: + if (node.p) { + for (const child of node.p.k) { + if (typeof child !== 'string') { + traverse(child, handler); + } + } + for (const child of node.p.v) { + traverse(child, handler); + } + } + break; + // TypedArray = 15, + case 15: + // BigIntTypedArray = 16, + case 16: + traverse(node.f, handler); + break; + // WKSymbol = 17, + case 17: + // Reference = 18, + case 18: + break; + // ArrayBuffer = 19, + case 19: + // DataView = 20, + case 20: + // Boxed = 21, + case 21: + traverse(node.f, handler); + break; + // PromiseConstructor = 22, + case 22: + break; + // PromiseSuccess = 23, + case 23: + // PromiseFailure = 24, + case 24: + traverse(node.a[1], handler); + break; + // Plugin = 25, + case 25: + for (const key in node.s) { + const current = node.s[key]; + if (current) { + traverse(current, handler); + } + } + break; + // SpecialReference = 26, + case 26: + break; + // IteratorFactory = 27, + case 27: + traverse(node.f, handler); + break; + // IteratorFactoryInstance = 28, + case 28: + traverse(node.a[0], handler); + traverse(node.a[1], handler); + break; + // AsyncIteratorFactory = 29, + case 29: + traverse(node.a[1], handler); + break; + // AsyncIteratorFactoryInstance = 30, + case 30: + traverse(node.a[0], handler); + traverse(node.a[1], handler); + break; + // StreamConstructor = 31, + case 31: + for (const child of node.a) { + traverse(child, handler); + } + break; + // StreamNext = 32, + case 32: + // StreamThrow = 33, + case 33: + // StreamReturn = 34 + case 34: + traverse(node.f, handler); + break; + case 35: + for (const child of node.a) { + if (child) { + traverse(child, handler); + } + } + break; + } +} + +export function getConstantValue(value: number) { + switch (value) { + case 0: + return 'null'; + case 1: + return 'undefined'; + case 2: + return 'true'; + case 3: + return 'false'; + case 4: + return '-0'; + case 5: + return 'Infinity'; + case 6: + return '-Infinity'; + case 7: + return 'NaN'; + } + return ''; +} + +export function getSymbolValue(value: number) { + switch (value) { + case 0: + return 'Symbol.asyncIterator'; + case 1: + return 'Symbol.hasInstance'; + case 2: + return 'Symbol.isConcatSpreadable'; + case 3: + return 'Symbol.iterator'; + case 4: + return 'Symbol.match'; + case 5: + return 'Symbol.matchAll'; + case 6: + return 'Symbol.replace'; + case 7: + return 'Symbol.search'; + case 8: + return 'Symbol.species'; + case 9: + return 'Symbol.toPrimitive'; + case 10: + return 'Symbol.toStringTag'; + case 11: + return 'Symbol.unscopables'; + } + return ''; +} diff --git a/src/dev-toolbar/icons.tsx b/src/dev-toolbar/icons.tsx index c4670ae..0ded254 100644 --- a/src/dev-toolbar/icons.tsx +++ b/src/dev-toolbar/icons.tsx @@ -681,3 +681,43 @@ export function MinimizeIcon(props: JSX.IntrinsicElements['svg'] & { title: stri ); } + +export function CopyIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} + +export function CheckIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} diff --git a/src/dev-toolbar/ownership/index.tsx b/src/dev-toolbar/ownership/index.tsx index f0e7e47..e89a337 100644 --- a/src/dev-toolbar/ownership/index.tsx +++ b/src/dev-toolbar/ownership/index.tsx @@ -5,6 +5,9 @@ import IconButton from '../../ui/IconButton.js'; import Placeholder from '../../ui/Placeholder.js'; import { Text } from '../../ui/Text.js'; import { CollapseIcon, ExpandIcon, EyeIcon, PauseIcon, PlayIcon } from '../icons.js'; +import { CopyReportButton } from '../report/CopyReportButton.js'; +import { pageRoute } from '../report/markdown.js'; +import { ownerReport } from '../report/owner-report.js'; import { previewValue, typeName } from './format.js'; import { excludeOwner, @@ -354,6 +357,17 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen + + ownerReport({ + node: node(), + ancestry: ancestry(), + nodes: byId(), + route: pageRoute(), + }) + } + />
diff --git a/src/dev-toolbar/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx index 31ae11a..837694b 100644 --- a/src/dev-toolbar/reactivity/index.tsx +++ b/src/dev-toolbar/reactivity/index.tsx @@ -5,6 +5,9 @@ import IconButton from '../../ui/IconButton.js'; import Placeholder from '../../ui/Placeholder.js'; import { Text } from '../../ui/Text.js'; import { FitIcon, PauseIcon, PlayIcon } from '../icons.js'; +import { CopyReportButton } from '../report/CopyReportButton.js'; +import { pageRoute } from '../report/markdown.js'; +import { reactiveReport } from '../report/reactive-report.js'; import { formatValue, typeName } from './format.js'; import { ARROW_SIZE, @@ -59,7 +62,12 @@ function ownerText(node: ReactiveNode): string { return node.ownerPath.length > 0 ? node.ownerPath.join(' › ') : 'unnamed owner'; } -function NodeSummary(props: { node: ReactiveNode; onOwner?: () => void }): JSX.Element { +function NodeSummary(props: { + node: ReactiveNode; + onOwner?: () => void; + /** Adds a button that copies a report of the node. */ + onReport?: () => string; +}): JSX.Element { return ( <>
@@ -68,6 +76,9 @@ function NodeSummary(props: { node: ReactiveNode; onOwner?: () => void }): JSX.E {props.node.name} {stateBadge(props.node)} + + {(report) => } +
value @@ -647,6 +658,14 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem
+ reactiveReport({ + node: node(), + // Every node, so a source the filter hides is still named. + nodes: new Map(graph().nodes.map((item) => [item.id, item])), + route: pageRoute(), + }) + } onOwner={ props.onViewOwner ? () => { diff --git a/src/dev-toolbar/report/CopyReportButton.tsx b/src/dev-toolbar/report/CopyReportButton.tsx new file mode 100644 index 0000000..5fc8160 --- /dev/null +++ b/src/dev-toolbar/report/CopyReportButton.tsx @@ -0,0 +1,84 @@ +import type { JSX } from '@solidjs/web'; +import { createSignal, onCleanup, Show } from 'solid-js'; +import IconButton from '../../ui/IconButton.js'; +import { CheckIcon, CopyIcon } from '../icons.js'; +import './styles.css'; + +/** How long the button shows that the report was copied. */ +const COPIED_MS = 1500; + +/** + * Writes text to the clipboard. The Clipboard API needs a secure context, and + * a dev server opened by its network address is not one, so a selected + * textarea and `execCommand` take over there. + */ +export async function copyText(text: string): Promise { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return; + } + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.append(area); + try { + area.select(); + if (!document.execCommand('copy')) throw new Error('The browser refused to copy.'); + } finally { + area.remove(); + } +} + +export interface CopyReportButtonProps { + /** Builds the report when the button is pressed. */ + report: () => string | Promise; + /** What the button copies, such as "this error". */ + label: string; +} + +/** Copies a Markdown report for an agent, such as Claude Code, to read. */ +export function CopyReportButton(props: CopyReportButtonProps): JSX.Element { + const [state, setState] = createSignal<'idle' | 'busy' | 'copied' | 'failed'>('idle'); + let timer: ReturnType | undefined; + onCleanup(() => clearTimeout(timer)); + + async function copy() { + if (state() === 'busy') return; + setState('busy'); + try { + await copyText(await props.report()); + setState('copied'); + } catch (error) { + console.warn('[solid dev toolbar] failed to copy the report', error); + setState('failed'); + } + clearTimeout(timer); + timer = setTimeout(() => setState('idle'), COPIED_MS); + } + + const title = () => { + switch (state()) { + case 'copied': + return 'Copied'; + case 'failed': + return 'Could not copy, see the console'; + default: + return `Copy ${props.label} for an agent`; + } + }; + + return ( + + }> + + + + ); +} diff --git a/src/dev-toolbar/report/error-report.ts b/src/dev-toolbar/report/error-report.ts new file mode 100644 index 0000000..f0a839f --- /dev/null +++ b/src/dev-toolbar/report/error-report.ts @@ -0,0 +1,170 @@ +import { parse as parseErrorStack } from 'error-stack-parser-es/lite'; +import { + loadStackFrame, + mapStackFrame, + type StackFrameSource, +} from '../error-viewer/create-stack-frame.js'; +import { code, displayPath, excerpt, fence, languageOf } from './markdown.js'; +import { redactText, redactUrl, truncateText } from './redact.js'; + +/** One frame of the stack, in the original source when its source map was found. */ +export interface ReportFrame { + name: string | undefined; + file: string | undefined; + line: number | undefined; + column: number | undefined; + /** The content of the file, when the dev server served it. */ + content: string | undefined; +} + +export interface ErrorReportInput { + error: unknown; + frames: ReportFrame[]; + /** The path and query of the page. */ + route: string; +} + +/** How many frames get the lines of source around them. */ +const EXCERPT_FRAMES = 3; + +function describeError(error: unknown): { name?: string; message: string } { + if (error instanceof Error) return { name: error.name, message: error.message }; + if (typeof ErrorEvent === 'function' && error instanceof ErrorEvent) { + return { message: error.message }; + } + return { message: String(error) }; +} + +function frameLocation(frame: ReportFrame): string { + if (!frame.file) return ''; + let text = displayPath(frame.file); + if (frame.line) text += `:${frame.line}`; + if (frame.line && frame.column) text += `:${frame.column}`; + return text; +} + +function isDependency(frame: ReportFrame): boolean { + return !!frame.file && /\/node_modules\/|\/\.vite\/deps\//.test(frame.file); +} + +/** The package a dependency frame is in, such as `solid-js`. */ +function packageOf(frame: ReportFrame): string { + const path = displayPath(frame.file!); + const packages = Array.from(path.matchAll(/node_modules\/((?:@[^/]+\/)?[^/]+)/g), (m) => m[1]!); + const last = packages.filter((name) => name !== '.pnpm').pop(); + return last ?? path.slice(path.lastIndexOf('/') + 1); +} + +function frameLine(frame: ReportFrame): string { + return `- ${code(frame.name ?? '')} at ${code(frameLocation(frame))}`; +} + +/** + * The stack, top first. A run of frames in dependencies, such as the + * runtime calling a component, is one line that names the packages. + */ +function stackLines(frames: ReportFrame[]): string[] { + const out: string[] = []; + for (let index = 0; index < frames.length;) { + let end = index; + while (end < frames.length && isDependency(frames[end]!)) end++; + if (end - index > 1) { + const packages = [...new Set(frames.slice(index, end).map(packageOf))]; + out.push(`- … ${end - index} frames in dependencies: ${packages.map(code).join(', ')}`); + index = end; + } else { + const frame = frames[index]!; + out.push(`${frameLine(frame)}${isDependency(frame) ? ' (dependency)' : ''}`); + index++; + } + } + return out; +} + +/** + * The frames whose source goes into the report: the first ones in the app, + * or the first one at all when every frame is in a dependency. + */ +function excerptFrames(frames: ReportFrame[]): ReportFrame[] { + const readable = frames.filter((frame) => frame.content && frame.line); + const app = readable.filter((frame) => !isDependency(frame)); + return (app.length ? app : readable).slice(0, EXCERPT_FRAMES); +} + +/** A Markdown report of an error, for an agent to read. */ +export function errorReport(input: ErrorReportInput): string { + const { name, message } = describeError(input.error); + const out: string[] = ['# Error in a Solid app', '']; + out.push(fence(redactText(name ? `${name}: ${message}` : message)), ''); + out.push(`- Page: ${code(redactUrl(input.route))}`); + + if (input.frames.length) { + out.push('', '## Stack', ''); + out.push(...stackLines(input.frames)); + + const shown = excerptFrames(input.frames); + if (shown.length) { + out.push('', '## Source'); + for (const frame of shown) { + out.push( + '', + `### ${code(frameLocation(frame))}`, + '', + fence(excerpt(frame.content!, frame.line!, frame.column), languageOf(frame.file!)), + ); + } + } + } + + const cause = input.error instanceof Error ? input.error.cause : undefined; + if (cause !== undefined) { + const text = cause instanceof Error ? (cause.stack ?? cause.message) : String(cause); + out.push('', '## Cause', '', fence(truncateText(redactText(text)))); + } + + return out.join('\n'); +} + +/** + * A source map names its sources relative to the file it maps, such as + * `app.tsx` or `../../.pnpm/…`. Resolved against the URL of the frame, they + * become paths from the root of the dev server. + */ +function resolveSource(source: string, frameFile: string | undefined): string { + if (!frameFile) return source; + try { + return new URL(source, frameFile).href; + } catch { + return source; + } +} + +function toReportFrame(source: StackFrameSource | undefined, fallback: ReportFrame): ReportFrame { + if (!source) return fallback; + return { + name: source.name ?? fallback.name, + file: resolveSource(source.source, fallback.file), + line: source.line, + column: source.column, + content: source.content, + }; +} + +/** Parses the stack of an error and maps each frame to its original source. */ +export async function resolveErrorFrames(error: unknown): Promise { + if (!(error instanceof Error)) return []; + const frames = parseErrorStack(error, { allowEmpty: true }); + return Promise.all( + frames.map(async (frame) => { + const raw: ReportFrame = { + name: frame.function, + file: frame.file, + line: frame.line, + column: frame.col, + content: undefined, + }; + const loaded = await loadStackFrame(frame); + return toReportFrame(loaded ? mapStackFrame(loaded, false) : undefined, raw); + }), + ); +} diff --git a/src/dev-toolbar/report/function-report.ts b/src/dev-toolbar/report/function-report.ts new file mode 100644 index 0000000..561ccf9 --- /dev/null +++ b/src/dev-toolbar/report/function-report.ts @@ -0,0 +1,147 @@ +import { BODY_FORMAT_FILE_KEY, BODY_FORMAT_KEY, BodyFormat } from '../functions/body-format.js'; +import type { ServerFunctionRequest, ServerFunctionResponse } from '../functions/tracker.js'; +import { code, fence } from './markdown.js'; +import { + isSensitiveName, + REDACTED, + redactHeaders, + redactJson, + redactText, + redactUrl, + truncateText, +} from './redact.js'; +import { serovalBodyText } from './seroval-text.js'; + +export interface ReportBody { + /** What the body is, such as `JSON` or `form data`. */ + format: string; + text: string; + /** The fence language of the text. */ + language?: string; +} + +export interface ServerFunctionReportInput { + request: ServerFunctionRequest; + response?: ServerFunctionResponse; + requestBody?: ReportBody; + responseBody?: ReportBody; + /** The path and query of the page. */ + route: string; +} + +function blobText(blob: Blob, label: string): string { + const name = blob instanceof File ? ` ${JSON.stringify(blob.name)}` : ''; + return `<${label}${name}, ${blob.size} bytes${blob.type ? `, ${blob.type}` : ''}>`; +} + +function formDataText(data: FormData): string { + const lines: string[] = []; + data.forEach((value, key) => { + if (isSensitiveName(key)) lines.push(`${key}: ${REDACTED}`); + else if (typeof value === 'string') lines.push(`${key}: ${JSON.stringify(value)}`); + else lines.push(`${key}: ${blobText(value, 'File')}`); + }); + return lines.join('\n'); +} + +function jsonText(text: string): ReportBody { + try { + return { + format: 'JSON', + text: JSON.stringify(redactJson(JSON.parse(text)), null, 2), + language: 'json', + }; + } catch { + return { format: 'text', text: redactText(text) }; + } +} + +async function readBody(source: Request | Response): Promise { + const format = source.headers.get(BODY_FORMAT_KEY); + const contentType = source.headers.get('Content-Type') ?? ''; + switch (true) { + case format === BodyFormat.Serialized: + return { format: 'seroval', text: redactText(await serovalBodyText(source.body!)) }; + case format === BodyFormat.Json: + case contentType.startsWith('application/json'): + return jsonText(await source.text()); + case format === BodyFormat.String: + return { format: 'string', text: redactText(await source.text()) }; + case format === BodyFormat.File: { + const file = (await source.formData()).get(BODY_FORMAT_FILE_KEY); + return { format: 'file', text: file instanceof File ? blobText(file, 'File') : '' }; + } + case format === BodyFormat.FormData: + case contentType.startsWith('multipart/form-data'): + return { format: 'form data', text: formDataText(await source.formData()) }; + case format === BodyFormat.URLSearchParams: + case contentType.startsWith('application/x-www-form-urlencoded'): + return { format: 'URL search params', text: redactUrl(`?${await source.text()}`).slice(1) }; + case format === BodyFormat.Blob: + case format === BodyFormat.ArrayBuffer: + case format === BodyFormat.Uint8Array: + return { format: 'binary', text: blobText(await source.blob(), 'Binary') }; + case contentType.startsWith('text/'): + case contentType === '': + return { format: 'text', text: redactText(await source.text()) }; + } + return { format: contentType, text: blobText(await source.blob(), 'Binary') }; +} + +/** + * The body of a request or response, read from a clone so the panel can still + * read the original. `undefined` when there is no body, or when it cannot be + * read. + */ +export async function readReportBody(source: Request | Response): Promise { + if (!source.body || source.bodyUsed) return undefined; + try { + const body = await readBody(source.clone()); + return body && { ...body, text: truncateText(body.text) }; + } catch { + return undefined; + } +} + +function failureOf(request: ServerFunctionRequest): string | undefined { + const error = request.meta?.error; + if (error === undefined) return undefined; + return error instanceof Error ? error.message : String(error); +} + +function bodySection(out: string[], body: ReportBody | undefined): void { + if (!body) return; + out.push('', `Body (${body.format}):`, '', fence(body.text, body.language)); +} + +/** A Markdown report of a server function call, for an agent to read. */ +export function serverFunctionReport(input: ServerFunctionReportInput): string { + const { request, response } = input; + const name = request.meta?.name ?? request.id; + const out: string[] = [`# Server function call: ${code(name)}`, '']; + + out.push(`- Function id: ${code(request.id)}`); + out.push(`- Page: ${code(redactUrl(input.route))}`); + out.push(`- Request: ${code(`${request.source.method} ${redactUrl(request.source.url)}`)}`); + const failure = failureOf(request); + if (response) { + const status = `${response.source.status} ${response.source.statusText}`.trim(); + out.push(`- Response: ${code(status)}${response.source.ok ? '' : ' (failed)'}`); + out.push(`- Time: ${((response.time - request.time) / 1000).toFixed(2)}s`); + } else if (failure !== undefined) { + out.push(`- Response: none, the request failed: ${redactText(failure)}`); + } else { + out.push('- Response: still pending'); + } + + out.push('', '## Request', '', 'Headers:', '', fence(redactHeaders(request.source.headers))); + bodySection(out, input.requestBody); + + if (response) { + out.push('', '## Response', '', 'Headers:', '', fence(redactHeaders(response.source.headers))); + bodySection(out, input.responseBody); + } + + out.push('', 'Values of headers, keys and fields with sensitive names are redacted.'); + return out.join('\n'); +} diff --git a/src/dev-toolbar/report/markdown.ts b/src/dev-toolbar/report/markdown.ts new file mode 100644 index 0000000..df7bb15 --- /dev/null +++ b/src/dev-toolbar/report/markdown.ts @@ -0,0 +1,79 @@ +const LANGUAGES: Record = { + ts: 'ts', + tsx: 'tsx', + js: 'js', + jsx: 'jsx', + mjs: 'js', + cjs: 'js', + mts: 'ts', + cts: 'ts', + css: 'css', + html: 'html', + json: 'json', +}; + +/** The fence language for a file, from its extension. */ +export function languageOf(file: string): string { + const clean = file.split(/[?#]/)[0]!; + const extension = clean.slice(clean.lastIndexOf('.') + 1).toLowerCase(); + return LANGUAGES[extension] ?? ''; +} + +/** A code block whose fence is longer than any run of backticks in the text. */ +export function fence(text: string, language = ''): string { + const longest = Math.max(2, ...Array.from(text.matchAll(/`+/g), (match) => match[0].length)); + const marks = '`'.repeat(longest + 1); + return `${marks}${language}\n${text}\n${marks}`; +} + +/** Inline code, with a fence that the text cannot close. */ +export function code(text: string): string { + const longest = Math.max(0, ...Array.from(text.matchAll(/`+/g), (match) => match[0].length)); + const marks = '`'.repeat(longest + 1); + const pad = text.startsWith('`') || text.endsWith('`') ? ' ' : ''; + return `${marks}${pad}${text}${pad}${marks}`; +} + +/** + * The lines around `line` (1-based), numbered, with the line marked by `>` and + * the column by a caret below it. + */ +export function excerpt(content: string, line: number, column?: number, radius = 3): string { + const lines = content.split('\n'); + const start = Math.max(1, line - radius); + const end = Math.min(lines.length, line + radius); + const width = String(end).length; + const out: string[] = []; + for (let current = start; current <= end; current++) { + const marker = current === line ? '>' : ' '; + const number = String(current).padStart(width); + out.push(`${marker} ${number} | ${lines[current - 1] ?? ''}`.trimEnd()); + if (current === line && column && column > 0) { + out.push(` ${' '.repeat(width)} | ${' '.repeat(column - 1)}^`); + } + } + return out.join('\n'); +} + +/** + * A file path as the agent finds it in the project: no origin, no `/@fs` + * prefix, no query such as Vite's `?t=` timestamp. + */ +export function displayPath(file: string): string { + let path = file; + try { + const url = new URL(file); + if (url.protocol === 'http:' || url.protocol === 'https:') path = url.pathname; + else if (url.protocol === 'file:') path = decodeURIComponent(url.pathname); + } catch { + // Not a URL, such as a path relative to the root. + } + path = path.replace(/[?#].*$/, ''); + if (path.startsWith('/@fs/')) path = path.slice('/@fs'.length); + return path; +} + +/** The path and query of the page the devtools run in. */ +export function pageRoute(): string { + return location.pathname + location.search; +} diff --git a/src/dev-toolbar/report/owner-report.ts b/src/dev-toolbar/report/owner-report.ts new file mode 100644 index 0000000..6578558 --- /dev/null +++ b/src/dev-toolbar/report/owner-report.ts @@ -0,0 +1,76 @@ +import type { TreeNode } from '../ownership/tree.js'; +import { code } from './markdown.js'; +import { isSensitiveName, REDACTED, redactUrl } from './redact.js'; +import { describeValue } from './value.js'; + +export interface OwnerReportInput { + node: TreeNode; + /** The owners above the node, nearest first. */ + ancestry: TreeNode[]; + nodes: Map; + /** The path and query of the page. */ + route: string; +} + +function valueOf(name: string, value: unknown): string { + return isSensitiveName(name) ? REDACTED : code(describeValue(value)); +} + +/** A Markdown report of a component or another owner, for an agent to read. */ +export function ownerReport(input: OwnerReportInput): string { + const { node } = input; + const out: string[] = [`# Solid ${node.kind}: ${code(node.name)}`, '']; + + out.push(`- Page: ${code(redactUrl(input.route))}`); + if (node.location) out.push(`- Declared at: ${code(node.location)}`); + const path = [...input.ancestry].reverse().map((frame) => frame.name); + out.push(`- Owner path: ${code(path.join(' > '))}`); + if (node.disposed) out.push('- Disposed: yes'); + if (node.hasValue) out.push(`- Value: ${valueOf(node.name, node.value)}`); + if (node.props) { + const names = node.props.map(code).join(', '); + out.push( + `- Props: ${names || 'none'}${names ? ' (the names only: props are getters, and reading them would track)' : ''}`, + ); + } + + if (node.signals.length) { + out.push('', '## Signals', ''); + for (const signal of node.signals) { + out.push(`- ${code(signal.name)} = ${valueOf(signal.name, signal.value)}`); + } + } + + if (node.scopes.length) { + out.push('', '## Memos and effects inside it', ''); + for (const scope of node.scopes) { + const value = scope.hasValue ? ` = ${valueOf(scope.name, scope.value)}` : ''; + out.push(`- ${scope.kind} ${code(scope.name)}${value}`); + } + } + + if (node.children.length) { + // Repeated children, such as the rows of a list, are counted on one line. + const counts = new Map(); + for (const id of node.children) { + const child = input.nodes.get(id); + if (!child) continue; + const label = `${child.kind} ${code(child.name)}`; + counts.set(label, (counts.get(label) ?? 0) + 1); + } + out.push('', '## Children', ''); + for (const [label, count] of counts) { + out.push(`- ${label}${count > 1 ? ` ×${count}` : ''}`); + } + } + + if (input.ancestry.length > 1) { + out.push('', '## Ancestry, nearest first', ''); + for (const frame of input.ancestry.slice(1)) { + const at = frame.location ? ` at ${code(frame.location)}` : ''; + out.push(`- ${frame.kind} ${code(frame.name)}${at}`); + } + } + + return out.join('\n'); +} diff --git a/src/dev-toolbar/report/reactive-report.ts b/src/dev-toolbar/report/reactive-report.ts new file mode 100644 index 0000000..741702d --- /dev/null +++ b/src/dev-toolbar/report/reactive-report.ts @@ -0,0 +1,57 @@ +import type { ReactiveNode } from '../reactivity/registry.js'; +import { code, fence } from './markdown.js'; +import { isSensitiveName, REDACTED, redactText, redactUrl, truncateText } from './redact.js'; +import { describeValue } from './value.js'; + +export interface ReactiveReportInput { + node: ReactiveNode; + nodes: Map; + /** The path and query of the page. */ + route: string; +} + +function stateText(node: ReactiveNode): string { + if (node.errored) return 'errored'; + if (node.pending) return 'pending'; + if (node.uninitialized) return 'not computed yet'; + return node.state; +} + +function valueOf(node: ReactiveNode): string { + return isSensitiveName(node.name) ? REDACTED : code(describeValue(node.value)); +} + +function links(out: string[], title: string, ids: string[], nodes: Map) { + out.push('', `## ${title}`, ''); + if (!ids.length) { + out.push('- none'); + return; + } + for (const id of ids) { + const linked = nodes.get(id); + if (linked) out.push(`- ${linked.kind} ${code(linked.name)} = ${valueOf(linked)}`); + } +} + +/** A Markdown report of a signal, memo or effect, for an agent to read. */ +export function reactiveReport(input: ReactiveReportInput): string { + const { node } = input; + const out: string[] = [`# Solid ${node.kind}: ${code(node.name)}`, '']; + + out.push(`- Page: ${code(redactUrl(input.route))}`); + if (node.ownerPath.length) out.push(`- Owner path: ${code(node.ownerPath.join(' > '))}`); + out.push(`- State: ${stateText(node)}${node.lazy ? ', lazy' : ''}`); + out.push(`- Updates while the devtools watched: ${node.updates}`); + if (!node.errored) out.push(`- Value: ${valueOf(node)}`); + + if (node.errored) { + const error = node.error; + const text = error instanceof Error ? (error.stack ?? error.message) : describeValue(error); + out.push('', '## Error', '', fence(truncateText(redactText(text)))); + } + + links(out, 'Sources, what it reads', node.sources, input.nodes); + links(out, 'Observers, what reads it', node.observers, input.nodes); + + return out.join('\n'); +} diff --git a/src/dev-toolbar/report/redact.ts b/src/dev-toolbar/report/redact.ts new file mode 100644 index 0000000..423ac68 --- /dev/null +++ b/src/dev-toolbar/report/redact.ts @@ -0,0 +1,72 @@ +/** + * A report leaves the page through the clipboard, so values that look like + * secrets are replaced before it is written. The match is by name: a key, a + * header, a form field or a signal whose name says it holds a secret. + */ +const SENSITIVE_NAME = + /pass(word|wd|phrase|code)|secret|token|auth(?!or)|cookie|session|api[-_]?key|credential|private[-_]?key|signature|bearer|jwt/i; + +export const REDACTED = '[redacted]'; + +/** Longest text a report keeps for one body or value. */ +export const MAX_TEXT = 4000; + +export function isSensitiveName(name: string): boolean { + return SENSITIVE_NAME.test(name); +} + +export function truncateText(text: string, max = MAX_TEXT): string { + if (text.length <= max) return text; + return `${text.slice(0, max)}\n… ${text.length - max} more characters`; +} + +/** Header lines, with the value of each sensitive header replaced. */ +export function redactHeaders(headers: Headers): string { + const lines: string[] = []; + headers.forEach((value, key) => { + lines.push(`${key}: ${isSensitiveName(key) ? REDACTED : value}`); + }); + return lines.join('\n'); +} + +/** A copy of a JSON value with the value of each sensitive key replaced. */ +export function redactJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redactJson); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [key, inner] of Object.entries(value)) { + out[key] = isSensitiveName(key) ? REDACTED : redactJson(inner); + } + return out; + } + return value; +} + +// `"key": "value"`, `key: 'value'` and `key=value`, for text that is not JSON. +const QUOTED_PAIR = /(["']?)([\w-]+)\1(\s*:\s*)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g; +const PLAIN_PAIR = /\b([\w-]+)=([^&\s]+)/g; + +/** Text with the values of sensitive `key: value` and `key=value` pairs replaced. */ +export function redactText(text: string): string { + return text + .replace(QUOTED_PAIR, (match, quote: string, key: string, separator: string) => + isSensitiveName(key) ? `${quote}${key}${quote}${separator}"${REDACTED}"` : match, + ) + .replace(PLAIN_PAIR, (match, key: string) => + isSensitiveName(key) ? `${key}=${REDACTED}` : match, + ); +} + +/** A URL with the value of each sensitive query parameter replaced. */ +export function redactUrl(url: string): string { + const query = url.indexOf('?'); + if (query === -1) return url; + const params = new URLSearchParams(url.slice(query + 1)); + const kept: string[] = []; + params.forEach((value, key) => { + kept.push( + `${encodeURIComponent(key)}=${isSensitiveName(key) ? REDACTED : encodeURIComponent(value)}`, + ); + }); + return kept.length ? `${url.slice(0, query)}?${kept.join('&')}` : url.slice(0, query); +} diff --git a/src/dev-toolbar/report/report.test.ts b/src/dev-toolbar/report/report.test.ts new file mode 100644 index 0000000..04bb043 --- /dev/null +++ b/src/dev-toolbar/report/report.test.ts @@ -0,0 +1,379 @@ +import { toCrossJSONStream } from 'seroval'; +import { describe, expect, it } from 'vitest'; +import { BODY_FORMAT_KEY, BodyFormat } from '../functions/body-format.js'; +import type { ServerFunctionRequest, ServerFunctionResponse } from '../functions/tracker.js'; +import type { TreeNode } from '../ownership/tree.js'; +import type { ReactiveNode } from '../reactivity/registry.js'; +import { errorReport } from './error-report.js'; +import { readReportBody, serverFunctionReport } from './function-report.js'; +import { code, displayPath, excerpt, fence } from './markdown.js'; +import { ownerReport } from './owner-report.js'; +import { reactiveReport } from './reactive-report.js'; +import { redactJson, redactText, redactUrl } from './redact.js'; +import { serovalBodyText } from './seroval-text.js'; +import { describeValue } from './value.js'; + +describe('markdown', () => { + it('fences text longer than any backtick run inside it', () => { + expect(fence('a ``` b', 'ts')).toBe('````ts\na ``` b\n````'); + expect(code('a`b')).toBe('``a`b``'); + }); + + it('marks the line and the column of an excerpt', () => { + const content = ['one', 'two', 'three', 'four', 'five'].join('\n'); + expect(excerpt(content, 3, 2, 1)).toBe( + [' 2 | two', '> 3 | three', ' | ^', ' 4 | four'].join('\n'), + ); + }); + + it('shows a path without its origin, /@fs prefix and query', () => { + expect(displayPath('http://localhost:3000/src/App.tsx?t=123')).toBe('/src/App.tsx'); + expect(displayPath('http://localhost:3000/@fs/Users/me/app/src/App.tsx')).toBe( + '/Users/me/app/src/App.tsx', + ); + expect(displayPath('src/App.tsx')).toBe('src/App.tsx'); + }); +}); + +describe('redaction', () => { + it('replaces values with sensitive keys in JSON', () => { + expect(redactJson({ user: 'ada', password: 'x', nested: [{ apiKey: 'k' }] })).toEqual({ + user: 'ada', + password: '[redacted]', + nested: [{ apiKey: '[redacted]' }], + }); + }); + + it('keeps keys that only look close to a sensitive name', () => { + expect(redactJson({ author: 'ada', compass: 'n' })).toEqual({ author: 'ada', compass: 'n' }); + }); + + it('replaces sensitive pairs in text and URLs', () => { + expect(redactText('{ token: "abc", id: "1" }')).toBe('{ token: "[redacted]", id: "1" }'); + expect(redactText('session=abc&page=2')).toBe('session=[redacted]&page=2'); + expect(redactUrl('/login?next=%2F&token=abc')).toBe('/login?next=%2F&token=[redacted]'); + }); +}); + +describe('describeValue', () => { + it('prints nested values, cycles and sensitive keys', () => { + const value: Record = { list: [1, 'a'], secret: 's', map: new Map([[1, 2]]) }; + value.self = value; + expect(describeValue(value)).toBe( + '{ list: [1, "a"], secret: [redacted], map: Map(1) { 1 => 2 }, self: [Circular] }', + ); + }); + + it('stops at a depth limit', () => { + expect(describeValue({ a: { b: { c: { d: 1 } } } })).toBe('{ a: { b: { c: {…} } } }'); + }); +}); + +describe('errorReport', () => { + it('lists the stack and the source of the first app frames', () => { + const error = new TypeError("Cannot read properties of undefined (reading 'map')"); + const report = errorReport({ + error, + route: '/todos?token=abc', + frames: [ + { + name: 'TodoList', + file: 'http://localhost:3000/src/TodoList.tsx', + line: 2, + column: 15, + content: 'export function TodoList(props) {\n return props.items.map(Item);\n}', + }, + { + name: 'runComputation', + file: 'http://localhost:3000/node_modules/.vite/deps/solid-js.js', + line: 10, + column: 1, + content: 'x', + }, + { + name: 'TodoPage', + file: 'http://localhost:3000/src/TodoPage.tsx', + line: 1, + column: 1, + content: undefined, + }, + { + name: 'untrack', + file: '/node_modules/.pnpm/@solidjs+signals@2.0.0/node_modules/@solidjs/signals/dist/dev.js', + line: 1, + column: 1, + content: undefined, + }, + { + name: 'createComponent', + file: '/node_modules/.pnpm/solid-js@2.0.0/node_modules/solid-js/dist/solid.dev.js', + line: 1, + column: 1, + content: undefined, + }, + ], + }); + expect(report).toContain("TypeError: Cannot read properties of undefined (reading 'map')"); + expect(report).toContain('- Page: `/todos?token=[redacted]`'); + expect(report).toContain('- `TodoList` at `/src/TodoList.tsx:2:15`'); + expect(report).toContain( + '- `runComputation` at `/node_modules/.vite/deps/solid-js.js:10:1` (dependency)', + ); + expect(report).toContain('- … 2 frames in dependencies: `@solidjs/signals`, `solid-js`'); + expect(report).toContain( + '```tsx\n 1 | export function TodoList(props) {\n> 2 | return props.items.map(Item);', + ); + expect(report).not.toContain('### `/node_modules'); + }); + + it('reports a value that is not an error', () => { + expect(errorReport({ error: 'boom', frames: [], route: '/' })).toContain('```\nboom\n```'); + }); +}); + +function chunk(data: string): Uint8Array { + const encoder = new TextEncoder(); + const bytes = encoder.encode(data); + const out = new Uint8Array(12 + bytes.length); + out.set(encoder.encode(`;0x${bytes.length.toString(16).padStart(8, '0')};`)); + out.set(bytes, 12); + return out; +} + +/** A body in the stream format of server functions, made by seroval itself. */ +function serovalStream(value: unknown): ReadableStream { + return new ReadableStream({ + start(controller) { + toCrossJSONStream(value, { + onParse(node) { + controller.enqueue(chunk(JSON.stringify(node))); + }, + onDone() { + controller.close(); + }, + onError(error) { + controller.error(error); + }, + }); + }, + }); +} + +describe('serovalBodyText', () => { + it('renders the value of a stream, with settled promises', async () => { + const shared = { id: 1 }; + const text = await serovalBodyText( + serovalStream({ + todos: [shared, shared], + when: new Date('2026-01-01T00:00:00.000Z'), + later: Promise.resolve('done'), + password: 'hunter2', + }), + ); + expect(text).toContain('todos: [{ id: 1 }, { id: 1 }]'); + expect(text).toContain('when: Date(2026-01-01T00:00:00.000Z)'); + expect(text).toContain('later: Promise'); + expect(text).toContain('password: [redacted]'); + expect(text).not.toContain('hunter2'); + }); +}); + +function call( + body: BodyInit | undefined, + headers: Record, +): { request: ServerFunctionRequest; response: ServerFunctionResponse } { + return { + request: { + type: 'request', + id: 'src/api.ts#getTodos', + instance: 'src/api.ts#getTodos#1', + source: new Request('http://localhost:3000/_server?id=1', { + method: 'POST', + body: JSON.stringify([{ filter: 'all', token: 'abc' }]), + headers: { 'content-type': 'application/json', cookie: 'sid=1' }, + }), + meta: { name: 'getTodos' }, + time: 1000, + }, + response: { + type: 'response', + id: 'src/api.ts#getTodos', + instance: 'src/api.ts#getTodos#1', + source: new Response(body, { status: 200, headers }), + meta: { name: 'getTodos' }, + time: 1250, + }, + }; +} + +describe('serverFunctionReport', () => { + it('reads both bodies and redacts headers and keys', async () => { + const instance = call(serovalStream([{ id: 1, title: 'Write' }]), { + [BODY_FORMAT_KEY]: BodyFormat.Serialized, + 'set-cookie': 'sid=2', + }); + const [requestBody, responseBody] = await Promise.all([ + readReportBody(instance.request.source), + readReportBody(instance.response.source), + ]); + const report = serverFunctionReport({ ...instance, requestBody, responseBody, route: '/' }); + + expect(report).toContain('# Server function call: `getTodos`'); + expect(report).toContain('- Response: `200`'); + expect(report).toContain('- Time: 0.25s'); + expect(report).toContain('cookie: [redacted]'); + expect(report).toContain('set-cookie: [redacted]'); + expect(report).toContain('"token": "[redacted]"'); + expect(report).toContain('Body (seroval):'); + expect(report).toContain('[{ id: 1, title: "Write" }]'); + // The panel reads the original bodies, so the report reads clones. + expect(instance.request.source.bodyUsed).toBe(false); + expect(instance.response.source.bodyUsed).toBe(false); + }); + + it('says when the request failed', () => { + const { request } = call(undefined, {}); + const report = serverFunctionReport({ + request: { ...request, meta: { name: 'getTodos', error: new Error('offline') } }, + route: '/', + }); + expect(report).toContain('- Response: none, the request failed: offline'); + }); +}); + +function treeNode(over: Partial): TreeNode { + return { + id: 'n', + parentId: undefined, + kind: 'component', + name: 'App', + depth: 0, + children: [], + signals: [], + props: undefined, + value: undefined, + hasValue: false, + time: 0, + disposed: false, + scopes: [], + location: undefined, + owner: {}, + ...over, + }; +} + +describe('ownerReport', () => { + it('describes a component with its signals, children and ancestry', () => { + const app = treeNode({ id: 'app', name: 'App', location: 'src/App.tsx:1:1' }); + const item = treeNode({ id: 'item', name: 'TodoItem' }); + const list = treeNode({ + id: 'list', + parentId: 'app', + name: 'TodoList', + location: 'src/TodoList.tsx:3:1', + props: ['items', 'onToggle'], + children: ['item', 'item2'], + signals: [ + { id: 's1', name: 'count', value: 3, time: 0, node: {} }, + { id: 's2', name: 'authToken', value: 'abc', time: 0, node: {} }, + ], + }); + const nodes = new Map([ + ['app', app], + ['list', list], + ['item', item], + ['item2', { ...item, id: 'item2' }], + ]); + const report = ownerReport({ node: list, ancestry: [list, app], nodes, route: '/' }); + + expect(report).toContain('# Solid component: `TodoList`'); + expect(report).toContain('- Declared at: `src/TodoList.tsx:3:1`'); + expect(report).toContain('- Owner path: `App > TodoList`'); + expect(report).toContain('- Props: `items`, `onToggle`'); + expect(report).toContain('- `count` = `3`'); + expect(report).toContain('- `authToken` = [redacted]'); + expect(report).toContain('- component `TodoItem` ×2'); + expect(report).toContain('- component `App` at `src/App.tsx:1:1`'); + }); +}); + +function reactiveNode(over: Partial): ReactiveNode { + return { + id: 'n', + kind: 'signal', + name: 'count', + value: 0, + state: 'clean', + pending: false, + errored: false, + uninitialized: false, + lazy: false, + error: undefined, + ownerPath: [], + raw: {}, + owner: undefined, + sources: [], + observers: [], + updates: 0, + updatedAt: 0, + ...over, + }; +} + +describe('reactiveReport', () => { + it('lists what a memo reads and what reads it', () => { + const todos = reactiveNode({ id: 'a', name: 'todos', value: [1, 2] }); + const effect = reactiveNode({ + id: 'c', + kind: 'render-effect', + name: 'render', + value: undefined, + }); + const memo = reactiveNode({ + id: 'b', + kind: 'memo', + name: 'filtered', + value: [1], + ownerPath: ['App', 'TodoList'], + sources: ['a'], + observers: ['c'], + updates: 4, + }); + const nodes = new Map([todos, memo, effect].map((node) => [node.id, node])); + const report = reactiveReport({ node: memo, nodes, route: '/' }); + + expect(report).toContain('# Solid memo: `filtered`'); + expect(report).toContain('- Owner path: `App > TodoList`'); + expect(report).toContain('- Updates while the devtools watched: 4'); + expect(report).toContain('## Sources, what it reads\n\n- signal `todos` = `[1, 2]`'); + expect(report).toContain( + '## Observers, what reads it\n\n- render-effect `render` = `undefined`', + ); + }); + + it('shows the error of a failed memo', () => { + const error = new Error('bad input'); + const report = reactiveReport({ + node: reactiveNode({ kind: 'memo', errored: true, error }), + nodes: new Map(), + route: '/', + }); + expect(report).toContain('- State: errored'); + expect(report).toContain('## Error'); + expect(report).toContain('Error: bad input'); + }); +}); + +describe('serovalBodyText with an open stream', () => { + it('renders what arrived and stops reading', async () => { + const stream = new ReadableStream({ + start(controller) { + toCrossJSONStream( + { later: new Promise(() => {}) }, + { onParse: (node) => controller.enqueue(chunk(JSON.stringify(node))) }, + ); + }, + }); + expect(await serovalBodyText(stream)).toBe('{ later: Promise }'); + }); +}); diff --git a/src/dev-toolbar/report/seroval-text.ts b/src/dev-toolbar/report/seroval-text.ts new file mode 100644 index 0000000..6a23757 --- /dev/null +++ b/src/dev-toolbar/report/seroval-text.ts @@ -0,0 +1,201 @@ +import type { SerovalNode } from 'seroval'; +import { ChunkReader } from '@solidjs/web/server-functions/client'; +import { + getConstantValue, + getNodeType, + getSymbolValue, + traverse, +} from '../functions/seroval-node.js'; +import { isSensitiveName, REDACTED } from './redact.js'; + +/** How long a report waits for the rest of a streamed body, such as a promise that settles later. */ +const STREAM_WAIT_MS = 500; +const MAX_DEPTH = 6; +const MAX_ENTRIES = 50; + +type PromiseResult = Extract; +type StreamEvent = Extract; + +interface Context { + nodes: Map; + promises: Map; + streams: Map; +} + +function collect(ctx: Context, node: SerovalNode): void { + switch (node.t) { + case 23: + case 24: + ctx.promises.set(node.i, node); + return; + case 32: + case 33: + case 34: { + const events = ctx.streams.get(node.i) ?? []; + events.push(node); + ctx.streams.set(node.i, events); + return; + } + } + // Nodes that can be referenced later by index. + if (node.t >= 5 && node.i !== undefined && node.t !== 18 && node.t !== 22) { + ctx.nodes.set(node.i, node); + } +} + +function keyText(ctx: Context, key: string | SerovalNode, seen: number[], depth: number): string { + return typeof key === 'string' ? key : `[${render(ctx, key, seen, depth)}]`; +} + +function record( + ctx: Context, + keys: (string | SerovalNode)[], + values: SerovalNode[], + seen: number[], + depth: number, +): string[] { + const out = keys.slice(0, MAX_ENTRIES).map((key, index) => { + const name = keyText(ctx, key, seen, depth); + if (typeof key === 'string' && isSensitiveName(key)) return `${name}: ${REDACTED}`; + return `${name}: ${render(ctx, values[index]!, seen, depth + 1)}`; + }); + if (keys.length > MAX_ENTRIES) out.push(`…${keys.length - MAX_ENTRIES} more`); + return out; +} + +function list(ctx: Context, items: (SerovalNode | 0)[], seen: number[], depth: number): string { + const out = items + .slice(0, MAX_ENTRIES) + .map((item) => (item ? render(ctx, item, seen, depth + 1) : '')); + if (items.length > MAX_ENTRIES) out.push(`…${items.length - MAX_ENTRIES} more`); + return out.join(', '); +} + +function render(ctx: Context, node: SerovalNode, seen: number[], depth: number): string { + if (depth > MAX_DEPTH) return '…'; + if (node.i !== undefined && node.t !== 4 && seen.includes(node.i)) return '[Circular]'; + const next = node.i !== undefined && node.t !== 4 ? [...seen, node.i] : seen; + + switch (node.t) { + case 0: + return String(node.s); + case 1: + return `"${node.s}"`; + case 2: + return getConstantValue(node.s); + case 3: + return `${node.s}n`; + case 4: { + if (seen.includes(node.i)) return '[Circular]'; + const target = ctx.nodes.get(node.i); + return target ? render(ctx, target, seen, depth) : ``; + } + case 5: + return `Date(${node.s})`; + case 6: + return `/${node.c}/${node.m}`; + case 7: + return `Set { ${list(ctx, node.a, next, depth)} }`; + case 8: { + const entries = node.e.k.slice(0, MAX_ENTRIES).map((key, index) => { + const value = node.e.v[index]!; + return `${render(ctx, key, next, depth + 1)} => ${render(ctx, value, next, depth + 1)}`; + }); + if (node.e.k.length > MAX_ENTRIES) entries.push(`…${node.e.k.length - MAX_ENTRIES} more`); + return `Map { ${entries.join(', ')} }`; + } + case 9: + return `[${list(ctx, node.a, next, depth)}]`; + case 10: + case 11: { + const entries = record(ctx, node.p.k, node.p.v, next, depth); + return entries.length ? `{ ${entries.join(', ')} }` : '{}'; + } + case 12: + return `Promise<${node.s === 0 ? 'resolved' : 'rejected'}: ${render(ctx, node.f, next, depth + 1)}>`; + case 13: + case 14: { + const head = `${getNodeType(node)}: ${node.m}`; + if (!node.p) return head; + const entries = record(ctx, node.p.k, node.p.v, next, depth); + return entries.length ? `${head} { ${entries.join(', ')} }` : head; + } + case 15: + case 16: + return `${node.c}(${node.l})`; + case 17: + return getSymbolValue(node.s); + case 18: + return ``; + case 19: + return 'ArrayBuffer'; + case 20: + return `DataView(${node.l})`; + case 21: + return `Boxed(${render(ctx, node.f, next, depth + 1)})`; + case 22: { + const result = ctx.promises.get(node.s); + if (!result) return 'Promise'; + const value = render(ctx, result.a[1], next, depth + 1); + return `Promise<${result.t === 23 ? 'resolved' : 'rejected'}: ${value}>`; + } + case 25: { + const entries = Object.entries(node.s).map( + ([key, value]) => `${key}: ${render(ctx, value, next, depth + 1)}`, + ); + return `${node.c} { ${entries.join(', ')} }`; + } + case 31: { + const events = (ctx.streams.get(node.i) ?? []).map((event) => { + const value = render(ctx, event.f, next, depth + 1); + return event.t === 32 ? value : `${event.t === 33 ? 'throw' : 'return'} ${value}`; + }); + return `Stream [${events.join(', ')}]`; + } + case 35: + return `Sequence [${list(ctx, node.a, next, depth)}]`; + default: + return `<${getNodeType(node)}>`; + } +} + +/** + * The body of a server function in seroval's stream format, as source-like + * text. The first chunk holds the value. The chunks after it settle promises + * and streams, so this reads them for a moment before rendering. + */ +export async function serovalBodyText(body: ReadableStream): Promise { + // Piped, so the copy can stop reading a stream that stays open. + const abort = new AbortController(); + const reader = new ChunkReader( + body.pipeThrough(new TransformStream(), { signal: abort.signal }), + ); + const ctx: Context = { nodes: new Map(), promises: new Map(), streams: new Map() }; + const read = (chunk: string): SerovalNode => { + const node = JSON.parse(chunk) as SerovalNode; + traverse(node, (child) => collect(ctx, child)); + return node; + }; + + const first = await reader.next(); + if (first.done || first.value === undefined) return ''; + const root = read(first.value); + + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, STREAM_WAIT_MS); + }); + const rest = (async () => { + while (true) { + const chunk = await reader.next(); + if (chunk.done) return; + if (chunk.value !== undefined) read(chunk.value); + } + })().catch(() => {}); + await Promise.race([rest, timeout]); + clearTimeout(timer); + // A stream that is still open is left to the call; this copy stops reading. + abort.abort(); + + return render(ctx, root, [], 0); +} diff --git a/src/dev-toolbar/report/styles.css b/src/dev-toolbar/report/styles.css new file mode 100644 index 0000000..af8a3bd --- /dev/null +++ b/src/dev-toolbar/report/styles.css @@ -0,0 +1,25 @@ +/* In the heading of a detail pane, the button sits at the end, sized like the other actions there. */ +[data-solid-ownership-detail-head] [data-solid-copy-report], +[data-solid-reactivity-card-head] [data-solid-copy-report] { + margin-left: auto; + padding: 0.25rem; + border-radius: 0.25rem; +} + +[data-solid-ownership-detail-head] [data-solid-ownership-view-graph] ~ [data-solid-copy-report] { + margin-left: 0; +} + +[data-solid-ownership-detail-head] [data-solid-copy-report] > svg, +[data-solid-reactivity-card-head] [data-solid-copy-report] > svg { + width: 0.875rem; + height: 0.875rem; +} + +[data-solid-icon-button][data-solid-copy-report='copied'] { + color: var(--start-dt-kind-effect, oklch(0.72 0.15 150)); +} + +[data-solid-icon-button][data-solid-copy-report='failed'] { + color: var(--start-dt-danger); +} diff --git a/src/dev-toolbar/report/value.ts b/src/dev-toolbar/report/value.ts new file mode 100644 index 0000000..69d50c5 --- /dev/null +++ b/src/dev-toolbar/report/value.ts @@ -0,0 +1,104 @@ +import { isSensitiveName, REDACTED } from './redact.js'; + +const MAX_DEPTH = 3; +const MAX_ENTRIES = 20; +const MAX_STRING = 200; + +function quote(value: string): string { + const text = value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…` : value; + return JSON.stringify(text); +} + +function read(target: object, key: string): { value: unknown } | { failed: true } { + try { + return { value: (target as Record)[key] }; + } catch { + return { failed: true }; + } +} + +/** + * A value as one line of source-like text, a few levels deep. Deeper than + * the previews in the panels, since an agent reads the whole line. Keys with + * sensitive names have their values replaced, and a value seen above itself + * prints as `[Circular]`. + */ +export function describeValue(value: unknown, depth = 0, seen = new WeakSet()): string { + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + + switch (typeof value) { + case 'string': + return quote(value); + case 'number': + case 'boolean': + return String(value); + case 'bigint': + return `${value}n`; + case 'symbol': + return value.toString(); + case 'function': + return value.name ? `ƒ ${value.name}()` : 'ƒ ()'; + } + + const target = value as object; + if (target instanceof Date) return `Date(${target.toISOString()})`; + if (target instanceof RegExp) return target.toString(); + if (target instanceof Error) return `${target.name}: ${target.message}`; + if (target instanceof Promise) return 'Promise'; + if (typeof Node === 'function' && target instanceof Node) { + const element = target as unknown as Element; + return element.tagName ? `<${element.tagName.toLowerCase()}>` : target.nodeName; + } + if (ArrayBuffer.isView(target)) return `${target.constructor.name}(${target.byteLength} bytes)`; + + if (seen.has(target)) return '[Circular]'; + if (depth >= MAX_DEPTH) { + if (Array.isArray(target)) return `Array(${target.length})`; + if (target instanceof Map) return `Map(${target.size})`; + if (target instanceof Set) return `Set(${target.size})`; + return '{…}'; + } + seen.add(target); + try { + const inner = (item: unknown) => describeValue(item, depth + 1, seen); + const more = (total: number) => (total > MAX_ENTRIES ? [`…${total - MAX_ENTRIES} more`] : []); + + if (Array.isArray(target)) { + const items = target.slice(0, MAX_ENTRIES).map(inner); + return `[${[...items, ...more(target.length)].join(', ')}]`; + } + if (target instanceof Map) { + const entries = Array.from(target.entries()) + .slice(0, MAX_ENTRIES) + .map(([key, item]) => + typeof key === 'string' && isSensitiveName(key) + ? `${inner(key)} => ${REDACTED}` + : `${inner(key)} => ${inner(item)}`, + ); + return `Map(${target.size}) { ${[...entries, ...more(target.size)].join(', ')} }`; + } + if (target instanceof Set) { + const items = Array.from(target).slice(0, MAX_ENTRIES).map(inner); + return `Set(${target.size}) { ${[...items, ...more(target.size)].join(', ')} }`; + } + + const name = target.constructor?.name; + const prefix = name && name !== 'Object' ? `${name} ` : ''; + let keys: string[]; + try { + keys = Object.keys(target); + } catch { + return `${prefix}{…}`; + } + if (keys.length === 0) return `${prefix}{}`; + const entries = keys.slice(0, MAX_ENTRIES).map((key) => { + if (isSensitiveName(key)) return `${key}: ${REDACTED}`; + const result = read(target, key); + return 'failed' in result ? `${key}: …` : `${key}: ${inner(result.value)}`; + }); + return `${prefix}{ ${[...entries, ...more(keys.length)].join(', ')} }`; + } finally { + seen.delete(target); + } +} diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index cc88736..1acca04 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -391,6 +391,54 @@ test('maps the ownership tree', async ({ page }) => { ).toHaveAttribute('data-solid-reactivity-node', 'selected'); }); +test('copies reports for an agent', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + const clipboard = () => page.evaluate(() => navigator.clipboard.readText()); + const copy = (name: string) => + page.getByRole('button', { name: `Copy ${name} for an agent` }).click(); + + await page.goto('/'); + + // An error, with its mapped stack and the source around the frame. + await page.locator('#throw-client-error').click(); + await copy('this error'); + await expect.poll(clipboard).toContain('# Error in a Solid app'); + const error = await clipboard(); + expect(error).toContain('Error: client boom'); + expect(error).toMatch(/`Broken` at `[^`]*app\.tsx:\d+:\d+`/); + expect(error).toContain("throw new Error('client boom');"); + + // The error replaced the app, so the other panels start from a fresh page. + await page.reload(); + + // A server-function call, with both bodies. + await page.locator('#emit-server-function-request').click(); + await page.locator('#emit-server-function-response').click(); + await openTab(page, 'Server functions'); + await page.locator('[data-solid-functions-instances] [data-solid-select-option]').first().click(); + await copy('this call'); + await expect.poll(clipboard).toContain('# Server function call: `loadUser`'); + expect(await clipboard()).toContain('- Response: `201`'); + + // A component, with its signals and where it sits. + await openTab(page, 'Ownership'); + await page.locator('[data-solid-ownership-label]').filter({ hasText: '' }).click(); + await copy('this component'); + await expect.poll(clipboard).toContain('# Solid component: ``'); + expect(await clipboard()).toContain('- `count` = `0`'); + + // A memo, with what it reads and what reads it. + await openTab(page, 'Reactivity'); + await page + .locator('[data-solid-reactivity-node]') + .filter({ hasText: /^doubled/ }) + .first() + .click(); + await copy('this memo'); + await expect.poll(clipboard).toContain('# Solid memo: `doubled`'); + expect(await clipboard()).toContain('- signal `count` = `0`'); +}); + test('mounts once and disposes', async ({ page }) => { await page.goto('/?mount'); From 122620da5bf692a5caafb26964669121e5628b04 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Tue, 27 Oct 2026 15:18:44 +0800 Subject: [PATCH 2/2] fix: close redaction gaps and bound body reads in reports - Header values are redacted by their pairs too, so a URL in `referer` or `location` loses its secrets. - Text redaction also covers unquoted values such as `token: abc` and `"password": 123`, and `Bearer` or `Basic` credentials. - Seroval `Map` entries and plugin values with sensitive keys are redacted. - A body is read for at most 500ms and 1 MiB, then the clone is cancelled. A body that stays open no longer leaves the copy button busy. - Source excerpts are redacted, and long lines are cut around the column. - Columns mapped through a source map are 1-based like the rest of the stack, so the caret points at the expression that threw. Co-Authored-By: Claude Opus 5.5 --- .../error-viewer/create-stack-frame.ts | 4 +- src/dev-toolbar/report/error-report.ts | 5 +- src/dev-toolbar/report/function-report.ts | 53 ++++++++++++++++++- src/dev-toolbar/report/markdown.ts | 28 ++++++++-- src/dev-toolbar/report/redact.ts | 26 ++++++--- src/dev-toolbar/report/report.test.ts | 49 +++++++++++++++++ src/dev-toolbar/report/seroval-text.ts | 53 ++++++++++--------- tests/e2e/devtools.spec.ts | 4 ++ 8 files changed, 182 insertions(+), 40 deletions(-) diff --git a/src/dev-toolbar/error-viewer/create-stack-frame.ts b/src/dev-toolbar/error-viewer/create-stack-frame.ts index 67a36d5..7d23a0b 100644 --- a/src/dev-toolbar/error-viewer/create-stack-frame.ts +++ b/src/dev-toolbar/error-viewer/create-stack-frame.ts @@ -97,13 +97,15 @@ export function mapStackFrame(current: LoadedStackFrame, isCompiled: boolean): S } as StackFrameSource; } } else { + // A stack frame counts columns from 1, and a source map from 0. const result = originalPositionFor(sourceMap, { line: source.line, - column: source.column, + column: source.column - 1, }); if (result.source) { return { ...result, + column: result.column + 1, content: sourceContentFor(sourceMap, result.source), } as StackFrameSource; } diff --git a/src/dev-toolbar/report/error-report.ts b/src/dev-toolbar/report/error-report.ts index f0a839f..a1446e1 100644 --- a/src/dev-toolbar/report/error-report.ts +++ b/src/dev-toolbar/report/error-report.ts @@ -110,7 +110,10 @@ export function errorReport(input: ErrorReportInput): string { '', `### ${code(frameLocation(frame))}`, '', - fence(excerpt(frame.content!, frame.line!, frame.column), languageOf(frame.file!)), + fence( + redactText(excerpt(frame.content!, frame.line!, frame.column)), + languageOf(frame.file!), + ), ); } } diff --git a/src/dev-toolbar/report/function-report.ts b/src/dev-toolbar/report/function-report.ts index 561ccf9..1349edd 100644 --- a/src/dev-toolbar/report/function-report.ts +++ b/src/dev-toolbar/report/function-report.ts @@ -88,6 +88,52 @@ async function readBody(source: Request | Response): Promise, +): Promise<{ bytes: Uint8Array; complete: boolean }> { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + let complete = false; + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), BODY_WAIT_MS); + }); + try { + while (size < MAX_BODY_BYTES) { + const result = await Promise.race([reader.read(), timeout]); + if (!result) break; + if (result.done) { + complete = true; + break; + } + chunks.push(result.value); + size += result.value.byteLength; + } + } finally { + clearTimeout(timer); + // This reads a clone, so cancelling it leaves the call alone. + reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { bytes, complete }; +} + /** * The body of a request or response, read from a clone so the panel can still * read the original. `undefined` when there is no body, or when it cannot be @@ -96,8 +142,11 @@ async function readBody(source: Request | Response): Promise { if (!source.body || source.bodyUsed) return undefined; try { - const body = await readBody(source.clone()); - return body && { ...body, text: truncateText(body.text) }; + const { bytes, complete } = await readBodyBytes(source.clone().body!); + const body = await readBody(new Response(bytes, { headers: source.headers })); + if (!body) return undefined; + const text = truncateText(body.text); + return { ...body, text: complete ? text : `${text}\n… the rest of the body was not read` }; } catch { return undefined; } diff --git a/src/dev-toolbar/report/markdown.ts b/src/dev-toolbar/report/markdown.ts index df7bb15..e53e204 100644 --- a/src/dev-toolbar/report/markdown.ts +++ b/src/dev-toolbar/report/markdown.ts @@ -34,9 +34,28 @@ export function code(text: string): string { return `${marks}${pad}${text}${pad}${marks}`; } +/** Longest line an excerpt keeps, so a minified file does not fill the report. */ +const MAX_LINE = 160; + +/** + * A line cut to `MAX_LINE` characters around the column (1-based), with the + * column moved to match. + */ +function clipLine(text: string, column?: number): { text: string; column?: number } { + if (text.length <= MAX_LINE) return { text, column }; + const center = column ? column - 1 - MAX_LINE / 2 : 0; + const start = Math.max(0, Math.min(center, text.length - MAX_LINE)); + const head = start > 0 ? '…' : ''; + const tail = start + MAX_LINE < text.length ? '…' : ''; + return { + text: `${head}${text.slice(start, start + MAX_LINE)}${tail}`, + column: column && column - start + head.length, + }; +} + /** * The lines around `line` (1-based), numbered, with the line marked by `>` and - * the column by a caret below it. + * the column (1-based) by a caret below it. */ export function excerpt(content: string, line: number, column?: number, radius = 3): string { const lines = content.split('\n'); @@ -47,9 +66,10 @@ export function excerpt(content: string, line: number, column?: number, radius = for (let current = start; current <= end; current++) { const marker = current === line ? '>' : ' '; const number = String(current).padStart(width); - out.push(`${marker} ${number} | ${lines[current - 1] ?? ''}`.trimEnd()); - if (current === line && column && column > 0) { - out.push(` ${' '.repeat(width)} | ${' '.repeat(column - 1)}^`); + const clipped = clipLine(lines[current - 1] ?? '', current === line ? column : undefined); + out.push(`${marker} ${number} | ${clipped.text}`.trimEnd()); + if (current === line && clipped.column && clipped.column > 0) { + out.push(` ${' '.repeat(width)} | ${' '.repeat(clipped.column - 1)}^`); } } return out.join('\n'); diff --git a/src/dev-toolbar/report/redact.ts b/src/dev-toolbar/report/redact.ts index 423ac68..9d9404d 100644 --- a/src/dev-toolbar/report/redact.ts +++ b/src/dev-toolbar/report/redact.ts @@ -20,11 +20,15 @@ export function truncateText(text: string, max = MAX_TEXT): string { return `${text.slice(0, max)}\n… ${text.length - max} more characters`; } -/** Header lines, with the value of each sensitive header replaced. */ +/** + * Header lines, with the value of each sensitive header replaced. Other values + * can hold a URL, such as `referer` or `location`, so their pairs are redacted + * too. + */ export function redactHeaders(headers: Headers): string { const lines: string[] = []; headers.forEach((value, key) => { - lines.push(`${key}: ${isSensitiveName(key) ? REDACTED : value}`); + lines.push(`${key}: ${isSensitiveName(key) ? REDACTED : redactText(value)}`); }); return lines.join('\n'); } @@ -42,19 +46,25 @@ export function redactJson(value: unknown): unknown { return value; } -// `"key": "value"`, `key: 'value'` and `key=value`, for text that is not JSON. +// `"key": "value"` and `key: 'value'`, for text that is not JSON. const QUOTED_PAIR = /(["']?)([\w-]+)\1(\s*:\s*)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g; -const PLAIN_PAIR = /\b([\w-]+)=([^&\s]+)/g; +// `key: value`, `"key": 123` and `key=value`. The value ends at a space, a +// delimiter or a query, so a URL's parameters are checked one by one. A quoted +// or nested value is left to the other patterns. +const PLAIN_PAIR = /(["']?)\b([\w-]+)\1(\s*[:=]\s*)(?!["'{[])([^\s?&,;"'<>(){}[\]]+)/g; +// The credentials of an `Authorization` value, wherever it appears. +const AUTH_SCHEME = /\b(Bearer|Basic)\s+[\w.~+/-]+=*/gi; -/** Text with the values of sensitive `key: value` and `key=value` pairs replaced. */ +/** Text with the values of sensitive `key: value` and `key=value` pairs and credentials replaced. */ export function redactText(text: string): string { return text .replace(QUOTED_PAIR, (match, quote: string, key: string, separator: string) => isSensitiveName(key) ? `${quote}${key}${quote}${separator}"${REDACTED}"` : match, ) - .replace(PLAIN_PAIR, (match, key: string) => - isSensitiveName(key) ? `${key}=${REDACTED}` : match, - ); + .replace(PLAIN_PAIR, (match, quote: string, key: string, separator: string) => + isSensitiveName(key) ? `${quote}${key}${quote}${separator}${REDACTED}` : match, + ) + .replace(AUTH_SCHEME, (_, scheme: string) => `${scheme} ${REDACTED}`); } /** A URL with the value of each sensitive query parameter replaced. */ diff --git a/src/dev-toolbar/report/report.test.ts b/src/dev-toolbar/report/report.test.ts index 04bb043..77d1a30 100644 --- a/src/dev-toolbar/report/report.test.ts +++ b/src/dev-toolbar/report/report.test.ts @@ -26,6 +26,13 @@ describe('markdown', () => { ); }); + it('cuts a long line around the column', () => { + const line = `${'a'.repeat(300)}X${'b'.repeat(300)}`; + const [marked, caret] = excerpt(line, 1, 301, 0).split('\n'); + expect(marked!.length).toBeLessThan(180); + expect(marked![caret!.indexOf('^')]).toBe('X'); + }); + it('shows a path without its origin, /@fs prefix and query', () => { expect(displayPath('http://localhost:3000/src/App.tsx?t=123')).toBe('/src/App.tsx'); expect(displayPath('http://localhost:3000/@fs/Users/me/app/src/App.tsx')).toBe( @@ -53,6 +60,17 @@ describe('redaction', () => { expect(redactText('session=abc&page=2')).toBe('session=[redacted]&page=2'); expect(redactUrl('/login?next=%2F&token=abc')).toBe('/login?next=%2F&token=[redacted]'); }); + + it('replaces unquoted values, credentials and query pairs in text', () => { + expect(redactText('{"password": 123, "id": 1}')).toBe('{"password": [redacted], "id": 1}'); + expect(redactText('token: abc123')).toBe('token: [redacted]'); + expect(redactText('Authorization: Bearer abc.def')).toBe('Authorization: Bearer [redacted]'); + expect(redactText('fn(apiKey=sk-1)')).toBe('fn(apiKey=[redacted])'); + expect(redactText('; rel="next"')).toBe( + '; rel="next"', + ); + expect(redactText('user: ada')).toBe('user: ada'); + }); }); describe('describeValue', () => { @@ -176,6 +194,18 @@ describe('serovalBodyText', () => { expect(text).toContain('password: [redacted]'); expect(text).not.toContain('hunter2'); }); + + it('redacts the values of sensitive Map keys', async () => { + const text = await serovalBodyText( + serovalStream( + new Map([ + ['token', 'abc'], + ['id', 'x'], + ]), + ), + ); + expect(text).toBe('Map { "token" => [redacted], "id" => "x" }'); + }); }); function call( @@ -231,6 +261,25 @@ describe('serverFunctionReport', () => { expect(instance.response.source.bodyUsed).toBe(false); }); + it('redacts URLs in headers that are not sensitive by name', () => { + const { request } = call(undefined, {}); + request.source.headers.set('x-return-to', 'http://localhost:3000/cb?access_token=abc'); + const report = serverFunctionReport({ request, route: '/' }); + expect(report).toContain('x-return-to: http://localhost:3000/cb?access_token=[redacted]'); + }); + + it('stops reading a body that stays open', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: 1\n')); + }, + }); + const body = await readReportBody( + new Response(stream, { headers: { 'content-type': 'text/event-stream' } }), + ); + expect(body?.text).toBe('data: 1\n\n… the rest of the body was not read'); + }); + it('says when the request failed', () => { const { request } = call(undefined, {}); const report = serverFunctionReport({ diff --git a/src/dev-toolbar/report/seroval-text.ts b/src/dev-toolbar/report/seroval-text.ts index 6a23757..8076937 100644 --- a/src/dev-toolbar/report/seroval-text.ts +++ b/src/dev-toolbar/report/seroval-text.ts @@ -98,8 +98,9 @@ function render(ctx: Context, node: SerovalNode, seen: number[], depth: number): return `Set { ${list(ctx, node.a, next, depth)} }`; case 8: { const entries = node.e.k.slice(0, MAX_ENTRIES).map((key, index) => { - const value = node.e.v[index]!; - return `${render(ctx, key, next, depth + 1)} => ${render(ctx, value, next, depth + 1)}`; + const name = render(ctx, key, next, depth + 1); + if (key.t === 1 && isSensitiveName(key.s)) return `${name} => ${REDACTED}`; + return `${name} => ${render(ctx, node.e.v[index]!, next, depth + 1)}`; }); if (node.e.k.length > MAX_ENTRIES) entries.push(`…${node.e.k.length - MAX_ENTRIES} more`); return `Map { ${entries.join(', ')} }`; @@ -140,8 +141,10 @@ function render(ctx: Context, node: SerovalNode, seen: number[], depth: number): return `Promise<${result.t === 23 ? 'resolved' : 'rejected'}: ${value}>`; } case 25: { - const entries = Object.entries(node.s).map( - ([key, value]) => `${key}: ${render(ctx, value, next, depth + 1)}`, + const entries = Object.entries(node.s).map(([key, value]) => + isSensitiveName(key) + ? `${key}: ${REDACTED}` + : `${key}: ${render(ctx, value, next, depth + 1)}`, ); return `${node.c} { ${entries.join(', ')} }`; } @@ -177,25 +180,27 @@ export async function serovalBodyText(body: ReadableStream): Promise return node; }; - const first = await reader.next(); - if (first.done || first.value === undefined) return ''; - const root = read(first.value); + try { + const first = await reader.next(); + if (first.done || first.value === undefined) return ''; + const root = read(first.value); - let timer: ReturnType | undefined; - const timeout = new Promise((resolve) => { - timer = setTimeout(resolve, STREAM_WAIT_MS); - }); - const rest = (async () => { - while (true) { - const chunk = await reader.next(); - if (chunk.done) return; - if (chunk.value !== undefined) read(chunk.value); - } - })().catch(() => {}); - await Promise.race([rest, timeout]); - clearTimeout(timer); - // A stream that is still open is left to the call; this copy stops reading. - abort.abort(); - - return render(ctx, root, [], 0); + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, STREAM_WAIT_MS); + }); + const rest = (async () => { + while (true) { + const chunk = await reader.next(); + if (chunk.done) return; + if (chunk.value !== undefined) read(chunk.value); + } + })().catch(() => {}); + await Promise.race([rest, timeout]); + clearTimeout(timer); + return render(ctx, root, [], 0); + } finally { + // A stream that is still open is left to the call; this copy stops reading. + abort.abort(); + } } diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 3ce0cd4..53e087c 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -464,6 +464,10 @@ test('copies reports for an agent', async ({ page, context }) => { expect(error).toContain('Error: client boom'); expect(error).toMatch(/`Broken` at `[^`]*app\.tsx:\d+:\d+`/); expect(error).toContain("throw new Error('client boom');"); + // The caret points at the expression that threw. + const lines = error.split('\n'); + const marked = lines.findIndex((line) => line.includes("throw new Error('client boom');")); + expect(lines[marked + 1]!.indexOf('^')).toBe(lines[marked]!.indexOf('new')); // The error replaced the app, so the other panels start from a fresh page. await page.reload();