From 4d265e9d3f22ec3a6eaf105ad589586a39e40348 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Fri, 24 Jul 2026 21:31:19 -0500 Subject: [PATCH] feat(ssr): defer serving and build wiring to external servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turnkey SSR assumes it owns the server. When a server integration (nitro, a Cloudflare worker, a custom harness) drives the build and serves requests, two assumptions break. The ssr build input was overwritten with virtual:solid-ssr-handler unconditionally, leaving the integration with no server entry — under nitro no renderer route gets registered and every page 404s in preview. A configured environments.ssr.build.rollupOptions.input now signals external ownership: entries, handler and client manifest config are still contributed, but the ssr input, dist/* outDirs, builder flag and dev HTML middleware are left alone. Entry CSS is collected in that dev HTML middleware, so it never runs once something else serves, and the handler usually runs in another runtime (a dev worker, workerd) that can reach neither Vite's module graph nor the globalThis resolver registry — dev SSR streamed unstyled markup and flashed on load. The stylesheets are now also exposed on a dev-only endpoint that the generated handler fetches when the caller supplies no devHead, per request so HMR-edited CSS stays current. Co-Authored-By: Claude Fable 5 --- .changeset/turnkey-external-server.md | 23 +++++++ src/ssr/index.ts | 99 +++++++++++++++++++++------ 2 files changed, 102 insertions(+), 20 deletions(-) create mode 100644 .changeset/turnkey-external-server.md diff --git a/.changeset/turnkey-external-server.md b/.changeset/turnkey-external-server.md new file mode 100644 index 0000000..e90e863 --- /dev/null +++ b/.changeset/turnkey-external-server.md @@ -0,0 +1,23 @@ +--- +'vite-plugin-solid': patch +--- + +Turnkey SSR: let an external server own serving and the build. + +- Configuring `environments.ssr.build.rollupOptions.input` now signals that a + server integration (nitro, a Cloudflare worker, a custom harness) owns the + server. Turnkey still resolves/generates the entries, emits + `virtual:solid-ssr-handler` and configures the client manifest, but stops + overriding the ssr build input, the `dist/*` outDirs and the `builder` flag, + and stops registering its dev HTML middleware. Previously the ssr input was + overwritten with the handler unconditionally, so the integration was left + with no server entry of its own — with nitro that means no renderer route is + registered and every page 404s in preview. +- The entry graph's dev stylesheets are now also exposed on a dev-only + endpoint, and the generated handler fetches it when the caller passes no + `devHead`. Entry CSS is collected in the dev HTML middleware, which never + runs when something else serves, and the handler typically runs in another + runtime (a dev worker, workerd) that can reach neither Vite's module graph + nor the `globalThis` resolver registry — so dev SSR streamed unstyled markup + and flashed on load. Collection stays per-request, so HMR-edited CSS is + current. diff --git a/src/ssr/index.ts b/src/ssr/index.ts index 41831e2..b18a088 100644 --- a/src/ssr/index.ts +++ b/src/ssr/index.ts @@ -67,6 +67,9 @@ export interface SsrOptions { // Server-only turnkey request handler; also the server bundle's entry so a // production server is one import away from `Request -> Response`. const HANDLER_ID = 'virtual:solid-ssr-handler'; +// Dev-only endpoint serving the entry graph's compiled stylesheets, so a +// handler running outside this process can still inline them (see below). +const DEV_ENTRY_CSS_PATH = '/__solid_dev_entry_css'; // Generated default entries / document shell. The `.tsx` suffix routes them // through the plugin's normal JSX transform (per-environment SSR/DOM // compile), exactly like user-authored entry files. @@ -178,6 +181,7 @@ export function ssrServe( let root = process.cwd(); let base = '/'; let isBuild = false; + let externalServer = false; let entries: ResolvedEntries | undefined; function requireEntries(): ResolvedEntries { @@ -344,7 +348,23 @@ export function ssrServe( const devHead = `` + ``; - lines.push(``, `const DEV_HEAD = ${JSON.stringify(devHead)};`); + lines.push( + ``, + `const DEV_HEAD = ${JSON.stringify(devHead)};`, + ``, + // Fallback for callers that cannot collect the styles themselves: + // an external server's handler runs in its own runtime (a dev + // worker, workerd) with no access to this module graph, so it asks + // the dev server over HTTP. Per request, so HMR edits stay current. + `async function fetchDevEntryStyles(request) {`, + ` try {`, + ` const response = await fetch(new URL(${JSON.stringify(DEV_ENTRY_CSS_PATH)}, request.url));`, + ` return response.ok ? await response.text() : '';`, + ` } catch {`, + ` return '';`, + ` }`, + `}`, + ); } lines.push( @@ -421,6 +441,9 @@ export function ssrServe( isBuild ? ` const clientEntry = options.clientEntry || resolveClientEntry();` : ` const clientEntry = options.clientEntry || ${JSON.stringify(devClientEntryUrl())};`, + isBuild + ? ` const devHead = options.devHead;` + : ` const devHead = options.devHead !== undefined ? options.devHead : await fetchDevEntryStyles(request);`, ` return provideRequestEvent({ request, locals: {} }, async () => {`, ` let result = entry.render(request, { clientEntry, ...options.context });`, // renderToStream results are thenables whose then() waits for the @@ -430,7 +453,7 @@ export function ssrServe( ` result = await result;`, ` }`, ` if (result instanceof Response) return result;`, - ` return htmlResponse(result, clientEntry, options.devHead, options.responseInit);`, + ` return htmlResponse(result, clientEntry, devHead, options.responseInit);`, ` });`, `}`, ); @@ -453,33 +476,51 @@ export function ssrServe( const scanEntries = entries.generated ? [entries.app!, ...(entries.document ? [entries.document] : [])] : [path.resolve(root, entries.entryClient)]; + // A configured ssr input means an external server integration owns + // the server: it drives the build and serves requests, adapting + // `virtual:solid-ssr-handler` to its own entry contract. Turnkey then + // contributes only the entries, the handler and the client manifest — + // overriding the ssr input here would leave that integration without a + // server entry, and its own build/serving pipeline in conflict. + externalServer = userConfig.environments?.ssr?.build?.rollupOptions?.input != null; return { // No index.html: dev must not fall back to SPA-serving one, and // the dep scanner needs explicit entries instead. appType: 'custom', ...(build - ? { - environments: { - client: { - build: { - manifest: true, - outDir: 'dist/client', - rollupOptions: { input: clientInput }, + ? externalServer + ? { + environments: { + client: { + build: { + manifest: true, + rollupOptions: { input: clientInput }, + }, }, }, - ssr: { - build: { - outDir: 'dist/server', - rollupOptions: { input: { server: HANDLER_ID } }, + } + : { + environments: { + client: { + build: { + manifest: true, + outDir: 'dist/client', + rollupOptions: { input: clientInput }, + }, + }, + ssr: { + build: { + outDir: 'dist/server', + rollupOptions: { input: { server: HANDLER_ID } }, + }, }, }, - }, - // Presence of `builder` makes a plain `vite build` build the - // whole app (all environments: client then ssr) on Vite 6+. - // A classic `vite build --ssr` invocation must stay a - // single-environment build, so it doesn't get the flag. - ...(env.isSsrBuild ? {} : { builder: {} }), - } + // Presence of `builder` makes a plain `vite build` build the + // whole app (all environments: client then ssr) on Vite 6+. + // A classic `vite build --ssr` invocation must stay a + // single-environment build, so it doesn't get the flag. + ...(env.isSsrBuild ? {} : { builder: {} }), + } : { optimizeDeps: { entries: scanEntries }, }), @@ -526,10 +567,28 @@ export function ssrServe( ? [app!, ...(document ? [document] : [])] : [path.resolve(root, entryServer)]; }; + // Exposed for handlers that run outside this process (see + // fetchDevEntryStyles in the generated handler); registered even when + // an external server owns HTML, which is exactly when it is needed. + server.middlewares.use(DEV_ENTRY_CSS_PATH, (_req, res) => { + collectDevStyles(server, styleRoots()).then( + (styles) => { + res.setHeader('content-type', 'text/html; charset=utf-8'); + res.setHeader('cache-control', 'no-store'); + res.end(styles.map(renderDevStyleTag).join('')); + }, + () => { + res.statusCode = 500; + res.end(''); + }, + ); + }); // Post middleware: Vite's own middlewares (transforms, static, the // server-function endpoint) run first; whatever asks for HTML after // that gets the streamed SSR render. return () => { + // An external server (see `externalServer` above) serves HTML itself. + if (externalServer) return; server.middlewares.use((req, res, next) => { if (req.method !== 'GET') return next(); const accept = req.headers.accept || '';