Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/copy-for-agent.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
161 changes: 91 additions & 70 deletions src/dev-toolbar/error-viewer/create-stack-frame.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<LoadedStackFrame | null> {
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<StackFrameSource>;
Expand Down
9 changes: 9 additions & 0 deletions src/dev-toolbar/error-viewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -271,6 +274,11 @@ export default function ErrorViewer(props: ErrorViewerProps): JSX.Element {
window.open(url, '_blank')!.focus();
}

async function copyErrorReport(): Promise<string> {
const error = props.errors[truncated() - 1];
return errorReport({ error, frames: await resolveErrorFrames(error), route: pageRoute() });
}

function redirectToDiscord() {
window.open(DISCORD_INVITE, '_blank')!.focus();
}
Expand Down Expand Up @@ -304,6 +312,7 @@ export default function ErrorViewer(props: ErrorViewerProps): JSX.Element {
</Show>
</div>
<div data-solid-error-viewer-controls>
<CopyReportButton label="this error" report={copyErrorReport} />
<IconButton data-solid-error-viewer-button onClick={redirectToGithub} type="button">
<GithubIcon title="Create an issue thread on Github" />
</IconButton>
Expand Down
Loading
Loading