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 || '';