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..7d23a0b 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,108 @@ 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 { + // A stack frame counts columns from 1, and a source map from 0. + const result = originalPositionFor(sourceMap, { + line: source.line, + column: source.column - 1, + }); + if (result.source) { + return { + ...result, + column: result.column + 1, + 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..a1446e1 --- /dev/null +++ b/src/dev-toolbar/report/error-report.ts @@ -0,0 +1,173 @@ +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( + redactText(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..1349edd --- /dev/null +++ b/src/dev-toolbar/report/function-report.ts @@ -0,0 +1,196 @@ +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') }; +} + +/** How long a report waits for a body that is still arriving. */ +const BODY_WAIT_MS = 500; +/** Most bytes a report reads from one body. */ +const MAX_BODY_BYTES = 1024 * 1024; + +/** + * The bytes of a body, read until it ends, it passes the size limit or the wait + * runs out. A body that streams for the life of the call, such as server-sent + * events, is cut off instead of keeping the report waiting. + */ +async function readBodyBytes( + body: ReadableStream, +): 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 + * read. + */ +export async function readReportBody(source: Request | Response): Promise { + if (!source.body || source.bodyUsed) return undefined; + try { + 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; + } +} + +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..e53e204 --- /dev/null +++ b/src/dev-toolbar/report/markdown.ts @@ -0,0 +1,99 @@ +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}`; +} + +/** 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 (1-based) 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); + 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'); +} + +/** + * 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..9d9404d --- /dev/null +++ b/src/dev-toolbar/report/redact.ts @@ -0,0 +1,82 @@ +/** + * 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. 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 : redactText(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"` and `key: 'value'`, for text that is not JSON. +const QUOTED_PAIR = /(["']?)([\w-]+)\1(\s*:\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 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, 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. */ +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..77d1a30 --- /dev/null +++ b/src/dev-toolbar/report/report.test.ts @@ -0,0 +1,428 @@ +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('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( + '/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]'); + }); + + 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', () => { + 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'); + }); + + 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( + 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('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({ + 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..8076937 --- /dev/null +++ b/src/dev-toolbar/report/seroval-text.ts @@ -0,0 +1,206 @@ +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 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(', ')} }`; + } + 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]) => + isSensitiveName(key) + ? `${key}: ${REDACTED}` + : `${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; + }; + + 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); + 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/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 de39d56..53e087c 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -448,6 +448,58 @@ 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 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(); + + // 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');