Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/devtools-dev-server.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions src/devtools/index.ts
Original file line number Diff line number Diff line change
@@ -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');
}
56 changes: 56 additions & 0 deletions src/devtools/protocol.ts
Original file line number Diff line number Diff line change
@@ -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;
}
187 changes: 187 additions & 0 deletions src/devtools/server.test.ts
Original file line number Diff line number Diff line change
@@ -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()'));
});
});
Loading
Loading