Skip to content
Closed
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
21 changes: 21 additions & 0 deletions .changeset/turnkey-external-server-inject.md
Original file line number Diff line number Diff line change
@@ -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 `</head>` 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.
133 changes: 115 additions & 18 deletions src/ssr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 `</head>` 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 `<head>`
* content is the response as it passes back through Vite. Non-HTML responses
* and bodies without a `</head>` 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('</head>');
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<typeof writeHead>));
} 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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 },
}),
Expand Down Expand Up @@ -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 || '';
Expand Down
Loading