Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/external-dev-styles.md
Original file line number Diff line number Diff line change
@@ -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.
66 changes: 64 additions & 2 deletions examples/turnkey/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion examples/turnkey/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 45 additions & 23 deletions src/dev-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { DevEnvironment, EnvironmentModuleNode, ViteDevServer } from 'vite'
*/

export type DevStyleDescriptor = { id: string; content: string; attrs?: Record<string, string> };
export type DevStyleSource = { id: string; url: string };

export type ResolvedAssets = {
js: string[];
Expand Down Expand Up @@ -98,12 +99,14 @@ export const devStylePatch = `(function(){var P=${JSON.stringify(
async function getModuleNode(
env: DevEnvironment,
file: string,
importer?: string,
): Promise<EnvironmentModuleNode | undefined> {
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;
}
Expand All @@ -114,12 +117,13 @@ async function collectModuleDeps(
file: string,
deps: Set<EnvironmentModuleNode>,
crawled: Set<string>,
importer?: string,
onFile?: (file: string) => void,
): Promise<void> {
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;

Expand All @@ -133,14 +137,40 @@ 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);
}
}

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<DevStyleSource[]> {
const deps = new Set<EnvironmentModuleNode>();
const crawled = new Set<string>();
for (const file of files) {
await collectModuleDeps(env, file, deps, crawled, onFile);
}

const css: DevStyleSource[] = [];
const seen = new Set<string>();
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
Expand All @@ -157,32 +187,24 @@ export async function collectDevStyles(
const clientEnv = server.environments?.client;
if (!ssrEnv || !clientEnv) return [];

const deps = new Set<EnvironmentModuleNode>();
const crawled = new Set<string>();
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<string>();
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
// <link> 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;
Expand Down
13 changes: 8 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -379,6 +379,8 @@ export default function solidPlugin(options: Partial<Options> = {}): 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;
Expand Down Expand Up @@ -658,10 +660,10 @@ export default function solidPlugin(options: Partial<Options> = {}): 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 [];
}
},
Expand Down Expand Up @@ -940,6 +942,7 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
? [
...serverFunctions(options.serverFunctions === true ? {} : options.serverFunctions, {
devMiddleware: true,
externalDevServer,
}),
mainPlugin,
]
Expand Down
15 changes: 13 additions & 2 deletions src/server-functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import path from 'path';
import {
createFilter,
isRunnableDevEnvironment,
type EnvironmentModuleGraph,
type FilterPattern,
type Plugin,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
},
Expand All @@ -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
Expand Down
Loading
Loading