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
9 changes: 9 additions & 0 deletions .changeset/start-instrument-early-server-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@solidjs/vite-plugin': patch
---

`start.instrument`: a server-only module the plugin runs to completion before anything else in the server graph loads — the app, the middleware, `@solidjs/web`, every dependency. The seam for instrumentation that must patch the runtime before the modules it patches are loaded (an APM's OpenTelemetry setup, a profiler, a `module.register` hook), honored on every surface: `vite dev`, `vite build`, `vite preview`, and a host consuming the handler entry. Replaces the per-host `node --import instrument.mjs` dance.

Import order cannot do this in ESM — static imports are hoisted and evaluated in dependency order — so the generated handler entry becomes `await import(instrument); await import(handler)`, with the handler's surface (`handleRequest`, the `fetch` default) re-declared by name. The module may be async and needs no exports; the server build must keep code splitting on (the default).

Also: the `componentNames` note in the compiler options no longer calls the labels DOM-only — the SSR generate emits them too from the compilers that carry solidjs/solid#3441 (2.0.0-rc.9), and the start-ssr suite gains an `observe` mode that asserts an `observe: true` production build resolves the observe artifacts and carries component labels (the SSR half asserted once the workspace rides an rc that emits them).
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,32 @@ whatever the hook renders must be matched client-side for hydration —
routers that own both sides (their client entry re-creates the router and
hydrates the same tree) fit naturally.

**`instrument`** — a server-only module that runs to completion before
anything else in the server graph loads: the app, the middleware,
`@solidjs/web`, every dependency. The seam for instrumentation that must
patch the runtime before the modules it patches are loaded — an APM's
OpenTelemetry setup, a profiler, a `module.register` hook:

```ts
// vite.config.ts
solid({ start: { instrument: './src/instrument.ts' }, ssr: true });

// src/instrument.ts
import * as Sentry from '@sentry/node';
Sentry.init({ dsn: process.env.SENTRY_DSN, tracesSampleRate: 1 });
```

Import order alone cannot do this in ESM: static imports are hoisted and
evaluated in dependency order, so `import './instrument'` at the top of an
entry still runs after everything the entry imports. The plugin therefore
hands out the handler as `await import(instrument); await import(handler)`
— top-level await sequencing, the one construct that guarantees the order —
on every surface (`vite dev`, `vite build`, `vite preview`, a host consuming
the handler entry). This replaces the per-host `node --import
instrument.mjs` dance. The module may be async and needs no exports; the
server build must keep code splitting on (the default), since inlining
dynamic imports would hoist the handler graph back above the instrument.

**`renderMode`** — how a page render becomes a response body: `'stream'`
(the default) or `'async'`, or a module path deciding per request.

Expand Down
27 changes: 27 additions & 0 deletions examples/start-ssr/src/instrument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// `start.instrument` (SSR_INSTRUMENT=1, middleware mode): the module the
// plugin awaits to completion before anything else in the server graph
// loads — the seam an APM's OpenTelemetry setup needs (`Sentry.init()`
// must run before the modules it patches are loaded). This fake proves the
// contract without an APM:
// - it evaluates FIRST: nothing from the app graph has run yet, so the
// ordering register it creates is empty (middleware.ts appends to it at
// its own module top level, which cannot happen before this line),
// - it is awaited to COMPLETION: real async work here (a timer) finishes
// before the handler graph is even imported — middleware.ts reads `done`
// at its top level and finds it true. A static `import './instrument'`
// would fail this: ESM hoists static imports and evaluates them in
// dependency order, so the handler's `@solidjs/web` import graph would
// run before this module's body.
// - it counts evaluations: once per server process (dev and prod), not once
// per request.
declare global {
// eslint-disable-next-line no-var
var __solidInstrument: { order: string[]; done: boolean; evaluations: number } | undefined;
}

const state = (globalThis.__solidInstrument ??= { order: [], done: false, evaluations: 0 });
state.evaluations++;
const alreadyLoaded = state.order.length;
await new Promise((resolve) => setTimeout(resolve, 20));
state.order.push(`instrument(before:${alreadyLoaded})`);
state.done = true;
11 changes: 11 additions & 0 deletions examples/start-ssr/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@
// handle fall back to Vite's own pipeline in dev.
import { getRequestEvent } from '@solidjs/web';

// `start.instrument` evidence (SSR_INSTRUMENT=1): this module evaluates as
// part of the handler graph — after `@solidjs/web` above — so what the
// instrument register holds HERE is what it held before the graph loaded.
// A `done: true` at this point means the instrument module was awaited to
// completion first, not merely imported first.
const instrumentAtLoad = globalThis.__solidInstrument
? { ...globalThis.__solidInstrument, order: [...globalThis.__solidInstrument.order] }
: null;
globalThis.__solidInstrument?.order.push('middleware');

type Next = (request?: Request) => Promise<Response>;

// A minimal filesystem-routing/createAPIHandler stand-in: owns /api/* and
Expand Down Expand Up @@ -124,6 +134,7 @@ async function first(request: Request, next: Next): Promise<Response> {
// these must be observable on the response head.
response.headers.set('x-mw-order', (event.locals.order as string[]).join(','));
response.headers.set('x-after-next', 'set-after-next');
if (instrumentAtLoad) response.headers.set('x-instrument', JSON.stringify(instrumentAtLoad));
return response;
} catch (error) {
return new Response(`caught: ${error instanceof Error ? error.message : String(error)}`, {
Expand Down
161 changes: 158 additions & 3 deletions examples/start-ssr/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
// (default: all)

import { spawn, execSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
import {
Expand Down Expand Up @@ -2729,6 +2730,37 @@ async function runFramesMode() {
// - error middleware (/boom: a render throw becomes the middleware's 500).
async function runMiddlewareChecksOverHttp(mode, origin, functionId) {
const page = await fetchStreamed(origin + '/');
// ---- start.instrument (src/instrument.ts) -------------------------------
// What middleware.ts saw at ITS module load, i.e. after `@solidjs/web`
// and the handler graph began evaluating: the instrument had run first
// (nothing ahead of it in the register), had been awaited to completion
// (its timer finished: `done`), and had evaluated once for the process.
const instrumentRaw = page.headers.get('x-instrument');
let instrument = null;
try {
instrument = instrumentRaw ? JSON.parse(instrumentRaw) : null;
} catch {}
record(
mode,
'instrument',
'instrument module ran before the handler graph, first in the register',
!!instrument && instrument.order[0] === 'instrument(before:0)',
`x-instrument: ${instrumentRaw}`,
);
record(
mode,
'instrument',
'instrument module was awaited to completion before @solidjs/web loaded',
!!instrument && instrument.done === true,
`x-instrument: ${instrumentRaw}`,
);
record(
mode,
'instrument',
'instrument module evaluated once per server process',
!!instrument && instrument.evaluations === 1,
`x-instrument: ${instrumentRaw}`,
);
record(
mode,
'mw',
Expand Down Expand Up @@ -3015,7 +3047,15 @@ async function runMiddlewareMode() {
// SSR_SETUP rides the middleware mode: the hook's contract (ordering
// after the chain, shared locals) is only observable with a middleware
// in front anyway.
const env = { ...process.env, SSR_MIDDLEWARE: '1', SSR_SETUP: '1', SSR_DEVTOOLS: '0' };
// SSR_INSTRUMENT rides along too: the instrument module's evidence is a
// header the middleware sets, so it needs the chain in front as well.
const env = {
...process.env,
SSR_MIDDLEWARE: '1',
SSR_SETUP: '1',
SSR_INSTRUMENT: '1',
SSR_DEVTOOLS: '0',
};

let server;
let serverLog = '';
Expand All @@ -3034,10 +3074,34 @@ async function runMiddlewareMode() {
// mutation stays possible through the whole unwind.
process.env.SSR_MIDDLEWARE = '1';
process.env.SSR_SETUP = '1';
process.env.SSR_INSTRUMENT = '1';
let probe;
try {
probe = await createServer({ root: exampleDir, server: { middlewareMode: true } });
const transformed = await probe.environments.ssr.transformRequest('virtual:solid-ssr-handler');
// ---- Codegen: start.instrument sequences the handler behind two awaits
// The entry the plugin hands out is a wrapper: `await import(instrument)`
// then `await import(impl)`, exports re-declared by name. Static
// imports would be hoisted and defeat the ordering.
const wrapper = (await probe.environments.ssr.transformRequest('virtual:solid-ssr-handler'))?.code || '';
const instrumentAt = wrapper.indexOf('instrument.ts');
const implAt = wrapper.indexOf('virtual:solid-ssr-handler-impl');
record(
'mw-codegen',
'instrument',
'handler entry awaits the instrument module before importing the handler',
instrumentAt !== -1 && implAt !== -1 && instrumentAt < implAt && /await\s+__vite_ssr_dynamic_import__|await\s+import/.test(wrapper),
wrapper.slice(0, 400),
);
record(
'mw-codegen',
'instrument',
'wrapper re-declares the handler surface by name (handleRequest, default)',
// The SSR transform rewrites `export const`/`export default` into
// defineProperty calls on the exports object; match the names.
/["']?handleRequest["']?/.test(wrapper) && /["']default["']|export\s+default/.test(wrapper),
wrapper.slice(0, 400),
);
const transformed = await probe.environments.ssr.transformRequest('virtual:solid-ssr-handler-impl');
const code = transformed?.code || '';
const unwind = code.indexOf('runMiddleware(request');
// The SSR transform rewrites the imported binding to a member access
Expand Down Expand Up @@ -3115,6 +3179,7 @@ async function runMiddlewareMode() {
await probe?.close();
delete process.env.SSR_MIDDLEWARE;
delete process.env.SSR_SETUP;
delete process.env.SSR_INSTRUMENT;
}

// ---- Dev: the chain fronts the dev middlewares -----------------------
Expand Down Expand Up @@ -3207,7 +3272,9 @@ async function runPreviewMode() {
// SSR_SETUP rides along like in middleware mode: the shared chain checks
// assert the per-request setup hook, and preview must serve the built
// entry that threads it exactly like dev and prod.
const env = { ...process.env, SSR_MIDDLEWARE: '1', SSR_SETUP: '1' };
// SSR_INSTRUMENT too: `vite preview` serves the built handler, so the
// instrument sequencing is asserted on the third surface here.
const env = { ...process.env, SSR_MIDDLEWARE: '1', SSR_SETUP: '1', SSR_INSTRUMENT: '1' };

let server;
let serverLog = '';
Expand Down Expand Up @@ -4161,6 +4228,92 @@ async function runBabelHmrMode() {
}
}

// `observe: true` (SOLID_OBSERVE=1, src/App.tsx unchanged): a production
// build on Solid's observe tier. What the built bundles must show:
// - the client resolved the observe artifact (`web.observe` chunk) and every
// component call carries its source label — `createComponent(Comp, props,
// "Comp")` survives minification as a string, which is what lets owner
// paths read `<App> › <Feed>` in a minified app;
// - the server resolved solid-js's observe build (findings and boundary
// records need it);
// - the server bundle carries component labels in its SSR output and
// @solidjs/web's observe server build. Both landed in Solid after
// 2.0.0-rc.8 (solidjs/solid#3441, #3433), so on older installs the two
// checks record the reason instead of failing; they become real
// assertions the moment the workspace rides an rc that carries them.
async function runObserveMode() {
const mode = 'observe';
console.log(`\n=== ${mode.toUpperCase()} ===`);
const env = { ...process.env, SOLID_OBSERVE: '1' };
console.log(' building…');
execSync('pnpm run build', { cwd: exampleDir, stdio: 'pipe', env });

const clientDir = path.join(exampleDir, 'dist/client/assets');
const clientFiles = readdirSync(clientDir).filter((f) => f.endsWith('.js'));
const entryFile = clientFiles.find((f) => f.startsWith('virtual_solid-ssr-entry-client-'));
const entry = entryFile ? readFileSync(path.join(clientDir, entryFile), 'utf-8') : '';
record(
mode,
'client',
'client entry resolves the observe artifact of @solidjs/web',
clientFiles.some((f) => f.startsWith('web.observe-')) && /web\.observe-/.test(entry),
clientFiles.join(', '),
);
// Minifiers emit the label with either quote style; the App's component
// tags are the labels expected.
const labelsIn = (code) =>
[...code.matchAll(/,\s*(["'`])([A-Z][A-Za-z]*)\1\)/g)].map((m) => m[2]);
const clientLabels = new Set(labelsIn(entry));
record(
mode,
'client',
'client components compile with their source labels (componentNames)',
clientLabels.has('HmrTarget') && clientLabels.size >= 5,
`labels: ${[...clientLabels].join(', ') || 'none'}`,
);

const serverBundle = readFileSync(path.join(exampleDir, 'dist/server/server.js'), 'utf-8');
record(
mode,
'server',
'server bundle resolves the observe build of solid-js',
/solid-js\/dist\/server\.observe\.js|server\.observe/.test(serverBundle),
);
// The two rc.9 checks: what the installed Solid can and cannot do yet.
const exampleRequire = createRequire(path.join(exampleDir, 'package.json'));
const prereleaseOf = (pkg) => {
// `package.json` may not be exported; resolve the entry and walk up.
let dir = path.dirname(exampleRequire.resolve(pkg));
while (!existsSync(path.join(dir, 'package.json'))) dir = path.dirname(dir);
const v = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf-8')).version;
const m = /-rc\.(\d+)$/.exec(v);
return { version: v, rc: m ? Number(m[1]) : Infinity };
};
const compiler = prereleaseOf('@solidjs/compiler');
const web = prereleaseOf('@solidjs/web');
const serverLabels = new Set(labelsIn(serverBundle));
const ssrLabelsExpected = compiler.rc >= 9;
record(
mode,
'server',
'SSR output compiles with component labels (compilers from solidjs/solid#3441, 2.0.0-rc.9)',
ssrLabelsExpected ? serverLabels.has('HmrTarget') : true,
ssrLabelsExpected
? `labels: ${[...serverLabels].join(', ') || 'none'}`
: `not asserted: @solidjs/compiler ${compiler.version} predates SSR componentNames`,
);
const webObserveExpected = web.rc >= 9;
record(
mode,
'server',
"server bundle resolves @solidjs/web's observe build (server records and findings, 2.0.0-rc.9)",
webObserveExpected ? /@solidjs\/web\/dist\/server\.observe\.js/.test(serverBundle) : true,
webObserveExpected
? 'expected @solidjs/web/dist/server.observe.js in the bundle'
: `not asserted: @solidjs/web ${web.version} has no server observe build`,
);
}

async function runExternalMode() {
const mode = 'external';
console.log(`\n=== ${mode.toUpperCase()} ===`);
Expand Down Expand Up @@ -5072,6 +5225,7 @@ const ALL_MODES = [
'frames',
'babel-hmr',
'external',
'observe',
'detect',
'vitest',
'node',
Expand All @@ -5097,6 +5251,7 @@ for (const mode of modes) {
else if (mode === 'frames') await runFramesMode();
else if (mode === 'babel-hmr') await runBabelHmrMode();
else if (mode === 'external') await runExternalMode();
else if (mode === 'observe') await runObserveMode();
else if (mode === 'vitest') await runVitestMode();
else if (mode === 'node') await runNodeMode();
else await runDetectMode();
Expand Down
13 changes: 13 additions & 0 deletions examples/start-ssr/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ import solidPlugin from '@solidjs/vite-plugin';
// - SSR_SETUP=1 wires src/setup.tsx through `start.setup` (middleware mode):
// the per-request app-setup hook, awaited between the middleware chain and
// renderToStream with the shared request event in hand.
// - SSR_INSTRUMENT=1 wires src/instrument.ts through `start.instrument`
// (middleware mode): the module awaited to completion before the handler
// graph loads — the APM/OpenTelemetry seam; middleware.ts reports what it
// saw at its own load in an `x-instrument` header.
// - SOLID_OBSERVE=1 (observe mode) turns on `observe`: the `observe` export
// condition everywhere and the compiler's `componentNames` for both
// postures, so the built server AND client bundles carry component labels.
// - SSR_RENDER_MODE sets `start.renderMode` (render-mode mode): `module`
// wires src/render-mode.ts (the per-request policy: header / crawler UA /
// `?nojs`); any other value passes through verbatim — `async` for the
Expand Down Expand Up @@ -135,6 +142,11 @@ export default defineConfig({
solidPlugin({
compiler: jsxCompiler,
ssr: true,
// SOLID_OBSERVE=1 (observe mode): the production-speed runtime that
// keeps `OBSERVE` alive, with component labels compiled into BOTH
// postures — the server bundle's boundary records and findings locate
// by component the same way the client's do.
...(process.env.SOLID_OBSERVE ? { observe: true } : {}),
start: serverComponents
? { app: 'src/frames/FramesApp.tsx' }
: process.env.SSR_DOCUMENT
Expand Down Expand Up @@ -181,6 +193,7 @@ export default defineConfig({
// renderToStream, receiving the event and returning the
// component to render (the TanStack-style async router seam).
...(process.env.SSR_SETUP ? { setup: './src/setup.tsx' } : {}),
...(process.env.SSR_INSTRUMENT ? { instrument: './src/instrument.ts' } : {}),
// SSR_RENDER_MODE (render-mode mode): `module` → the
// per-request policy module; anything else verbatim
// (`async`, or an invalid value for the validation checks).
Expand Down
8 changes: 5 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,9 +561,11 @@ function getSolidOptions(
// Component labels: the dev and observe runtimes name each component's
// owner (`<Home>`) for diagnostics and attribution paths. Without the
// compiler carrying the source tag name, a minified build labels owners by
// whatever the minifier left of `Comp.name`. DOM-only by construction (the
// ssr generate ignores the flag), and the production runtime ignores the
// argument, so it is only emitted for the postures whose runtime reads it.
// whatever the minifier left of `Comp.name`. Both generates emit it — the
// ssr generate from the compilers that carry solidjs/solid#3441
// (2.0.0-rc.9), so server findings and boundary records locate by
// component too — and the production runtime ignores the argument, so it
// is only emitted for the postures whose runtime reads it.
return {
...solidOptions,
...(serverComponents && solidOptions.generate === 'ssr' ? { serverComponents: true } : {}),
Expand Down
Loading
Loading