From d28ecb508d130291d45e702a88b9b9e852d23a48 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 5 Nov 2026 01:29:02 +0800 Subject: [PATCH] feat: connect the devtools toolbar to the dev server The toolbar runs in the page and cannot see the server process or the disk. In dev serve, when the app uses @solidjs/start-devtools: - errors the server runtime reports through configureServerErrors are sent to the toolbar over the HMR channel, and the app's own hook is still called - the toolbar's hello gets the installed versions and the errors kept since the page started loading - /__solid/devtools/open-in-editor resolves a location against the Vite root and hands it to Vite's /__open-in-editor The generated client entry and the mount module call connectDevServer with import.meta.hot when the devtools release exports it. Co-Authored-By: Claude Opus 5.5 --- .changeset/devtools-dev-server.md | 5 + README.md | 8 + src/devtools/index.ts | 16 ++ src/devtools/protocol.ts | 56 ++++++ src/devtools/server.test.ts | 187 ++++++++++++++++++++ src/devtools/server.ts | 273 ++++++++++++++++++++++++++++++ src/ssr/index.ts | 6 + 7 files changed, 551 insertions(+) create mode 100644 .changeset/devtools-dev-server.md create mode 100644 src/devtools/protocol.ts create mode 100644 src/devtools/server.test.ts create mode 100644 src/devtools/server.ts diff --git a/.changeset/devtools-dev-server.md b/.changeset/devtools-dev-server.md new file mode 100644 index 00000000..f8d9c8f5 --- /dev/null +++ b/.changeset/devtools-dev-server.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': minor +--- + +Connect the start-mode development toolbar to the dev server. Errors the server runtime reports through `configureServerErrors` reach the toolbar, and the app's own hook is still called. The toolbar also gets the installed versions of `solid-js`, `@solidjs/web`, the plugin and Vite, and an endpoint that opens files in the editor. diff --git a/README.md b/README.md index 06fe5203..50806453 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,14 @@ to require it or `start: { devtools: false }` to disable automatic integration. The package is an optional peer and the toolbar is not included in production builds. +During `vite dev` the plugin also connects the toolbar to the dev server. The +toolbar then lists the errors the server runtime reports through +`configureServerErrors` (renders, server functions and requests), beside the +app's own hook. It opens files in the editor through +`/__solid/devtools/open-in-editor`, which resolves paths against the Vite root. +Server errors are heard when the server environment runs in the dev server's +process, as Vite's default Node runner does. + Generated entries wrap the app automatically. With custom server and client entries, place the development boundary around the app in the shared document or root: diff --git a/src/devtools/index.ts b/src/devtools/index.ts index fcbd1198..f8239219 100644 --- a/src/devtools/index.ts +++ b/src/devtools/index.ts @@ -1,9 +1,25 @@ export const DEVTOOLS_PACKAGE = '@solidjs/start-devtools'; export const DEVTOOLS_MOUNT_ID = 'virtual:solid-devtools/mount'; +/** + * Connects the toolbar to the dev server (see `./server.ts`). The toolbar + * is pre-bundled and has no `import.meta.hot` of its own, so the generated + * module passes its own. A namespace import, because a devtools release + * older than `connectDevServer` does not export it. + */ +export function devtoolsConnectCode(): string[] { + return [ + `import * as __solidDevtools from '${DEVTOOLS_PACKAGE}';`, + `if (import.meta.hot && typeof __solidDevtools.connectDevServer === 'function') {`, + ` __solidDevtools.connectDevServer(import.meta.hot);`, + `}`, + ]; +} + export function devtoolsMountModuleCode(): string { return [ `import { mountDevToolbar } from '${DEVTOOLS_PACKAGE}';`, + ...devtoolsConnectCode(), `mountDevToolbar();`, ].join('\n'); } diff --git a/src/devtools/protocol.ts b/src/devtools/protocol.ts new file mode 100644 index 00000000..c48ed62e --- /dev/null +++ b/src/devtools/protocol.ts @@ -0,0 +1,56 @@ +/** + * The messages and endpoints the dev server and the devtools toolbar share. + * + * `@solidjs/start-devtools/protocol` defines them (solidjs/solid-start-devtools#48). + * They are copied here until a devtools release ships that entry. Then the + * constants take the types of the package's, as `src/diagnostics` does with + * `@solidjs/diagnostics/protocol`, so drift fails this plugin's compile. + */ + +/** Opens a file in the editor: `GET /__solid/devtools/open-in-editor?file=src/App.tsx:12:5`. */ +export const OPEN_IN_EDITOR_PATH = '/__solid/devtools/open-in-editor'; + +/** Toolbar to server, once it connects. The payload is a `DevServerHello`. */ +export const DEVTOOLS_HELLO_EVENT = 'solid:devtools:hello'; + +/** Server to toolbar, the answer to the hello. The payload is a `DevServerInfo`. */ +export const DEVTOOLS_INFO_EVENT = 'solid:devtools:info'; + +/** Server to toolbar, each server error after the hello. The payload is a `ServerErrorMessage`. */ +export const DEVTOOLS_SERVER_ERROR_EVENT = 'solid:devtools:server-error'; + +export interface DevServerHello { + /** When the page started loading, in milliseconds since the epoch. */ + since: number; +} + +/** Package versions resolved from the app root. A package that was not found is left out. */ +export interface DevServerVersions { + 'solid-js'?: string; + '@solidjs/web'?: string; + '@solidjs/vite-plugin'?: string; + vite?: string; +} + +export interface DevServerInfo { + versions: DevServerVersions; + /** The server errors kept since the hello's `since`, oldest first. */ + errors: ServerErrorMessage[]; +} + +/** A failure the server runtime reported, as `configureServerErrors` hears it. */ +export interface ServerErrorMessage { + /** Unique for the life of the dev server. */ + id: number; + /** When the server heard it, in milliseconds since the epoch. */ + at: number; + name: string; + message: string; + stack?: string; + kind: 'render' | 'server-function' | 'request'; + handling: string; + functionId?: string; + ownerPath?: string[]; + method?: string; + url?: string; +} diff --git a/src/devtools/server.test.ts b/src/devtools/server.test.ts new file mode 100644 index 00000000..9eb5f0c6 --- /dev/null +++ b/src/devtools/server.test.ts @@ -0,0 +1,187 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import path from 'path'; +import { configureServerErrors, reportRequestFailure } from '@solidjs/web'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { devtoolsConnectCode, devtoolsMountModuleCode } from './index.js'; +import { + detectDevtoolsPackage, + listenToServerErrors, + readPackageVersion, + resolveEditorLocation, + serverErrorMessage, +} from './server.js'; + +const temporary: string[] = []; + +function makeDirectory(): string { + const dir = mkdtempSync(path.join(tmpdir(), 'solid-devtools-')); + temporary.push(dir); + return dir; +} + +function writeFile(file: string, content = ''): void { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +afterEach(() => { + for (const dir of temporary.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('resolveEditorLocation', () => { + it('resolves a location against the root and keeps its line and column', () => { + const root = makeDirectory(); + writeFile(path.join(root, 'src/App.tsx')); + expect(resolveEditorLocation(root, 'src/App.tsx:12:5')).toBe( + path.join(root, 'src/App.tsx') + ':12:5', + ); + }); + + it('drops leading folders the root already includes', () => { + const root = makeDirectory(); + writeFile(path.join(root, 'src/App.tsx')); + expect(resolveEditorLocation(root, 'examples/app/src/App.tsx:3')).toBe( + path.join(root, 'src/App.tsx') + ':3', + ); + }); + + it('takes an absolute path that exists', () => { + const root = makeDirectory(); + const file = path.join(makeDirectory(), 'server.ts'); + writeFile(file); + expect(resolveEditorLocation(root, `${file}:1:1`)).toBe(`${file}:1:1`); + }); + + it('gives nothing for a file that does not exist', () => { + expect(resolveEditorLocation(makeDirectory(), 'src/Missing.tsx:1:1')).toBeUndefined(); + }); +}); + +describe('readPackageVersion', () => { + it('finds a package installed above the root', () => { + const workspace = makeDirectory(); + writeFile( + path.join(workspace, 'node_modules/solid-js/package.json'), + JSON.stringify({ version: '2.0.0' }), + ); + const root = path.join(workspace, 'apps/site'); + mkdirSync(root, { recursive: true }); + expect(readPackageVersion(root, 'solid-js')).toBe('2.0.0'); + expect(readPackageVersion(root, 'vite')).toBeUndefined(); + }); +}); + +describe('detectDevtoolsPackage', () => { + it('finds the package declared by the app or installed above it', () => { + const declared = makeDirectory(); + writeFile( + path.join(declared, 'package.json'), + JSON.stringify({ devDependencies: { '@solidjs/start-devtools': '^1.0.0' } }), + ); + expect(detectDevtoolsPackage(path.join(declared, 'src'))).toBe(true); + + const itself = makeDirectory(); + writeFile( + path.join(itself, 'package.json'), + JSON.stringify({ name: '@solidjs/start-devtools' }), + ); + expect(detectDevtoolsPackage(path.join(itself, 'examples/demo'))).toBe(true); + + const without = makeDirectory(); + writeFile(path.join(without, 'package.json'), JSON.stringify({ name: 'app' })); + expect(detectDevtoolsPackage(without)).toBe(false); + }); +}); + +describe('serverErrorMessage', () => { + it('carries the error and where the runtime met it', () => { + const error = new TypeError('boom'); + const message = serverErrorMessage(3, error, { + kind: 'server-function', + handling: 'thrown', + functionId: 'abc', + event: { request: new Request('http://localhost/_server', { method: 'POST' }) }, + }); + expect(message).toMatchObject({ + id: 3, + name: 'TypeError', + message: 'boom', + kind: 'server-function', + handling: 'thrown', + functionId: 'abc', + method: 'POST', + url: 'http://localhost/_server', + }); + expect(message.stack).toContain('boom'); + }); + + it('describes a thrown value that is not an error', () => { + expect(serverErrorMessage(1, 'nope', { kind: 'request', handling: 'failed' })).toMatchObject({ + name: 'Error', + message: 'nope', + }); + }); +}); + +describe('listenToServerErrors', () => { + it('hears a failure and still calls the hook the app registers', () => { + const heard: string[] = []; + const stop = listenToServerErrors((error) => heard.push((error as Error).message)); + const appHook = vi.fn(); + configureServerErrors({ onError: appHook }); + + const event = { request: new Request('http://localhost/page') } as never; + reportRequestFailure(new Error('middleware failed'), event); + + expect(heard).toEqual(['middleware failed']); + expect(appHook).toHaveBeenCalledTimes(1); + expect(appHook.mock.calls[0]![1]).toMatchObject({ kind: 'request', handling: 'failed' }); + + stop(); + configureServerErrors({}); + }); + + it('prints a failed request when the app registers no hook', () => { + const stop = listenToServerErrors(() => {}); + const print = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const failure = new Error('no hook'); + reportRequestFailure(failure, { request: new Request('http://localhost/') } as never); + + expect(print).toHaveBeenCalledWith(failure); + print.mockRestore(); + stop(); + }); + + it('puts the app hook back when it stops', () => { + const appHook = vi.fn(); + const stop = listenToServerErrors(() => {}); + configureServerErrors({ onError: appHook }); + stop(); + const heard = vi.fn(); + listenToServerErrors(heard)(); + + reportRequestFailure(new Error('after'), { + request: new Request('http://localhost/'), + } as never); + + expect(appHook).toHaveBeenCalledTimes(1); + expect(heard).not.toHaveBeenCalled(); + configureServerErrors({}); + }); +}); + +describe('devtoolsConnectCode', () => { + it('connects only when the devtools release has connectDevServer', () => { + const code = devtoolsConnectCode().join('\n'); + expect(code).toContain(`import * as __solidDevtools from '@solidjs/start-devtools';`); + expect(code).toContain(`typeof __solidDevtools.connectDevServer === 'function'`); + expect(code).toContain('__solidDevtools.connectDevServer(import.meta.hot)'); + }); + + it('connects before the mount module mounts the toolbar', () => { + const code = devtoolsMountModuleCode(); + expect(code.indexOf('connectDevServer(')).toBeLessThan(code.indexOf('mountDevToolbar()')); + }); +}); diff --git a/src/devtools/server.ts b/src/devtools/server.ts new file mode 100644 index 00000000..9b6a200b --- /dev/null +++ b/src/devtools/server.ts @@ -0,0 +1,273 @@ +/** + * The dev server side of the devtools toolbar (dev serve only). + * + * The toolbar runs in the page and cannot see what happens in the server + * process or on disk. This plugin sends it: + * - the errors the server runtime reports through `configureServerErrors`, + * over Vite's WebSocket; + * - the versions of the packages the app resolves; + * - an open-in-editor endpoint that resolves paths against the Vite root. + * + * The toolbar connects from the client entry this plugin generates, which + * calls `connectDevServer(import.meta.hot)` (see `devtoolsConnectCode`). + */ +import { existsSync, readFileSync } from 'fs'; +import path from 'path'; +import type { Plugin } from 'vite'; +import { DEVTOOLS_PACKAGE } from './index.js'; +import { + DEVTOOLS_HELLO_EVENT, + DEVTOOLS_INFO_EVENT, + DEVTOOLS_SERVER_ERROR_EVENT, + OPEN_IN_EDITOR_PATH, + type DevServerHello, + type DevServerInfo, + type DevServerVersions, + type ServerErrorMessage, +} from './protocol.js'; + +/** The registered symbol `@solidjs/web` keeps the ambient server error hook under. */ +const SERVER_ERRORS = Symbol.for('solid-js/server/errors'); + +/** Errors kept for a page that connects after the render that served it. */ +const KEPT_ERRORS = 50; + +const VERSIONED_PACKAGES = ['solid-js', '@solidjs/web', '@solidjs/vite-plugin', 'vite'] as const; + +type ServerErrorHook = (error: unknown, context: ServerErrorContext) => unknown; + +interface ServerErrorContext { + kind: ServerErrorMessage['kind']; + handling: string; + functionId?: string; + ownerPath?: string[]; + event?: { request?: Request }; +} + +/** The `version` of a package installed at or above `root`, or nothing when it is not found. */ +export function readPackageVersion(root: string, name: string): string | undefined { + let dir = path.resolve(root); + while (true) { + const manifest = path.join(dir, 'node_modules', name, 'package.json'); + if (existsSync(manifest)) { + try { + const version = (JSON.parse(readFileSync(manifest, 'utf8')) as { version?: unknown }) + .version; + return typeof version === 'string' ? version : undefined; + } catch { + return undefined; + } + } + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +/** + * Whether the app uses `@solidjs/start-devtools`: the nearest package.json + * declares it or is the package itself, or it is installed at or above the + * root. Declared or installed, the toolbar can resolve it. + */ +export function detectDevtoolsPackage(root: string): boolean { + let dir = path.resolve(root); + while (true) { + const manifestPath = path.join(dir, 'package.json'); + if (existsSync(manifestPath)) { + try { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + name?: string; + dependencies?: Record; + devDependencies?: Record; + }; + if ( + manifest.name === DEVTOOLS_PACKAGE || + manifest.dependencies?.[DEVTOOLS_PACKAGE] || + manifest.devDependencies?.[DEVTOOLS_PACKAGE] + ) { + return true; + } + } catch { + // An unreadable manifest declares nothing. + } + break; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return readPackageVersion(root, DEVTOOLS_PACKAGE) !== undefined; +} + +export function readVersions(root: string): DevServerVersions { + const versions: DevServerVersions = {}; + for (const name of VERSIONED_PACKAGES) { + const version = readPackageVersion(root, name); + if (version) versions[name] = version; + } + return versions; +} + +/** + * The file an editor location points at, keeping its `:line:col`. + * + * Locations the compiler emits are relative to the Vite root. A location + * written from a parent folder, such as `examples/app/src/App.tsx` when the + * root is `examples/app`, still resolves: leading folders are dropped until + * the file exists. Nothing when no candidate exists. + */ +export function resolveEditorLocation(root: string, location: string): string | undefined { + const match = /^(.*?)((?::\d+){0,2})$/.exec(location); + const file = match?.[1] ?? location; + const position = match?.[2] ?? ''; + if (!file) return undefined; + if (path.isAbsolute(file) && existsSync(file)) return file + position; + + const parts = file.split(/[/\\]/); + for (let index = 0; index < parts.length; index++) { + const candidate = path.resolve(root, ...parts.slice(index)); + if (existsSync(candidate)) return candidate + position; + } + return undefined; +} + +/** The message the toolbar gets for one reported failure. */ +export function serverErrorMessage( + id: number, + error: unknown, + context: ServerErrorContext, +): ServerErrorMessage { + const isError = error instanceof Error; + const message: ServerErrorMessage = { + id, + at: Date.now(), + name: isError ? error.name : 'Error', + message: isError ? error.message : String(error), + kind: context.kind, + handling: context.handling, + }; + if (isError && error.stack) message.stack = error.stack; + if (context.functionId) message.functionId = context.functionId; + if (context.ownerPath) message.ownerPath = [...context.ownerPath]; + const request = context.event?.request; + if (request) { + message.method = request.method; + message.url = request.url; + } + return message; +} + +/** + * Hears every failure the server runtime reports, beside the app's own hook. + * + * `configureServerErrors` keeps one hook on `globalThis`, so registering a + * second one would replace the app's. Instead the slot's `hook` becomes an + * accessor: the app's `configureServerErrors` call sets the app hook, and + * the runtime reads a hook that reports the failure, then calls the app's. + * The server modules run in this process under Vite's module runner, so they + * share this `globalThis`. A server environment in a worker or another process + * is not heard. + */ +export function listenToServerErrors( + report: (error: unknown, context: ServerErrorContext) => void, +): () => void { + const scope = globalThis as Record; + const slot = (scope[SERVER_ERRORS] ??= {}); + let appHook = slot.hook; + + function forward(error: unknown, context: ServerErrorContext): unknown { + try { + report(error, context); + } catch { + // The toolbar's copy must never change how the app handles the failure. + } + if (appHook) return appHook(error, context); + // With no hook registered the runtime prints a failure that fails the + // request. A hook is now always registered, so it prints it here instead. + if (context.handling === 'failed') console.error(error); + return undefined; + } + + Object.defineProperty(slot, 'hook', { + configurable: true, + enumerable: true, + get: () => forward, + set(value: ServerErrorHook | undefined) { + appHook = value; + }, + }); + + return () => { + Object.defineProperty(slot, 'hook', { + configurable: true, + enumerable: true, + writable: true, + value: appHook, + }); + }; +} + +export function solidDevtoolsServer(): Plugin { + let root = process.cwd(); + let versions: DevServerVersions = {}; + let enabled = false; + + return { + name: 'solid:devtools/server', + // Like the diagnostics surface: the toolbar exists in dev serve only, and + // test runs (Vitest serves too) never render it. + apply(_config, env) { + return env.command === 'serve' && !env.isPreview && env.mode !== 'test'; + }, + + configResolved(config) { + root = config.root; + enabled = detectDevtoolsPackage(root); + if (enabled) versions = readVersions(root); + }, + + configureServer(server) { + if (!enabled) return; + + // Kept so a page that loads after an error still gets it: the render + // that serves a page fails before the page can connect. + const kept: ServerErrorMessage[] = []; + let nextId = 1; + + const stopListening = listenToServerErrors((error, context) => { + const message = serverErrorMessage(nextId++, error, context); + kept.push(message); + if (kept.length > KEPT_ERRORS) kept.shift(); + server.ws.send(DEVTOOLS_SERVER_ERROR_EVENT, message); + }); + server.httpServer?.once('close', stopListening); + + server.ws.on(DEVTOOLS_HELLO_EVENT, (hello: DevServerHello, client) => { + const since = typeof hello?.since === 'number' ? hello.since : 0; + const errors: ServerErrorMessage[] = []; + for (const message of kept) { + if (message.at >= since) errors.push(message); + } + const info: DevServerInfo = { versions, errors }; + client.send(DEVTOOLS_INFO_EVENT, info); + }); + + // Hands the resolved path to Vite's own `/__open-in-editor`, which + // launches the editor. Mounted without a path, so connect does not put + // the original URL back before Vite's middleware reads it. + server.middlewares.use((req, res, next) => { + const url = req.url; + if (!url || !url.startsWith(OPEN_IN_EDITOR_PATH + '?')) return next(); + const file = new URL(url, 'http://localhost').searchParams.get('file'); + const resolved = file ? resolveEditorLocation(root, file) : undefined; + if (!resolved) { + res.statusCode = 404; + res.end(`No file found for ${JSON.stringify(file)}`); + return; + } + req.url = `/__open-in-editor?file=${encodeURIComponent(resolved)}`; + next(); + }); + }, + }; +} diff --git a/src/ssr/index.ts b/src/ssr/index.ts index a4f3518f..5c5c5522 100644 --- a/src/ssr/index.ts +++ b/src/ssr/index.ts @@ -66,8 +66,10 @@ import { exactIds } from '../hook-filters.js'; import { DEVTOOLS_MOUNT_ID, DEVTOOLS_PACKAGE, + devtoolsConnectCode, devtoolsMountModuleCode, } from '../devtools/index.js'; +import { solidDevtoolsServer } from '../devtools/server.js'; import { DIAGNOSTICS_CLIENT_ID, detectDiagnosticsPackage } from '../diagnostics/index.js'; import { PERFORMANCE_TRACKS_CLIENT_ID, @@ -1092,6 +1094,7 @@ export function startServe( `import { render } from '@solidjs/web';`, ...errorBoundaryImport(), ...(toolbar ? [`import { DevToolbar } from ${JSON.stringify(DEVTOOLS_PACKAGE)};`] : []), + ...(toolbar ? devtoolsConnectCode() : []), `import App from ${JSON.stringify(app)};`, ``, `render(() => ${ @@ -1109,6 +1112,7 @@ export function startServe( ...clientSetupImport, `import { hydrate } from '@solidjs/web';`, ...(toolbar ? [`import { DevToolbar } from ${JSON.stringify(DEVTOOLS_PACKAGE)};`] : []), + ...(toolbar ? devtoolsConnectCode() : []), ...(serverComponents ? [`import { installServerComponents } from '@solidjs/web/frames';`] : []), @@ -2458,5 +2462,7 @@ export function startServe( ), }, }); + // The dev server side of the toolbar: server errors, versions, open in editor. + if (options.devtools !== false) plugins.push(solidDevtoolsServer()); return plugins; }