Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .changeset/dev-server-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@solidjs/start-devtools': minor
---

Add `connectDevServer`, which lets `@solidjs/vite-plugin` send the toolbar what only the dev server knows, and `@solidjs/start-devtools/protocol`, the messages and endpoints both sides use.

- Errors the server runtime reports during a render, a server function or a request show in the Health view.
- Open in editor sends one request to the plugin's endpoint, which resolves the path against the Vite root.
- The solid-js version in the header comes from the dev server when the page cannot import the package file.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"import": "./dist/index.js"
},
"default": "./dist/noop.js"
},
"./protocol": {
"types": "./dist/types/protocol.d.ts",
"default": "./dist/protocol.js"
}
},
"scripts": {
Expand Down
9 changes: 8 additions & 1 deletion rolldown.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,15 @@ export default defineConfig([
generate: 'ssr',
server: true,
}),
config({
input: 'src/protocol.ts',
entryFileNames: 'protocol.js',
chunkFileNames: 'protocol-chunks/[name]-[hash].js',
generate: 'ssr',
server: true,
}),
{
input: 'src/index.tsx',
input: ['src/index.tsx', 'src/protocol.ts'],
output: {
format: 'esm',
dir: 'dist/types',
Expand Down
117 changes: 117 additions & 0 deletions src/dev-toolbar/dev-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest';
import {
DEVTOOLS_HELLO_EVENT,
DEVTOOLS_INFO_EVENT,
DEVTOOLS_SERVER_ERROR_EVENT,
type DevServerChannel,
type DevServerInfo,
type ServerErrorMessage,
} from '../protocol.js';
import { createDevServerConnection, serverErrorPlace, serverErrorValue } from './dev-server.js';

function fakeChannel() {
const handlers = new Map<string, (data: any) => void>();
const sent: { event: string; data: unknown }[] = [];
const channel: DevServerChannel = {
on(event, listener) {
handlers.set(event, listener);
},
send(event, data) {
sent.push({ event, data });
},
};
function emit(event: string, data: unknown): void {
handlers.get(event)?.(data);
}
return { channel, sent, emit };
}

function serverError(over: Partial<ServerErrorMessage> = {}): ServerErrorMessage {
return {
id: 1,
at: 1000,
name: 'Error',
message: 'boom',
stack: 'Error: boom\n at load (/app/src/api.ts:3:9)',
kind: 'render',
handling: 'failed',
...over,
};
}

function info(errors: ServerErrorMessage[] = []): DevServerInfo {
return { versions: { 'solid-js': '2.0.0' }, errors };
}

describe('createDevServerConnection', () => {
it('says hello with the time the page started loading', () => {
const connection = createDevServerConnection();
const { channel, sent } = fakeChannel();
connection.connect(channel, 500);
connection.connect(channel, 600);

expect(sent).toEqual([{ event: DEVTOOLS_HELLO_EVENT, data: { since: 500 } }]);
});

it('hands a late listener the info and the errors that came before it', () => {
const connection = createDevServerConnection();
const { channel, emit } = fakeChannel();
connection.connect(channel, 0);
emit(DEVTOOLS_INFO_EVENT, info([serverError()]));
emit(DEVTOOLS_SERVER_ERROR_EVENT, serverError({ id: 2 }));

const infos: DevServerInfo[] = [];
const ids: number[] = [];
connection.listen({
onInfo: (value) => infos.push(value),
onServerError: (message) => ids.push(message.id),
});

expect(infos).toHaveLength(1);
expect(connection.info()?.versions['solid-js']).toBe('2.0.0');
expect(ids).toEqual([1, 2]);
});

it('drops an error it already has', () => {
const connection = createDevServerConnection();
const { channel, emit } = fakeChannel();
connection.connect(channel, 0);
const ids: number[] = [];
connection.listen({ onServerError: (message) => ids.push(message.id) });

emit(DEVTOOLS_SERVER_ERROR_EVENT, serverError());
emit(DEVTOOLS_INFO_EVENT, info([serverError()]));

expect(ids).toEqual([1]);
});

it('stops calling a listener that was removed', () => {
const connection = createDevServerConnection();
const { channel, emit } = fakeChannel();
connection.connect(channel, 0);
const ids: number[] = [];
const stop = connection.listen({ onServerError: (message) => ids.push(message.id) });
stop();
emit(DEVTOOLS_SERVER_ERROR_EVENT, serverError());

expect(ids).toEqual([]);
});
});

describe('serverErrorValue', () => {
it('keeps the name, message and stack the server sent', () => {
const error = serverErrorValue(serverError({ name: 'TypeError' }));
expect(error.name).toBe('TypeError');
expect(error.message).toBe('boom');
expect(error.stack).toContain('/app/src/api.ts:3:9');
});
});

describe('serverErrorPlace', () => {
it('names the server function that threw', () => {
expect(serverErrorPlace(serverError({ kind: 'server-function', functionId: 'abc' }))).toBe(
'Server function abc',
);
expect(serverErrorPlace(serverError({ kind: 'request' }))).toBe('Server request');
});
});
118 changes: 118 additions & 0 deletions src/dev-toolbar/dev-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
DEVTOOLS_HELLO_EVENT,
DEVTOOLS_INFO_EVENT,
DEVTOOLS_SERVER_ERROR_EVENT,
type DevServerChannel,
type DevServerHello,
type DevServerInfo,
type ServerErrorMessage,
} from '../protocol.js';
import { MAX_ERRORS } from './error-list.js';

export interface DevServerListener {
onInfo?(info: DevServerInfo): void;
onServerError?(message: ServerErrorMessage): void;
}

export interface DevServerConnection {
/** Sends the hello and starts listening to the channel. Later calls do nothing. */
connect(channel: DevServerChannel, since: number): void;
/** The info the server answered with, once it has. */
info(): DevServerInfo | undefined;
/**
* Adds a listener. It hears the info and the errors that arrived before it
* was added, then the ones after. The returned function removes it.
*/
listen(listener: DevServerListener): () => void;
}

/**
* The toolbar's side of the dev server channel.
*
* The plugin connects before the toolbar renders, so the info and the first
* errors can arrive before any panel listens. The connection keeps them and
* hands them to each listener that joins later.
*/
export function createDevServerConnection(): DevServerConnection {
const listeners = new Set<DevServerListener>();
const seen = new Set<number>();
const errors: ServerErrorMessage[] = [];
let info: DevServerInfo | undefined;
let connected = false;

function receiveError(message: ServerErrorMessage): void {
// The info's errors and a live event can carry the same error.
if (seen.has(message.id)) return;
seen.add(message.id);
errors.push(message);
if (errors.length > MAX_ERRORS) seen.delete(errors.shift()!.id);
for (const listener of listeners) listener.onServerError?.(message);
}

return {
connect(channel, since) {
if (connected) return;
connected = true;
channel.on(DEVTOOLS_INFO_EVENT, (data: DevServerInfo) => {
info = data;
for (const listener of listeners) listener.onInfo?.(data);
for (const message of data.errors) receiveError(message);
});
channel.on(DEVTOOLS_SERVER_ERROR_EVENT, receiveError);
const hello: DevServerHello = { since };
channel.send(DEVTOOLS_HELLO_EVENT, hello);
},
info: () => info,
listen(listener) {
listeners.add(listener);
if (info) listener.onInfo?.(info);
for (const message of errors) listener.onServerError?.(message);
return () => {
listeners.delete(listener);
};
},
};
}

const shared = createDevServerConnection();

/**
* Connects the toolbar to the dev server. `@solidjs/vite-plugin` calls this
* with `import.meta.hot` from the client entry it generates.
*/
export function connectDevServer(channel: DevServerChannel): void {
shared.connect(channel, performance.timeOrigin);
}

export function listenToDevServer(listener: DevServerListener): () => void {
return shared.listen(listener);
}

export function devServerInfo(): DevServerInfo | undefined {
return shared.info();
}

const serverErrorValues = new WeakSet<object>();

/** A server error as an `Error`, so the error panel shows it like one thrown in the page. */
export function serverErrorValue(message: ServerErrorMessage): Error {
const error = new Error(message.message);
error.name = message.name;
error.stack = message.stack ?? `${message.name}: ${message.message}`;
serverErrorValues.add(error);
return error;
}

/** Whether `serverErrorValue` made the value, so the page never threw it. */
export function isServerErrorValue(value: unknown): boolean {
return typeof value === 'object' && value !== null && serverErrorValues.has(value);
}

/** Where on the server the error happened, for the subtitle of its row. */
export function serverErrorPlace(message: ServerErrorMessage): string {
if (message.kind === 'server-function') {
return message.functionId ? `Server function ${message.functionId}` : 'Server function';
}
if (message.kind === 'render') return 'Server render';
return 'Server request';
}
64 changes: 64 additions & 0 deletions src/dev-toolbar/error-record.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import type { ServerErrorMessage } from '../protocol.js';
import {
addPageError,
addServerError,
createServerErrorRecord,
type ErrorRecord,
} from './error-record.js';

function serverMessage(over: Partial<ServerErrorMessage> = {}): ServerErrorMessage {
return {
id: 1,
at: performance.timeOrigin,
name: 'Error',
message: 'boom',
kind: 'render',
handling: 'fallback',
...over,
};
}

function pageRecord(message = 'boom'): ErrorRecord {
return { error: new Error(message), at: 10, origin: undefined };
}

describe('render errors the page throws again', () => {
it('merges the server copy into the error the page caught first', () => {
const page = pageRecord();
const list = addServerError([page], createServerErrorRecord(serverMessage()));

expect(list).toHaveLength(1);
expect(list[0]!.error).toBe(page.error);
expect(list[0]!.server?.handling).toBe('fallback');
});

it('merges the page error into the server copy that came first', () => {
const page = pageRecord();
const list = addPageError([createServerErrorRecord(serverMessage())], page);

expect(list).toHaveLength(1);
expect(list[0]!.error).toBe(page.error);
expect(list[0]!.server?.id).toBe(1);
});

it('keeps errors with another message, or from a server function, apart', () => {
expect(
addServerError([pageRecord('other')], createServerErrorRecord(serverMessage())),
).toHaveLength(2);
expect(
addPageError(
[createServerErrorRecord(serverMessage({ kind: 'server-function' }))],
pageRecord(),
),
).toHaveLength(2);
});

it('merges one page error into one server copy only', () => {
let list = addServerError([], createServerErrorRecord(serverMessage()));
list = addPageError(list, pageRecord());
list = addPageError(list, pageRecord());

expect(list).toHaveLength(2);
});
});
Loading
Loading