diff --git a/.changeset/turnkey-external-server-inject.md b/.changeset/turnkey-external-server-inject.md
new file mode 100644
index 0000000..700d8c5
--- /dev/null
+++ b/.changeset/turnkey-external-server-inject.md
@@ -0,0 +1,21 @@
+---
+'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.
+- Dev entry CSS survives that handover: the styles are collected in the Vite
+ process, where the module graph lives, and spliced into the streamed HTML
+ before `` as it passes back out. They were previously inlined by the
+ dev HTML middleware, which no longer runs once something else serves, so dev
+ SSR streamed unstyled markup and flashed on load. Collection is per-request,
+ so HMR-edited CSS stays current.
diff --git a/src/ssr/index.ts b/src/ssr/index.ts
index 41831e2..8aca686 100644
--- a/src/ssr/index.ts
+++ b/src/ssr/index.ts
@@ -25,6 +25,7 @@
// `virtual:solid-server-function-handler` before SSR (in dev, the
// server-function middleware is registered earlier and already wins).
import { existsSync } from 'fs';
+import type { ServerResponse } from 'node:http';
import path from 'path';
import type { Plugin, ViteDevServer } from 'vite';
import { collectDevStyles, devStylePatch, renderDevStyleTag } from '../dev-manifest.js';
@@ -67,6 +68,57 @@ 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';
+
+/**
+ * Splices `html` in front of `` in a streamed HTML response, once.
+ *
+ * Used when an external server renders the page: the markup is produced
+ * outside this process, so the only place left to add dev-only `
`
+ * content is the response as it passes back through Vite. Non-HTML responses
+ * and bodies without a `` pass through untouched, and `content-length`
+ * is dropped when injecting since the body grows.
+ */
+function injectIntoHtmlHead(res: ServerResponse, html: string): void {
+ const write = res.write.bind(res);
+ const end = res.end.bind(res);
+ const writeHead = res.writeHead.bind(res);
+ let injected = false;
+
+ const isHtml = () => String(res.getHeader('content-type') || '').includes('text/html');
+ const transform = (chunk: unknown, encoding?: unknown): unknown => {
+ if (injected || chunk == null || typeof chunk === 'function' || !isHtml()) return chunk;
+ // Web-stream sources (an external server piping a Response body) write
+ // Uint8Array chunks, which are not Buffers.
+ const text =
+ typeof chunk === 'string'
+ ? chunk
+ : Buffer.isBuffer(chunk)
+ ? chunk.toString(typeof encoding === 'string' ? (encoding as BufferEncoding) : 'utf8')
+ : ArrayBuffer.isView(chunk)
+ ? Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).toString('utf8')
+ : null;
+ if (text === null) return chunk;
+ const at = text.indexOf('');
+ if (at === -1) return chunk;
+ injected = true;
+ return Buffer.from(text.slice(0, at) + html + text.slice(at), 'utf8');
+ };
+
+ res.writeHead = function (this: ServerResponse, ...args: never[]) {
+ // The injected bytes invalidate a precomputed length; streamed SSR
+ // responses are chunked and unaffected.
+ if (isHtml() && !injected) res.removeHeader('content-length');
+ return writeHead(...(args as Parameters));
+ } as typeof res.writeHead;
+
+ res.write = function (this: ServerResponse, chunk: never, ...rest: never[]) {
+ return write(transform(chunk, rest[0]) as never, ...(rest as []));
+ } as typeof res.write;
+
+ res.end = function (this: ServerResponse, chunk?: never, ...rest: never[]) {
+ return end(transform(chunk, rest[0]) as never, ...(rest as []));
+ } as typeof res.end;
+}
// 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 +230,7 @@ export function ssrServe(
let root = process.cwd();
let base = '/';
let isBuild = false;
+ let externalServer = false;
let entries: ResolvedEntries | undefined;
function requireEntries(): ResolvedEntries {
@@ -453,33 +506,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 +597,36 @@ export function ssrServe(
? [app!, ...(document ? [document] : [])]
: [path.resolve(root, entryServer)];
};
+ // An external server owns HTML, so turnkey's own serving middleware
+ // (below) never gets the request — and with it goes the entry CSS
+ // that middleware would have inlined, leaving dev SSR to flash
+ // unstyled. Collect the styles here instead, in the Vite process
+ // where the module graph lives, and splice them into the response on
+ // its way back out. A *pre* middleware, because an external server
+ // typically answers before Vite's own middlewares run; and on the
+ // response rather than through the handler, so this works whether or
+ // not the integration renders via `handleRequest`.
+ if (externalServer) {
+ server.middlewares.use((req, res, next) => {
+ if (req.method !== 'GET' || !(req.headers.accept || '').includes('text/html')) {
+ return next();
+ }
+ collectDevStyles(server, styleRoots()).then(
+ (styles) => {
+ const tags = styles.map(renderDevStyleTag).join('');
+ if (tags) injectIntoHtmlHead(res, tags);
+ next();
+ },
+ () => next(),
+ );
+ });
+ }
// 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 above) serves HTML itself.
+ if (externalServer) return;
server.middlewares.use((req, res, next) => {
if (req.method !== 'GET') return next();
const accept = req.headers.accept || '';