diff --git a/.changeset/rc10-live-address-preview-passthrough-prebundle.md b/.changeset/rc10-live-address-preview-passthrough-prebundle.md new file mode 100644 index 0000000..45ff5f9 --- /dev/null +++ b/.changeset/rc10-live-address-preview-passthrough-prebundle.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +Follow-ups to riding Solid 2.0.0-rc.10. The server-function dev middleware reads the id out of the runtime's third address shape, `/live/` (a `live` loop's connection), the way it already did for `/data/` — mirroring the runtime's `parseServerFunctionAddress`, one kind segment at most — so a live function whose module nothing has imported server-side yet is loaded on demand and the first call resolves instead of the runtime answering `404 Unknown server function`. `vite preview` passes `text/event-stream` responses and everything under the server-function mount through its compression middleware as `content-encoding: identity`, as it already did for `text/html`: the middleware compresses from a ≥1KiB first write and then holds small writes in zlib's buffer, which would leave a live event stream's events (and the streaming codec's chunks) undelivered while the connection stays open. Under `serve`, `solid-js/attribution` and `@solidjs/web/performance-tracks` join `optimizeDeps.include`, so a consumer the dependency scanner never crawls (a linked package, a `solid`-condition package vitefu excluded) importing one mid-session no longer triggers a late discovery → re-optimize → a second `@solidjs/signals` core beside the one `solid-js` was bundled with. diff --git a/README.md b/README.md index ed607cf..fc11153 100644 --- a/README.md +++ b/README.md @@ -121,9 +121,9 @@ A [picomatch](https://github.com/micromatch/picomatch) pattern, or array of patt - Type: Boolean - Default: true -This will inject `solid-js/dev` in place of `solid-js` in dev mode. Has no effect in prod. -If set to false, it won't inject it in dev. -This is useful for extra logs and debug. +Resolve Solid's development builds under `vite dev` — the `development` export condition of +`solid-js` and `@solidjs/web`, which carry the extra checks, warnings and diagnostics. Has no +effect on `vite build`. Set to `false` to serve the production builds in dev instead. #### options.observe diff --git a/examples/start-ssr/test/components-warning.mjs b/examples/start-ssr/test/components-warning.mjs index fd9029e..94d48c2 100644 --- a/examples/start-ssr/test/components-warning.mjs +++ b/examples/start-ssr/test/components-warning.mjs @@ -112,6 +112,31 @@ async function resolveWith(solidOptions) { componentsWarnings.length === 0 && !config.optimizeDeps.include.includes('@solidjs/web/frames'), ); + // Independent of server components: the attribution engine and the + // performance-tracks recorder are pre-bundled in the first optimizer pass + // under `serve`, so a consumer the scanner never crawls (a linked package, + // a `solid`-condition package vitefu excluded) importing one mid-session + // can't re-optimize a second @solidjs/signals core into the page. + record( + 'serve: solid-js/attribution and @solidjs/web/performance-tracks pre-bundled', + config.optimizeDeps.include.includes('solid-js/attribution') && + config.optimizeDeps.include.includes('@solidjs/web/performance-tracks'), + config.optimizeDeps.include.join(', '), + ); +} + +// ---- build: optimizeDeps is serve-only, the entries stay out -------------- +{ + const config = await resolveConfig( + { root: exampleDir, configFile: false, plugins: [solidPlugin({ ssr: true })] }, + 'build', + ); + record( + 'build: attribution / performance-tracks not in optimizeDeps.include', + !config.optimizeDeps.include.includes('solid-js/attribution') && + !config.optimizeDeps.include.includes('@solidjs/web/performance-tracks'), + config.optimizeDeps.include.join(', '), + ); } // ---- full SSR start mode: `'external'` is redundant but harmless --------- diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index b898951..6bac8d1 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -998,6 +998,56 @@ async function runDevMode() { record(mode, 'sf', 'dev middleware rejects unknown id', bogus.status === 404); await runCsrfChecks(mode, origin, functionId); + // ---- Live address, cold: `/live/` (@solidjs/web rc.10) -- + // A `live` loop connects at the third address shape, answered as an + // event stream. The function lives in a module nothing has loaded + // server-side yet — src/frames/data.tsx is the frames page's; App.tsx + // never imports it, and the bare cold dispatch above loaded api.ts only + // — so the middleware has to read the id out of the live shape and + // import the module itself. Read as a `data/`-only shape the id parsed + // to null, the import was skipped, and the runtime 404'd the call. + const liveModule = await (await fetch(origin + '/src/frames/data.tsx')).text(); + const liveId = extractFunctionId(liveModule, 'incrementCounter'); + const live = liveId + ? await fetch(`${origin}/_server/live/${encodeURIComponent(liveId)}`, { method: 'POST' }) + : null; + // A live body would stay open on a source that keeps yielding; a plain + // value is one event and the end of the stream — bound the read so a + // regression into an open connection fails instead of hanging. + const liveBody = live + ? await Promise.race([ + live.text(), + new Promise((resolve) => setTimeout(() => resolve(null), 10000)), + ]) + : null; + record( + mode, + 'sf', + 'cold dispatch at the live address imports the module on demand (event stream)', + !!live && + live.status === 200 && + (live.headers.get('content-type') || '').startsWith('text/event-stream') && + typeof liveBody === 'string' && + /^data: /m.test(liveBody), + liveId + ? `status ${live.status}, content-type ${JSON.stringify(live.headers.get('content-type'))}, body ${JSON.stringify(liveBody)}` + : 'could not extract function id', + ); + // The runtime's address grammar, mirrored: one kind segment at most, and + // an id is exactly one segment — the middleware must not import a module + // for these either (the runtime answers 404 in every case). + for (const miss of ['/_server/live/', '/_server/live/a/b', '/_server/data/live/' + liveId]) { + const res = await fetch(origin + miss, { method: 'POST' }); + await res.text(); + record( + mode, + 'sf', + `live address grammar: ${miss} is a miss`, + res.status === 404, + `status ${res.status}`, + ); + } + const html = await runSsrChecks(mode, origin); record(mode, 'dev', 'Vite client injected into ', html.includes('/@vite/client')); // The patch script's own source references the selector; match it inside @@ -3323,6 +3373,103 @@ async function runPreviewMode() { const bogus = await fetch(origin + '/_server/bogus-0', { method: 'POST' }); record(mode, 'sf', 'preview dispatches /_server (unknown id rejected)', bogus.status === 404); + // ---- Streamed shapes pass preview's compression untouched ----------- + // Preview's compression middleware sizes a response up on its first + // write and, at ≥1KiB, pipes the rest through zlib — which holds small + // writes until its buffer fills. A live event stream (@solidjs/web + // 2.0.0-rc.10, `/live/`) stays open, so a held event is + // one never delivered; the plugin passes `text/event-stream` and + // everything under the server-function mount through as identity, like + // it already did for the streamed page. Answers big enough to trip the + // threshold, at both scripted shapes: without the exemption they come + // back gzip/br (fetch decodes transparently — the header is the tell). + // The registrations sit in the handler chunk — split out of server.js + // under `start.instrument` (this mode's env) — so scan the whole + // server build. + const serverDist = path.join(exampleDir, 'dist/server'); + const serverAssetsDir = path.join(serverDist, 'assets'); + const previewBundle = [ + path.join(serverDist, 'server.js'), + ...(existsSync(serverAssetsDir) + ? readdirSync(serverAssetsDir) + .filter((f) => f.endsWith('.js')) + .map((f) => path.join(serverAssetsDir, f)) + : []), + ] + .map((file) => readFileSync(file, 'utf-8')) + .join('\n'); + const registeredId = (name) => + previewBundle.match(new RegExp(`registerServerReference\\w*\\("(${name}-[^"]+)"`))?.[1] ?? + null; + // `greet` answers an object (via respond(), status 201): a value the + // live address frames as an event. A string result has a natural HTTP + // body and takes the ordinary road even there, so `getServerMessage` + // is the data-address probe below, not this one. + const greetId = registeredId('greet'); + const messageId = registeredId('getServerMessage'); + const bigName = 'x'.repeat(2000); + const bigArgs = `?args=${encodeURIComponent(JSON.stringify([bigName]))}`; + const compressible = { method: 'POST', headers: { 'accept-encoding': 'gzip, br' } }; + const notCompressed = (res) => + !/\b(gzip|br|deflate)\b/.test(res.headers.get('content-encoding') || ''); + const liveRes = greetId + ? await fetch(`${origin}/_server/live/${encodeURIComponent(greetId)}${bigArgs}`, compressible) + : null; + const liveText = liveRes + ? await Promise.race([ + liveRes.text(), + new Promise((resolve) => setTimeout(() => resolve(null), 10000)), + ]) + : null; + record( + mode, + 'sf', + 'live event stream passes preview compression as identity', + !!liveRes && + liveRes.status === 201 && + (liveRes.headers.get('content-type') || '').startsWith('text/event-stream') && + notCompressed(liveRes) && + typeof liveText === 'string' && + /^data: /m.test(liveText) && + liveText.includes(bigName), + greetId + ? `status ${liveRes.status}, content-type ${JSON.stringify(liveRes.headers.get('content-type'))}, content-encoding ${JSON.stringify(liveRes.headers.get('content-encoding'))}, body ${liveText === null ? 'timed out' : `${liveText.length} chars`}` + : 'could not find greet in the server bundle', + ); + // The data address answers a string result as a plain body — a shape + // the content type alone would not exempt; the mount rule does. + const dataRes = messageId + ? await fetch( + `${origin}/_server/data/${encodeURIComponent(messageId)}${bigArgs}`, + compressible, + ) + : null; + const dataText = dataRes ? await dataRes.text() : ''; + record( + mode, + 'sf', + 'server-function endpoint answers pass preview compression as identity', + !!dataRes && dataRes.status === 200 && notCompressed(dataRes) && dataText.includes(bigName), + dataRes + ? `status ${dataRes.status}, content-type ${JSON.stringify(dataRes.headers.get('content-type'))}, content-encoding ${JSON.stringify(dataRes.headers.get('content-encoding'))}` + : 'could not find getServerMessage in the server bundle', + ); + // Static assets keep compressing — the exemption is scoped to the + // streamed shapes, not a preview-wide switch. + if (entryMatch) { + const compressedAsset = await fetch(origin + entryMatch[1], { + headers: { 'accept-encoding': 'gzip, br' }, + }); + await compressedAsset.arrayBuffer(); + record( + mode, + 'static', + 'hashed client asset still compressed under preview', + /\b(gzip|br)\b/.test(compressedAsset.headers.get('content-encoding') || ''), + `content-encoding ${JSON.stringify(compressedAsset.headers.get('content-encoding'))}`, + ); + } + // Middleware fronts preview exactly like dev and prod. record( mode, @@ -4270,7 +4417,7 @@ async function runObserveMode() { record( mode, 'client', - 'client components compile with their source labels (componentNames)', + 'client components compile with their source labels (sourceNames)', clientLabels.has('HmrTarget') && clientLabels.size >= 5, `labels: ${[...clientLabels].join(', ') || 'none'}`, ); @@ -4303,7 +4450,7 @@ async function runObserveMode() { ssrLabelsExpected ? serverLabels.has('HmrTarget') : true, ssrLabelsExpected ? `labels: ${[...serverLabels].join(', ') || 'none'}` - : `not asserted: @solidjs/compiler ${compiler.version} predates SSR componentNames`, + : `not asserted: @solidjs/compiler ${compiler.version} predates SSR sourceNames`, ); const webObserveExpected = web.rc >= 9; record( diff --git a/examples/start-ssr/vite.config.ts b/examples/start-ssr/vite.config.ts index 3758483..e9928fe 100644 --- a/examples/start-ssr/vite.config.ts +++ b/examples/start-ssr/vite.config.ts @@ -32,7 +32,7 @@ import solidPlugin from '@solidjs/vite-plugin'; // 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 +// condition everywhere and the compiler's `sourceNames` 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 / diff --git a/src/index.ts b/src/index.ts index e0850bd..6ed68d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,11 @@ import { import { boundaryModules } from './boundary-modules.js'; import { solidDiagnostics } from './diagnostics/index.js'; -import { serverFunctions, type ServerFunctionsOptions } from './server-functions/index.js'; +import { + normalizeServerFunctionsEndpoint, + serverFunctions, + type ServerFunctionsOptions, +} from './server-functions/index.js'; import { SSR_HANDLER_ID, startServe, type StartOptions } from './ssr/index.js'; import { startEnv } from './start-env.js'; import { @@ -258,9 +262,10 @@ export interface Options { */ exclude?: FilterPattern; /** - * This will inject solid-js/dev in place of solid-js in dev mode. Has no - * effect in prod. If set to `false`, it won't inject it in dev. This is - * useful for extra logs and debugging. + * Resolve Solid's development builds under `vite dev` — the `development` + * export condition of `solid-js` and `@solidjs/web`, which carry the extra + * checks, warnings and diagnostics. Has no effect on `vite build`. Set to + * `false` to serve the production builds in dev instead. * * @default true */ @@ -1374,6 +1379,19 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { ...(command === 'serve' && serverComponents ? ['@solidjs/web/frames', '@solidjs/web/server-functions'] : []), + // The attribution engine and the Chrome performance-tracks + // recorder are subpaths the scanner only sees when the app's own + // graph imports them; a consumer it never crawls (a linked + // package, a `solid`-condition package vitefu excluded from the + // scan) importing one mid-session would discover it late → + // re-optimize → a second `@solidjs/signals` core beside the one + // `solid-js` was bundled with. Pre-bundle both in the first pass + // so they share that core. Both subpaths exist for every version + // in the peer range (^2.0.0-rc.10: `solid-js/attribution` since + // rc.8, `@solidjs/web/performance-tracks` since rc.10). + ...(command === 'serve' + ? ['solid-js/attribution', '@solidjs/web/performance-tracks'] + : []), ...solidPkgsConfig.optimizeDeps.include, ], exclude: solidPkgsConfig.optimizeDeps.exclude, @@ -2117,6 +2135,16 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { ...startEnv(startOptions.env), ...startServe(startOptions, { serverFunctions: !!options.serverFunctions, + // The mount the runtime is configured with (before `base`; the + // start plugin applies it): the preview middleware passes every + // response under it through uncompressed. + ...(options.serverFunctions + ? { + serverFunctionsEndpoint: normalizeServerFunctionsEndpoint( + options.serverFunctions === true ? undefined : options.serverFunctions.endpoint, + ), + } + : {}), serverComponents, ssr: !!options.ssr, styleFilter: filterDevStyles, diff --git a/src/server-functions/index.ts b/src/server-functions/index.ts index 28aae5d..5bee2d3 100644 --- a/src/server-functions/index.ts +++ b/src/server-functions/index.ts @@ -171,6 +171,18 @@ const DEFAULT_RUNTIME = '@solidjs/web/server-functions'; // equals it, no configure calls need to be emitted at all. const DEFAULT_ENDPOINT = '/_server'; const STORAGE_SOURCE = '@solidjs/web/storage'; + +/** + * The endpoint mount as the runtime is configured with it — the option (or + * the runtime's default) with its leading slash guaranteed, before Vite's + * `base` is applied. Shared with the start-mode plugin, whose preview + * middleware classifies responses by this mount. + * @internal + */ +export function normalizeServerFunctionsEndpoint(endpoint?: string): string { + const option = endpoint || DEFAULT_ENDPOINT; + return option.startsWith('/') ? option : '/' + option; +} // Server-only handler: importing it wires the endpoint in one line // (registrations via the manifest, request-event scoping, endpoint config). const HANDLER_ID = 'virtual:solid-server-function-handler'; @@ -367,8 +379,7 @@ export function serverFunctions( const manifestId = options.manifest || DEFAULT_MANIFEST; const directive = options.directive || DEFAULT_DIRECTIVE; const runtime = options.runtime || { server: DEFAULT_RUNTIME, client: DEFAULT_RUNTIME }; - const endpointOption = options.endpoint || DEFAULT_ENDPOINT; - const endpoint = endpointOption.startsWith('/') ? endpointOption : '/' + endpointOption; + const endpoint = normalizeServerFunctionsEndpoint(options.endpoint); const components = !!options.components; // The middleware only exists on the main plugin's path to begin with (see the // `internal` parameter doc); the public option opts out of it there. @@ -597,11 +608,13 @@ export function serverFunctions( if (internal.externalDevServer || !isRunnableEnvironment(ssrEnvironment)) { return; } - // A call's address is `/` — plain HTTP — or - // `/data/` — the scripted transport's own path - // (solidjs/solid#3076, #3094). Bare-mount requests still reach the - // runtime handler (it answers 404), so misdirected posts fail - // through the endpoint rather than falling through to SSR. + // A call's address takes one of three shapes: `/` + // (plain HTTP), `/data/` (the scripted transport's own + // path, solidjs/solid#3076, #3094) or `/live/` (a + // `live` loop's connection, answered as an event stream; @solidjs/web + // 2.0.0-rc.10). Bare-mount requests still reach the runtime handler + // (it answers 404), so misdirected posts fail through the endpoint + // rather than falling through to SSR. const underMount = (pathname: string, mount: string) => pathname === mount || pathname.startsWith(mount + '/'); server.middlewares.use((req, res, next) => { @@ -623,13 +636,19 @@ export function serverFunctions( // code references are never loaded by the SSR render itself. // The id lives in the path segment after the mount — behind a // literal `data` segment on the scripted transport's address - // (solidjs/solid#3094). Segment count keeps the two apart: an id - // occupies exactly one segment, so `data/` is only ever a - // data address, and a function id spelled `data` still parses at - // the bare one. + // (solidjs/solid#3094) or a literal `live` segment on a live + // loop's. Mirrors the runtime's `parseServerFunctionAddress`: + // one kind segment at most is stripped, and segment count keeps + // the shapes apart — an id occupies exactly one segment, so + // `data/` / `live/` are only ever kind-prefixed + // addresses, a function id spelled `data` or `live` still parses + // at the bare one, and `data/live/` is a miss (the runtime + // 404s it too). const mount = basePrefixed ? resolvedEndpoint : endpoint; let segment = url.pathname.slice(mount.length + 1); - if (segment.startsWith('data/')) segment = segment.slice(5); + if (segment.startsWith('data/') || segment.startsWith('live/')) { + segment = segment.slice(5); + } let functionId: string | null = null; if (segment && !segment.includes('/')) { try { diff --git a/src/ssr/index.ts b/src/ssr/index.ts index f443e86..e1987be 100644 --- a/src/ssr/index.ts +++ b/src/ssr/index.ts @@ -622,6 +622,12 @@ export function startServe( options: StartOptions, internal: { serverFunctions?: boolean; + /** + * The server-function endpoint as the runtime is configured with it + * (leading slash, no `base`); present when `serverFunctions` is on. + * The preview middleware exempts everything under it from compression. + */ + serverFunctionsEndpoint?: string; serverComponents?: boolean; ssr?: boolean; styleFilter?: DevStyleFilter; @@ -1314,10 +1320,11 @@ export function startServe( lines.push(``, `async function dispatchRequest(request, event, options) {`); if (composeServerFunctions) { lines.push( - // A call's address is `/` or `/data/` - // (solidjs/solid#3076, #3094); the prefix gate covers both, and the - // bare mount still routes so a misaddressed request 404s through the - // runtime handler instead of rendering a page at it. + // A call's address is `/`, `/data/` or + // `/live/` (solidjs/solid#3076, #3094; the live shape + // since @solidjs/web 2.0.0-rc.10); the prefix gate covers all three, + // and the bare mount still routes so a misaddressed request 404s + // through the runtime handler instead of rendering a page at it. ` const requestPath = new URL(request.url).pathname;`, ` if (requestPath === endpoint || requestPath.startsWith(endpoint + '/')) {`, // The call shares the middleware chain's event (locals decoration, @@ -1812,6 +1819,14 @@ export function startServe( // only the server-function endpoint needs the handler, and without // server functions there is no dist/server at all. if (externalServer || (clientMode && !internal.serverFunctions)) return; + // The server-function mount as the built handler sees it (base + // applied, like every pathname it compares against). + const serverFunctionsMount = internal.serverFunctionsEndpoint + ? joinBase(base, internal.serverFunctionsEndpoint) + : null; + const underServerFunctionsMount = (pathname: string) => + serverFunctionsMount !== null && + (pathname === serverFunctionsMount || pathname.startsWith(serverFunctionsMount + '/')); return () => { let handlerPromise: Promise<{ handleRequest: ( @@ -1831,16 +1846,41 @@ export function startServe( // server-function endpoint) and hands the URL to application // code, so restore the base — the deployed production handler // receives base-prefixed URLs and preview must match it. + const requestUrl = joinBase(base, req.url || '/'); const response = await handler.handleRequest( - webRequestFromNode(req, joinBase(base, req.url || '/'), res), + webRequestFromNode(req, requestUrl, res), // Same event extension the dev middleware and a production // Node entry pass: the raw Node request as `nativeEvent`. { event: { nativeEvent: req } }, ); - // Preview's compression middleware buffers whole responses; - // opting HTML out keeps SSR streaming observable, matching - // production behavior. - if ((response.headers.get('content-type') || '').includes('text/html')) { + // Preview's compression middleware (@polka/compression) sizes + // a response up on its first write and, past the threshold, + // pipes the rest through a zlib stream that holds small writes + // until its buffer fills — a streamed response arrives in + // bursts, or not at all while a connection stays open. The + // deployed handler streams as it renders, and preview exists + // to show that artifact, so the streamed shapes opt out + // (`content-encoding: identity` is the middleware's own + // pass-through signal): + // - `text/html`: page renders stream the shell ahead of async + // content; + // - `text/event-stream`: server-sent events wherever the app + // emits them (a `live` server function, a middleware route) + // — an SSE connection stays open, so a held event is an + // event never delivered; + // - everything under the server-function mount: the runtime + // answers there in its own wire — the streaming codec + // (`text/plain`, too generic to key on), the live event + // stream, JSON — and every shape is either streamed or too + // small for compression to buy anything in preview. Keying + // on the mount also keeps a new answer shape from silently + // regressing to buffered. + const contentType = response.headers.get('content-type') || ''; + if ( + contentType.includes('text/html') || + contentType.includes('text/event-stream') || + underServerFunctionsMount(new URL(requestUrl, 'http://localhost').pathname) + ) { res.setHeader('content-encoding', 'identity'); } await sendWebResponse(res, response);