diff --git a/.changeset/external-dev-styles.md b/.changeset/external-dev-styles.md new file mode 100644 index 0000000..68f6ca3 --- /dev/null +++ b/.changeset/external-dev-styles.md @@ -0,0 +1,9 @@ +--- +'vite-plugin-solid': patch +--- + +Let provider-owned SSR environments serve turnkey development requests without +calling `ssrLoadModule`. Entry CSS and server functions are transported through +the generated handler so isolated runtimes retain SSR styles and HMR support. + +Add `ssr.external` for integrations that also own the server build wiring. diff --git a/examples/turnkey/test/run.mjs b/examples/turnkey/test/run.mjs index 099edce..49a2551 100644 --- a/examples/turnkey/test/run.mjs +++ b/examples/turnkey/test/run.mjs @@ -62,6 +62,7 @@ import { spawn, execSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { rmSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createServer } from 'vite'; const exampleDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; @@ -1321,7 +1322,67 @@ async function runBabelHmrMode() { } } -const ALL_MODES = ['dev', 'prod', 'document', 'entries', 'endpoint', 'frames', 'babel-hmr']; +async function runExternalMode() { + const mode = 'external'; + console.log(`\n=== ${mode.toUpperCase()} ===`); + process.env.SOLID_EXTERNAL = '1'; + let server; + + try { + server = await createServer({ root: exampleDir, server: { middlewareMode: true } }); + const clientModule = await server.environments.client.transformRequest('/src/api.ts'); + const functionId = extractFunctionId(clientModule?.code || '', 'getServerMessage'); + const handler = await server.ssrLoadModule('virtual:solid-ssr-handler'); + const response = await handler.handleRequest(new Request('http://localhost/')); + const html = await response.text(); + record( + mode, + 'ssr', + 'external handler renders the app', + response.status === 200 && html.includes('Turnkey SSR'), + ); + record( + mode, + 'css', + 'virtual styles module inlines entry CSS', + html.includes('data-vite-dev-id') && html.includes('rgb(20, 40, 60)'), + ); + + const functionResponse = functionId + ? await handler.handleRequest( + new Request( + `http://localhost/_server?id=${encodeURIComponent(functionId)}&args=${encodeURIComponent('["external"]')}`, + { method: 'POST' }, + ), + ) + : null; + record( + mode, + 'sf', + 'external handler composes server functions in dev', + functionResponse + ? (await functionResponse.text()) === 'hello external from the server' + : false, + functionId ? undefined : 'could not extract function id', + ); + } catch (error) { + record(mode, 'run', 'mode completed', false, String(error)); + } finally { + await server?.close(); + delete process.env.SOLID_EXTERNAL; + } +} + +const ALL_MODES = [ + 'dev', + 'prod', + 'document', + 'entries', + 'endpoint', + 'frames', + 'babel-hmr', + 'external', +]; const arg = process.argv[2]; const modes = ALL_MODES.includes(arg) ? [arg] : ALL_MODES; for (const mode of modes) { @@ -1331,7 +1392,8 @@ for (const mode of modes) { else if (mode === 'entries') await runEntriesMode(); else if (mode === 'endpoint') await runEndpointMode(); else if (mode === 'frames') await runFramesMode(); - else await runBabelHmrMode(); + else if (mode === 'babel-hmr') await runBabelHmrMode(); + else await runExternalMode(); } const failed = results.filter((r) => !r.ok); diff --git a/examples/turnkey/vite.config.ts b/examples/turnkey/vite.config.ts index 17688e1..403d676 100644 --- a/examples/turnkey/vite.config.ts +++ b/examples/turnkey/vite.config.ts @@ -35,7 +35,7 @@ export default defineConfig({ ? { app: 'src/frames/FramesApp.tsx' } : process.env.SSR_DOCUMENT ? { document: process.env.SSR_DOCUMENT } - : {}, + : { external: !!process.env.SOLID_EXTERNAL }, serverFunctions: serverComponents ? { components: true } : process.env.SERVER_FN_ENDPOINT diff --git a/src/dev-manifest.ts b/src/dev-manifest.ts index e57bf5b..1705022 100644 --- a/src/dev-manifest.ts +++ b/src/dev-manifest.ts @@ -18,6 +18,7 @@ import type { DevEnvironment, EnvironmentModuleNode, ViteDevServer } from 'vite' */ export type DevStyleDescriptor = { id: string; content: string; attrs?: Record }; +export type DevStyleSource = { id: string; url: string }; export type ResolvedAssets = { js: string[]; @@ -98,12 +99,14 @@ export const devStylePatch = `(function(){var P=${JSON.stringify( async function getModuleNode( env: DevEnvironment, file: string, - importer?: string, ): Promise { try { - const resolved = await env.fetchModule(file, importer); - if (!('id' in resolved)) return; - return env.moduleGraph.getModuleById(resolved.id); + let node = env.moduleGraph.getModuleById(file) ?? (await env.moduleGraph.getModuleByUrl(file)); + if (!node?.transformResult) { + await env.transformRequest(node?.url ?? file); + node = env.moduleGraph.getModuleById(file) ?? (await env.moduleGraph.getModuleByUrl(file)); + } + return node; } catch { return; } @@ -114,12 +117,13 @@ async function collectModuleDeps( file: string, deps: Set, crawled: Set, - importer?: string, + onFile?: (file: string) => void, ): Promise { crawled.add(file); - const node = await getModuleNode(env, file, importer); + const node = await getModuleNode(env, file); if (!node?.id || deps.has(node)) return; deps.add(node); + if (node.file && !node.id.includes('node_modules')) onFile?.(node.file); if (cssFileRegExp.test(node.url.split('?')[0]) || node.id.includes('node_modules')) return; @@ -133,7 +137,7 @@ async function collectModuleDeps( // from dynamicDeps — dynamic imports load their own styles when rendered. for (const dep of directDeps) { if (crawled.has(dep)) continue; - await collectModuleDeps(env, dep, deps, crawled, node.id); + await collectModuleDeps(env, dep, deps, crawled, onFile); } } @@ -141,6 +145,32 @@ function injectQuery(url: string, query: string): string { return url.includes('?') ? `${url}&${query}` : `${url}?${query}`; } +/** Discovers ambient CSS in an entry graph without choosing how it is transported. */ +export async function collectDevStyleSources( + env: DevEnvironment, + files: string[], + onFile?: (file: string) => void, +): Promise { + const deps = new Set(); + const crawled = new Set(); + for (const file of files) { + await collectModuleDeps(env, file, deps, crawled, onFile); + } + + const css: DevStyleSource[] = []; + const seen = new Set(); + for (const node of deps) { + if (!node.id) continue; + const cleanUrl = node.url.split('?')[0]; + if (!cssFileRegExp.test(cleanUrl) || nonAmbientQueryRegExp.test(node.url)) continue; + const id = wrapId(node.id); + if (seen.has(id)) continue; + seen.add(id); + css.push({ id, url: node.url }); + } + return css; +} + /** * Walks the SSR module graph from `files` (root-relative or absolute) and * returns inline-style descriptors for every transitively imported CSS @@ -157,32 +187,24 @@ export async function collectDevStyles( const clientEnv = server.environments?.client; if (!ssrEnv || !clientEnv) return []; - const deps = new Set(); - const crawled = new Set(); - for (const file of files) { - await collectModuleDeps(ssrEnv, path.resolve(server.config.root, file), deps, crawled); - } + const sources = await collectDevStyleSources( + ssrEnv, + files.map((file) => path.resolve(server.config.root, file)), + ); const css: DevStyleDescriptor[] = []; - const seen = new Set(); - for (const node of deps) { - if (!node.id) continue; - const cleanUrl = node.url.split('?')[0]; - if (!cssFileRegExp.test(cleanUrl) || nonAmbientQueryRegExp.test(node.url)) continue; + for (const source of sources) { // `?direct` yields the compiled stylesheet text (what Vite serves for // requests) — through the client environment, whose css // pipeline matches what the browser will run for HMR updates. const result = await clientEnv - .transformRequest(injectQuery(node.url, 'direct')) + .transformRequest(injectQuery(source.url, 'direct')) .catch(() => null); if (result?.code == null) continue; - const id = wrapId(node.id); - if (seen.has(id)) continue; - seen.add(id); css.push({ - id, + id: source.id, content: result.code, - attrs: { 'data-vite-dev-id': id }, + attrs: { 'data-vite-dev-id': source.id }, }); } return css; diff --git a/src/index.ts b/src/index.ts index c91bb15..a6c95cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,7 @@ export type { ServerFunctionsFilter } from './server-functions/index.js'; export type { SsrOptions }; import path from 'path'; import type { FilterPattern, Plugin } from 'vite'; -import { createFilter, version } from 'vite'; +import { createFilter, isRunnableDevEnvironment, version } from 'vite'; import { crawlFrameworkPkgs } from 'vitefu'; const require = createRequire(import.meta.url); @@ -379,6 +379,8 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { const filter = createFilter(options.include, options.exclude); const serverComponents = typeof options.serverFunctions === 'object' && !!options.serverFunctions.components; + const externalDevServer = + typeof options.ssr === 'object' && options.ssr !== null && !!options.ssr.external; let needHmr = false; let replaceDev = false; @@ -658,10 +660,10 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { hotUpdate() { // solid-refresh only injects HMR boundaries into client modules, so - // non-client environments have no accept handlers. Without this, Vite - // would see no boundaries and send full-reload messages that race with - // client-side HMR updates. - if (this.environment.name !== 'client') { + // the in-process SSR runner should not send full reloads that race with + // client HMR. Remote runners still need their update so provider-owned + // handlers and virtual dev styles are invalidated. + if (this.environment.name !== 'client' && isRunnableDevEnvironment(this.environment)) { return []; } }, @@ -940,6 +942,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { ? [ ...serverFunctions(options.serverFunctions === true ? {} : options.serverFunctions, { devMiddleware: true, + externalDevServer, }), mainPlugin, ] diff --git a/src/server-functions/index.ts b/src/server-functions/index.ts index c3dfc33..4de8e37 100644 --- a/src/server-functions/index.ts +++ b/src/server-functions/index.ts @@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import path from 'path'; import { createFilter, + isRunnableDevEnvironment, type EnvironmentModuleGraph, type FilterPattern, type Plugin, @@ -240,7 +241,7 @@ function invalidateModules( */ export function serverFunctions( options: ServerFunctionsOptions = {}, - internal: { devMiddleware?: boolean } = {}, + internal: { devMiddleware?: boolean; externalDevServer?: boolean } = {}, ): Plugin[] { const filter = createFilter( options.filter?.include || DEFAULT_INCLUDE, @@ -386,7 +387,10 @@ export function serverFunctions( }, load(id, opts) { if (id === HANDLER_ID && opts?.ssr) { - return handlerModuleCode(isBuild); + const externalDev = + this.environment.mode === 'dev' && + (internal.externalDevServer || !isRunnableDevEnvironment(this.environment)); + return handlerModuleCode(isBuild || externalDev); } return null; }, @@ -398,6 +402,13 @@ export function serverFunctions( name: 'solid:server-functions/dev-middleware', apply: 'serve', configureServer(server) { + const ssrEnvironment = server.environments.ssr; + if ( + internal.externalDevServer || + (ssrEnvironment && !isRunnableDevEnvironment(ssrEnvironment)) + ) { + return; + } server.middlewares.use((req, res, next) => { const url = new URL(req.url || '/', 'http://localhost'); // Match with and without `base` — middleware-mode hosts may mount diff --git a/src/ssr/index.ts b/src/ssr/index.ts index 41831e2..4e9c740 100644 --- a/src/ssr/index.ts +++ b/src/ssr/index.ts @@ -2,13 +2,10 @@ // of the existing `ssr` option) adds a serving layer on top of the SSR // transforms so no hand-rolled entries or dev server are needed. // -// - Dev: a middleware on the Vite dev server streams the rendered app for -// HTML-accepting GET requests, loading the app through the SSR environment -// (`ssrLoadModule`) and injecting the Vite client + dev style patch + the -// entry graph's CSS (inlined `';`, + `}).join('');`, + ].join('\n'); + } + function generatedEntryServerCode(): string { const { app } = requireEntries(); return [ @@ -289,11 +336,11 @@ export function ssrServe( // The handler module: dev and prod share the render/response plumbing; // they differ in how the client entry URL is known (baked dev URL vs a // manifest scan), what gets injected into (Vite client + style - // patch in dev), and server-function composition (build only — in dev the - // server-function middleware intercepts the endpoint before SSR runs). - function handlerModuleCode(): string { + // patch in dev), and server-function composition (production and external + // dev; runnable dev environments use the server-function middleware). + function handlerModuleCode(externalDev: boolean): string { const { generated, entryClient } = requireEntries(); - const composeServerFunctions = isBuild && internal.serverFunctions; + const composeServerFunctions = (isBuild || externalDev) && internal.serverFunctions; // Generated entries own the whole document wiring, so the handler also // injects the server-component bootstrap (the per-function-id // placeholder registry the render plugin references; must be inline in @@ -304,6 +351,12 @@ export function ssrServe( const lines = [ `import { provideRequestEvent } from ${JSON.stringify(STORAGE_SOURCE)};`, `import * as entry from ${JSON.stringify(entryServerSpec())};`, + ...(externalDev ? [`import DEV_STYLES_HEAD from ${JSON.stringify(DEV_STYLES_ID)};`] : []), + ...(composeServerFunctions + ? [ + `import { handleServerFunctionRequest, endpoint } from ${JSON.stringify(SERVER_FUNCTION_HANDLER_ID)};`, + ] + : []), ...(injectComponentBootstrap ? [`import { SERVER_COMPONENT_BOOTSTRAP } from '@solidjs/web/frames';`] : []), @@ -311,11 +364,6 @@ export function ssrServe( if (isBuild) { lines.push(`import manifest from ${JSON.stringify(MANIFEST_ID)};`); - if (composeServerFunctions) { - lines.push( - `import { handleServerFunctionRequest, endpoint } from ${JSON.stringify(SERVER_FUNCTION_HANDLER_ID)};`, - ); - } lines.push( ``, `function joinAssetPath(base, file) {`, @@ -365,9 +413,16 @@ export function ssrServe( } lines.push(` if (!injected && chunk.includes('')) {`, ` injected = true;`); const headParts: string[] = []; - // Dev: the style patch + Vite client, then the SSR'd entry styles the - // middleware collected (per request, so HMR-edited CSS is current). - if (!isBuild) headParts.push(`DEV_HEAD`, `(extraHead || '')`); + // Dev: the style patch + Vite client, then either middleware-provided + // styles or the external environment's HMR-tracked virtual styles module. + if (!isBuild) { + headParts.push( + `DEV_HEAD`, + externalDev + ? `(extraHead === undefined ? DEV_STYLES_HEAD : extraHead)` + : `(extraHead || '')`, + ); + } if (injectComponentBootstrap) { headParts.push(`'