From 24164684ce56a84e8c515d61f77f682ba6255979 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:37:07 +0000 Subject: [PATCH 1/4] feat: frontends option for languages that lower to Solid A front end claims a file extension and compiles its modules itself: compile(source, { id, ssr, root, solid, addWatchFile }) returns { code, map, css }, given the compiler options a JSX module would get in that environment. The module then takes the lowered .tsrx route: lazy and refresh passes, server functions keyed by the real module path, css as a CSS module, dep scanning. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWMdD4K7882vob4J9vq --- .changeset/frontends-option.md | 5 + README.md | 33 + src/frontends.test.ts | 130 ++ src/index.ts | 181 +- src/index.ts.orig | 3123 ++++++++++++++++++++++++++++++++ src/server-functions/index.ts | 22 +- src/tsrx.ts | 6 +- 7 files changed, 3478 insertions(+), 22 deletions(-) create mode 100644 .changeset/frontends-option.md create mode 100644 src/frontends.test.ts create mode 100644 src/index.ts.orig diff --git a/.changeset/frontends-option.md b/.changeset/frontends-option.md new file mode 100644 index 00000000..8938d1ae --- /dev/null +++ b/.changeset/frontends-option.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': minor +--- + +Add a `frontends` option: other languages that lower to Solid can claim a file extension and compile its modules themselves (`frontends: { ".rx": rx() }`). A front end's `compile(source, { id, ssr, root, solid, addWatchFile })` returns `{ code, map, css }`, given the compiler options this plugin would use for a JSX module in that environment; the result then takes the lowered `.tsrx` route (lazy and refresh passes, server functions keyed by the module's real path, `css` as a CSS module, dep scanning). Server functions in a front end's modules need `@solidjs/compiler` with secondary passes that accept any extension. diff --git a/README.md b/README.md index 66f402e0..447a5d4f 100644 --- a/README.md +++ b/README.md @@ -1122,6 +1122,39 @@ By default, the plugin transforms `jsx`, `tsx`, and experimental `tsrx` files. TSRX is always recognized and does not need to be added here. This is useful if you want to transform `mdx` files for example. +#### options.frontends + +- Type: `Record` +- Default: `{}` + +Front ends for languages that lower to Solid themselves, keyed by file +extension (`{ ".rx": rx() }`). Unlike `extensions`, whose files are parsed as +JSX text, a front end's module is handed to its `compile` function, which +returns the module's code, source map and stylesheet: + +```ts +interface Frontend { + compile( + source: string, + context: { + id: string; // the module's file path + ssr: boolean; // compiled for a server environment + root: string; // Vite's resolved root + solid: SolidOptions; // what this plugin would pass @solidjs/compiler for a JSX module here + addWatchFile(file: string): void; // recompile this module when `file` changes + }, + ): { code: string; map?: string | object | null; css?: string } | null; +} +``` + +A front end usually ends in `@solidjs/compiler` with `context.solid`, so its +output matches this environment's `generate`, `hydratable` and `moduleName`. +From there the module takes the route a lowered `.tsrx` module takes: the +lazy and HMR refresh passes, server functions (IDs from the module's own +path), and `css` as a CSS module the module imports. Solid's own extensions +(`.jsx`, `.tsx`, `.tsrx`, ...) can't be claimed. Return `null` to leave a +module to the rest of the pipeline. + ## `server-only` and `client-only` boundary markers The plugin always claims the bare specifiers `server-only` and `client-only` diff --git a/src/frontends.test.ts b/src/frontends.test.ts new file mode 100644 index 00000000..f700a1db --- /dev/null +++ b/src/frontends.test.ts @@ -0,0 +1,130 @@ +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { transformAsync, transformDirectives } from '@solidjs/compiler'; +import { createServer, type ViteDevServer } from 'vite'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import solidPlugin, { type Frontend, type FrontendContext } from './index.js'; + +let root: string; + +function directivesTakeAnyExtension() { + try { + transformDirectives('export async function f() { "use server"; }', { + filename: '/p/a.fe', + root: '/p', + mode: 'server', + }); + return true; + } catch { + return false; + } +} +let server: ViteDevServer; +const calls: FrontendContext[] = []; + +// A front end whose language is JSX under another extension: it lowers +// through Solid's compiler with the options the plugin hands it, and returns +// a stylesheet — what a real one (ReactiveScript's `.rx`) returns. +const fe: Frontend = { + async compile(source, context) { + calls.push(context); + context.addWatchFile(path.join(root, 'src/shared.d.fe')); + const result = await transformAsync(source, { + ...context.solid, + filename: context.id + '.jsx', + sourceMap: true, + }); + // the map names the module, as a front end's own map would + const map = { ...JSON.parse(result.map!), sources: [context.id] }; + return { code: result.code, map, css: '.fe { color: red; }' }; + }, +}; + +beforeAll(async () => { + root = await realpath(await mkdtemp(path.join(tmpdir(), 'solid-frontends-'))); + await symlink( + fileURLToPath(new URL('../node_modules', import.meta.url)), + path.join(root, 'node_modules'), + 'dir', + ); + await mkdir(path.join(root, 'src')); + await writeFile( + path.join(root, 'src/App.fe'), + 'export default function App() { return

hi

; }\n', + ); + await writeFile(path.join(root, 'src/shared.d.fe'), '// declarations the front end reads\n'); + await writeFile( + path.join(root, 'src/actions.fe'), + 'export async function save(x) { "use server"; return x; }\nexport const View = () => ;\n', + ); + server = await createServer({ + root, + configFile: false, + appType: 'custom', + logLevel: 'silent', + server: { middlewareMode: true, hmr: false, watch: null }, + optimizeDeps: { noDiscovery: true, include: [] }, + environments: { client: {}, ssr: { consumer: 'server' } }, + plugins: [solidPlugin({ ssr: true, serverFunctions: true, frontends: { '.fe': fe } })], + }); +}); + +afterAll(async () => { + await server?.close(); + await rm(root, { recursive: true, force: true }); +}); + +describe('frontends', () => { + it('compiles a module of its extension with the client options', async () => { + const result = await server.environments.client.transformRequest('/src/App.fe'); + expect(result?.code).toContain('_$template('); + const context = calls.find((c) => c.id.endsWith('App.fe') && !c.ssr); + expect(context?.solid.generate).toBe('dom'); + expect(context?.solid.hydratable).toBe(true); + }); + + it('compiles it for the server in a server environment', async () => { + const result = await server.environments.ssr.transformRequest('/src/App.fe'); + expect(result?.code).toContain('ssrHydrationKey'); + expect(calls.find((c) => c.id.endsWith('App.fe') && c.ssr)?.solid.generate).toBe('ssr'); + }); + + it('serves its stylesheet as a CSS module the module imports', async () => { + const result = await server.environments.client.transformRequest('/src/App.fe'); + const css = result?.code.match(/import "([^"]*solid-tsrx-css[^"]*)"/)?.[1]; + expect(css).toBeTruthy(); + const loaded = await server.environments.client.transformRequest(css!); + expect(loaded?.code).toContain('.fe { color: red; }'); + }); + + it('keeps a source map back to the module', async () => { + const result = await server.environments.client.transformRequest('/src/App.fe'); + const map = result?.map as { sources?: string[] } | null; + expect(map?.sources?.some((s) => s.endsWith('App.fe'))).toBe(true); + }); + + // Needs @solidjs/compiler's secondary passes to take another front end's + // filename (solidjs/solid, secondary passes parse unknown extensions as TSX). + it.skipIf(!directivesTakeAnyExtension())( + 'extracts server functions from its output', + async () => { + const client = await server.environments.client.transformRequest('/src/actions.fe'); + expect(client?.code).toContain('createServerReference'); + expect(client?.code).not.toContain('return x'); + }, + ); + + it('watches the files the front end read', async () => { + await server.environments.client.transformRequest('/src/App.fe'); + const node = await server.environments.client.moduleGraph.getModuleByUrl('/src/App.fe'); + expect([...(node?.importedModules ?? [])].some((m) => m.file?.endsWith('shared.d.fe'))).toBe( + true, + ); + }); + + it("refuses an extension Solid's compiler already owns", () => { + expect(() => solidPlugin({ frontends: { '.tsx': fe } })).toThrow(/frontends/); + }); +}); diff --git a/src/index.ts b/src/index.ts index 3b62e56a..b246aab7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -261,6 +261,52 @@ export interface ExtensionOptions { export type Compiler = 'babel' | 'native'; export type { SourceNamesOptions }; +/** + * Another front end for Solid (see `Options.frontends`): a language whose + * modules it compiles itself, to what `@solidjs/compiler` emits for a JSX + * module — Solid's output, no JSX left. The plugin then treats the module + * the way it treats lowered `.tsrx`: primitive names, `lazy()` module URLs, + * the HMR refresh pass, server functions, and the stylesheet as a CSS module. + */ +export interface Frontend { + /** + * Compiles one module for one environment. `null` leaves the module to + * the rest of the pipeline untouched. + */ + compile( + source: string, + context: FrontendContext, + ): FrontendResult | null | Promise; +} + +export interface FrontendContext { + /** The module's file path. */ + id: string; + /** Whether the module is compiled for a server environment. */ + ssr: boolean; + /** Vite's resolved project root. */ + root: string; + /** + * The options this plugin would hand `@solidjs/compiler` for a JSX module + * in this environment (`generate`, `hydratable`, `moduleName`, `dev`, …), + * so a front end that ends in Solid's compiler compiles alike. + */ + solid: SolidOptions & { root?: string }; + /** + * Marks a file the front end read for this module (an imported module's + * declarations, say): a change to it recompiles this module. + */ + addWatchFile(file: string): void; +} + +export interface FrontendResult { + code: string; + /** A source map from `code` back to the module's source. */ + map?: string | object | null; + /** The module's stylesheet; the module imports it as a CSS module. */ + css?: string; +} + // `root` (the project root `sourceNames.locations` paths are relative to) // is not a user option: the plugin passes Vite's resolved `config.root`. export type SolidOptions = Omit< @@ -495,6 +541,16 @@ export interface Options { * @default undefined */ extensions?: (string | [string, ExtensionOptions])[]; + /** + * Other front ends, by file extension (`{ ".rx": rx }`): a module of that + * extension is compiled by the front end instead of Solid's JSX compiler, + * then takes the route lowered `.tsrx` takes (see `Frontend`). Its + * extension joins the default server-functions filter and the dependency + * scan. + * + * @default undefined + */ + frontends?: Record; /** * Pass any additional babel transform options. They will be merged with * the transformations required by Solid. @@ -1203,6 +1259,23 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // resolve against the Vite root, not process.cwd() — running `vite` from // outside the project would otherwise change what the filter matches. let filter = createFilter(options.include, options.exclude); + const frontends = Object.entries(options.frontends ?? {}).map(([extension, frontend]) => { + if (!/^\.[\w.-]+$/.test(extension) || /^\.(?:[mc]?[jt]sx?|tsrx)$/i.test(extension)) { + throw new Error( + `[@solidjs/vite-plugin] \`frontends\` takes file extensions other than Solid's own, such as ".rx"; got ${JSON.stringify(extension)}.`, + ); + } + return [extension.toLowerCase(), frontend] as const; + }); + const frontendFor = (id: string): Frontend | undefined => { + if (frontends.length === 0) return undefined; + const path = cleanModuleId(id).toLowerCase(); + return frontends.find(([extension]) => path.endsWith(extension))?.[1]; + }; + // A module that reaches Solid's pipeline already lowered: `.tsrx` (lowered + // here, first) or another front end's. + const isLoweredModule = (id: string) => isTsrxModule(id) || !!frontendFor(id); + const scanExtensions = ['.tsrx', ...frontends.map(([extension]) => extension)]; const serverComponentsOption = typeof options.serverFunctions === 'object' ? options.serverFunctions.components : undefined; const serverComponents = !!serverComponentsOption; @@ -1293,8 +1366,27 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { const tsrxDepScanPlugin = { name: 'solid:tsrx-dep-scan', async transform(source: string, id: string) { - if (!isTsrxModule(id) || isTsrxCssModule(id)) return null; + if (!isLoweredModule(id) || isTsrxCssModule(id)) return null; const compiler = await loadNativeCompiler(); + const frontend = frontendFor(id); + if (frontend) { + const result = await frontend.compile(source, { + id: cleanModuleId(id), + ssr: false, + root: projectRoot, + solid: getSolidOptions( + options, + false, + replaceDev, + observe, + isTestMode, + 'native', + projectRoot, + ), + addWatchFile: () => {}, + }); + return result ? { code: result.code, map: null } : null; + } const result = await compiler.transformAsync(source, { ...getSolidOptions(options, false, replaceDev, observe, isTestMode, 'native', projectRoot), filename: cleanModuleId(id), @@ -1454,6 +1546,29 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { return typeof css === 'string' ? css : ''; } + async function compileLoweredCss(source: string, id: string): Promise { + const frontend = frontendFor(id); + if (frontend) { + const result = await frontend.compile(source, { + id, + ssr: false, + root: projectRoot, + solid: getSolidOptions( + options, + false, + replaceDev, + observe, + isTestMode, + 'native', + projectRoot, + ), + addWatchFile: () => {}, + }); + return result?.css ?? ''; + } + return compileTsrxCss(source, id); + } + async function compileTsrxCss(source: string, id: string): Promise { if (options.compiler === 'babel') { const solidOptions = getSolidOptions( @@ -1769,7 +1884,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { dedupe, }, optimizeDeps: { - extensions: ['.tsrx'], + extensions: scanExtensions, include: [ ...nestedDeps, // Dev refresh wrappers import the solid-js/refresh runtime in @@ -1918,8 +2033,11 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // outside Node turn discovery back on (@cloudflare/vite-plugin), #387. if (name !== 'client') { const optimizeDeps = (config.optimizeDeps ??= {}); - if (!optimizeDeps.extensions?.includes('.tsrx')) { - optimizeDeps.extensions = [...(optimizeDeps.extensions ?? []), '.tsrx']; + const missing = scanExtensions.filter( + (extension) => !optimizeDeps.extensions?.includes(extension), + ); + if (missing.length) { + optimizeDeps.extensions = [...(optimizeDeps.extensions ?? []), ...missing]; } const rolldownOptions = (optimizeDeps.rolldownOptions ??= {}); const transform = (rolldownOptions.transform ??= {}); @@ -2152,8 +2270,8 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { devAssetResolver.invalidate(); this.environment.hot.send({ type: 'custom', event: DEV_MANIFEST_INVALIDATE_EVENT }); } - if (isTsrxModule(file) && this.environment.name === 'client') { - updateTsrxCss(tsrxCss, file, await compileTsrxCss(await read(), file)); + if (isLoweredModule(file) && this.environment.name === 'client') { + updateTsrxCss(tsrxCss, file, await compileLoweredCss(await read(), file)); const cssModule = this.environment.moduleGraph.getModuleById(resolvedTsrxCssModuleId(file)); if (cssModule) { this.environment.moduleGraph.invalidateModule(cssModule); @@ -2211,7 +2329,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { for (const depId of info.dynamicallyImportedIds || []) { const cleanId = depId.split('?')[0]; if (/node_modules/.test(cleanId) || cleanId.startsWith('\0')) continue; - if (!(/\.[mc]?[tj]sx?$/i.test(cleanId) || isTsrxModule(cleanId))) continue; + if (!(/\.[mc]?[tj]sx?$/i.test(cleanId) || isLoweredModule(cleanId))) continue; if (emittedLazyChunks.has(depId)) continue; emittedLazyChunks.add(depId); emittedLazyChunkRefs.push( @@ -2392,12 +2510,16 @@ export default manifest;`; const moduleId = id; id = getTransformPath( id, - (path) => /\.[mc]?[tj]sx$/i.test(path) || allExtensions.includes(getExtension(path)), + (path) => + /\.[mc]?[tj]sx$/i.test(path) || + allExtensions.includes(getExtension(path)) || + !!frontendFor(path), ); // Read the extension from the query-free path, so a dot inside the // query (`a.mdx?x=1.5`) is not taken as the extension. const currentFileExtension = getExtension(id); const isTsrx = isTsrxModule(id); + const frontend = frontendFor(id); const inNodeModules = /node_modules/.test(id); // Primitive names stop at node_modules: a dependency's internals // (Solid's own flow controls included) name what they mean to name, @@ -2406,7 +2528,12 @@ export default manifest;`; const sourceNames = getSourceNames(options.solid?.sourceNames, replaceDev, observe); const namePrimitives = !inNodeModules && sourceNames.primitives; - if (!(/\.[mc]?[tj]sx$/i.test(id) || isTsrx || allExtensions.includes(currentFileExtension))) { + if (!( + /\.[mc]?[tj]sx$/i.test(id) || + isTsrx || + frontend || + allExtensions.includes(currentFileExtension) + )) { // Not a JSX module. Primitive naming stays gated as before // (`createSignal` lives in `.ts`/`.js` as much as in components). // The lazy module-URL pipeline also runs, and it is not gated on @@ -2525,9 +2652,10 @@ export default manifest;`; let code = source; const maps: ChainableMap[] = []; - // Authored TSRX cannot be parsed by a standalone pass; its primitives - // are named after Solid lowering, on the generated module, below. - if (namePrimitives && !isTsrx) { + // Authored TSRX (or another front end's source) cannot be parsed by a + // standalone pass; its primitives are named after Solid lowering, on + // the generated module, below. + if (namePrimitives && !isTsrx && !frontend) { const named = await transformPrimitiveNames( this, compiler, @@ -2542,7 +2670,7 @@ export default manifest;`; } } - if (isTsrx) { + if (isTsrx || frontend) { // Solid lowering preserves authored TypeScript annotations; secondary // passes therefore parse the generated module as TSX even though no // template syntax remains. @@ -2561,7 +2689,29 @@ export default manifest;`; }; let css = ''; - if (options.compiler !== 'babel') { + if (frontend) { + // Another front end lowers the module itself, through the options + // a JSX module of this environment would get from this plugin. + const result = await frontend.compile(code, { + id, + ssr: !!isSsr, + root: projectRoot, + solid: getSolidOptions( + options, + !!isSsr, + replaceDev, + observe, + isTestMode, + 'native', + projectRoot, + ), + addWatchFile: (file) => this.addWatchFile(file), + }); + if (!result) return null; + code = result.code; + css = result.css ?? ''; + maps.push(result.map as ChainableMap); + } else if (options.compiler !== 'babel') { const result = await compiler.transformAsync(code, { ...solidOptions, filename: id, @@ -2637,7 +2787,7 @@ export default manifest;`; } code = injectSsrModuleId(await resolveLazyModuleUrls(this, code, id), moduleId, !!isSsr); - let map = options.compiler === 'babel' ? combineSourcemaps(maps) : null; + let map = options.compiler === 'babel' || frontend ? combineSourcemaps(maps) : null; updateTsrxCss(tsrxCss, id, css); if (css) { code = prependTsrxCssImport(code, id); @@ -2788,6 +2938,7 @@ export default manifest;`; externalDevServer, tsrxAfterSolid: true, tsrxSourceMap: options.compiler === 'babel', + frontendExtensions: frontends.map(([extension]) => extension), // With start mode on (either variant), the dev middleware dispatches // the endpoint through the SSR handler so user middleware and the // stub-backed request event front it exactly like page SSR. diff --git a/src/index.ts.orig b/src/index.ts.orig new file mode 100644 index 00000000..49dac6bd --- /dev/null +++ b/src/index.ts.orig @@ -0,0 +1,3123 @@ +import * as babel from '@babel/core'; +import type { TransformOptions as JsxCompilerOptions } from '@solidjs/compiler'; +import remapping from '@ampproject/remapping'; +import solid from '@solidjs/babel-plugin'; +import { existsSync, readFileSync, realpathSync } from 'fs'; +import { mergeAndConcat } from 'merge-anything'; +import { createRequire } from 'module'; +import { + createDevAssetResolver, + registerDevAssetResolver, + installDevManifestBridge, + devManifestBridgeUrl, + DEV_MANIFEST_REGISTRY_KEY, + DEV_MANIFEST_INVALIDATE_EVENT, +} from './dev-manifest.js'; +import { boundaryModules } from './boundary-modules.js'; +import { solidDiagnostics } from './diagnostics/index.js'; +import { + resolvePerformanceTracksOptions, + solidPerformanceTracks, + type PerformanceTracksOption, +} from './performance-tracks/index.js'; + +import { + normalizeServerFunctionsEndpoint, + serverFunctions, + type ServerFunctionsOptions, +} from './server-functions/index.js'; +import { SSR_HANDLER_ID, startServe, type StartOptions } from './ssr/index.js'; +import { startEnv } from './start-env.js'; +import { + detectLocationSupport, + getJsxSourceNamesOptions, + getPrimitiveNamesOptions, + getSourceNames, + remapJsxLocations, + requestedLocations, + type LocationTarget, + type ResolvedSourceNames, + type SourceNamesOptions, +} from './source-names.js'; +import { + cleanModuleId, + isTsrxCssModule, + isTsrxModule, + offsetSourceMapLine, + prependTsrxCssImport, + resolvedTsrxCssModuleId, + resolveTsrxCssModule, + tsrxCssSourceId, + updateTsrxCss, +} from './tsrx.js'; + +export { devStylePatch } from './dev-manifest.js'; +export { serverFunctions }; +export type { ServerFunctionsOptions }; +export type { + PersistedServerFunctionManifest, + ServerFunctionsFilter, +} from './server-functions/index.js'; +export type { StartOptions }; +export type { StartMiddleware } from './request-event.js'; +import path from 'path'; +import type { FilterPattern, Logger, Plugin, ViteDevServer } from 'vite'; +import { + createFilter, + defaultClientConditions, + defaultExternalConditions, + defaultServerConditions, + searchForWorkspaceRoot, + transformWithOxc, +} from 'vite'; +import { getEnvironmentConsumer, isRunnableEnvironment } from './environment.js'; +import { crawlFrameworkPkgs } from 'vitefu'; + +/** + * The `lazy()` module-URL placeholder contract, shared with the native + * compiler's `transformLazy` pass: `lazy(() => import("spec"))` calls gain a + * second string-literal argument of the form + * `"__SOLID_LAZY_MODULE__:" + spec`, which `resolveLazyModuleUrls` swaps for + * the project-relative resolved module path. The prefix and shape are FROZEN + * — the emitting side lives in @solidjs/compiler and must match. + */ +const LAZY_PLACEHOLDER_PREFIX = '__SOLID_LAZY_MODULE__:'; + +/** + * The HMR runtime: the dev-only `solid-js/refresh` core entry. Refresh + * wrappers are compiled by the native `transformRefresh` pass in every mode + * and import the runtime through normal module resolution (the legacy + * solid-refresh package — whose runtime carries a known Solid 2.0 HMR bug, + * solid-refresh#85 — is no longer used at all). + */ +const REFRESH_RUNTIME_SOURCE = 'solid-js/refresh'; + +// Appended to the document shell's client compile instead of a refresh +// boundary (see documentModuleId in solidPlugin): self-accept, then +// invalidate — Vite's spelling for "this module cannot hot-update, reload". +const DOCUMENT_HMR_DECLINE = + '\nif (import.meta.hot) {\n import.meta.hot.accept(() => import.meta.hot.invalidate());\n}\n'; + +const DEFAULT_STYLE_EXCLUDE = /node_modules/; + +const VIRTUAL_MANIFEST_ID = 'virtual:solid-manifest'; +const RESOLVED_VIRTUAL_MANIFEST_ID = '\0' + VIRTUAL_MANIFEST_ID; +const VIRTUAL_MANIFEST_BRIDGE_ID = 'virtual:solid-manifest-bridge'; +const RESOLVED_VIRTUAL_MANIFEST_BRIDGE_ID = '\0' + VIRTUAL_MANIFEST_BRIDGE_ID; + +// In dev the virtual manifest exports a `{ resolve, resolveSync }` resolver: +// lazy modules resolve to their dev URL plus transitively imported CSS as +// inline-style descriptors collected from the live module graph. The resolver +// itself lives plugin-side (it closes over the dev server) and is reached +// through a global registry; isolated module runners that don't share +// globals (nitro's dev worker, workerd) fall back to fetching the dev +// server's bridge endpoint, whose URL is baked in at generation time +// (`bridgeUrl` — null outside a live dev server, e.g. the manifest-less SSR +// build fallback, where js-only resolution remains). Bridge failures log +// loudly and resolve to null so the runtime's own no-assets warning stays +// the final catch-all. Generated before the server listens (#414), it has no +// origin yet: `deferBridge` imports it from `virtual:solid-manifest-bridge` +// on first lookup (waiting in `load` deadlocks environment `listen()`). +// +// The generated `moduleUrl` mirrors `devModuleUrl` (src/dev-manifest.ts) — +// base-prefixed root-relative URLs, `/@fs/` for root-external keys — for the +// degraded paths that can't reach the plugin-side resolver (no registry and +// no bridge, or a resolveSync call before the bridge cache warms). Keep the +// two in sync. +const devManifestCode = ( + root: string, + base: string, + bridgeUrl: string | null, + deferBridge: boolean, +) => `const registry = globalThis[Symbol.for(${JSON.stringify(DEV_MANIFEST_REGISTRY_KEY)})]; +const projectRoot = ${JSON.stringify(root.split(path.sep).join('/'))}; +const base = ${JSON.stringify(base.startsWith('/') ? base.replace(/\/$/, '') : '')}; +function moduleUrl(key) { + key = key.replace(/^\\/+/, ""); + const queryIndex = key.indexOf("?"); + const file = queryIndex === -1 ? key : key.slice(0, queryIndex); + const query = queryIndex === -1 ? "" : key.slice(queryIndex); + if (file.slice(0, 2) !== "..") return base + "/" + key; + const segments = (projectRoot + "/" + file).split("/"); + const resolved = []; + for (const segment of segments) { + if (segment === "..") resolved.pop(); + else if (segment && segment !== ".") resolved.push(segment); + } + return base + "/@fs/" + resolved.join("/") + query; +} +const jsOnly = key => ({ js: [moduleUrl(key)], css: [] }); +let bridgeUrl = ${JSON.stringify(bridgeUrl)}; +const deferBridge = ${deferBridge}; +let bridgeLookup = null; +let bridgeMissing = false; +function lookupBridge() { + if (!bridgeLookup) { + bridgeLookup = import(${JSON.stringify(VIRTUAL_MANIFEST_BRIDGE_ID)}).then( + (mod) => { + bridgeLookup = null; + bridgeUrl = mod.default; + bridgeMissing = !bridgeUrl; + }, + (error) => { + bridgeLookup = null; + throw error; + }, + ); + } + return bridgeLookup; +} +function createBridgeResolver() { + // Lazy SSR retries need synchronous cache hits to converge. Failed bridge + // requests stay retryable; concurrent lookups share one pending promise. + const cache = new Map(); + const inFlight = new Map(); + let generation = 0; + const invalidate = () => { + generation++; + cache.clear(); + inFlight.clear(); + }; + if (import.meta.hot) { + // Hosts such as Nitro consume full-reload before it reaches this runner. + import.meta.hot.on(${JSON.stringify(DEV_MANIFEST_INVALIDATE_EVENT)}, invalidate); + import.meta.hot.dispose(() => { + import.meta.hot.off(${JSON.stringify(DEV_MANIFEST_INVALIDATE_EVENT)}, invalidate); + invalidate(); + }); + } + + return { + resolve(key) { + const cached = cache.get(key); + if (cached) return cached; + if (bridgeMissing) { + // No origin yet: stay synchronous and retry the lookup. + lookupBridge().catch(() => {}); + return jsOnly(key); + } + let request = inFlight.get(key); + if (!request) { + const startedAt = generation; + request = fetchAssets(key).then( + (assets) => { + if (generation === startedAt) { + if (assets && !bridgeMissing) cache.set(key, assets); + inFlight.delete(key); + } + return assets; + }, + (error) => { + if (generation === startedAt) inFlight.delete(key); + throw error; + }, + ); + inFlight.set(key, request); + } + return request; + }, + resolveSync: (key) => cache.get(key) || jsOnly(key), + }; +} +async function fetchAssets(key) { + if (!bridgeUrl) { + await lookupBridge(); + if (!bridgeUrl) return jsOnly(key); + } + const url = new URL(bridgeUrl); + url.searchParams.set("key", key); + let response; + try { + response = await fetch(url); + } catch (error) { + console.error( + '[@solidjs/vite-plugin] Dev manifest bridge request failed for module key "' + key + + '" (' + url.href + '): ' + ((error && error.message) || error) + + ". SSR will render without this module's client assets, so its hydration preload entry will be missing.", + ); + return null; + } + if (!response.ok) { + // A silent null here strips the module's client assets from the + // SSR'd hydration asset map and hydration fails much later with a + // cryptic client-side error — report the miss where it happens. + console.error( + '[@solidjs/vite-plugin] Dev manifest bridge request failed with status ' + response.status + + ' for module key "' + key + '" (' + url.href + + "). SSR will render without this module's client assets, so its hydration preload entry will be missing.", + ); + return null; + } + return response.json(); +} +export default (registry && registry[${JSON.stringify(root)}]) || + (bridgeUrl || deferBridge ? createBridgeResolver() : { resolve: jsOnly, resolveSync: jsOnly });`; + +/** Possible options for the extensions property */ +export interface ExtensionOptions { + typescript?: boolean; +} + +export type Compiler = 'babel' | 'native'; +export type { SourceNamesOptions }; + +/** + * Another front end for Solid (see `Options.frontends`): a language whose + * modules it compiles itself, to what `@solidjs/compiler` emits for a JSX + * module — Solid's output, no JSX left. The plugin then treats the module + * the way it treats lowered `.tsrx`: primitive names, `lazy()` module URLs, + * the HMR refresh pass, server functions, and the stylesheet as a CSS module. + */ +export interface Frontend { + /** + * Compiles one module for one environment. `null` leaves the module to + * the rest of the pipeline untouched. + */ + compile( + source: string, + context: FrontendContext, + ): FrontendResult | null | Promise; +} + +export interface FrontendContext { + /** The module's file path. */ + id: string; + /** Whether the module is compiled for a server environment. */ + ssr: boolean; + /** Vite's resolved project root. */ + root: string; + /** + * The options this plugin would hand `@solidjs/compiler` for a JSX module + * in this environment (`generate`, `hydratable`, `moduleName`, `dev`, …), + * so a front end that ends in Solid's compiler compiles alike. + */ + solid: SolidOptions & { root?: string }; + /** + * Marks a file the front end read for this module (an imported module's + * declarations, say): a change to it recompiles this module. + */ + addWatchFile(file: string): void; +} + +export interface FrontendResult { + code: string; + /** A source map from `code` back to the module's source. */ + map?: string | object | null; + /** The module's stylesheet; the module imports it as a CSS module. */ + css?: string; +} + +// `root` (the project root `sourceNames.locations` paths are relative to) +// is not a user option: the plugin passes Vite's resolved `config.root`. +export type SolidOptions = Omit< + JsxCompilerOptions, + 'filename' | 'sourceMap' | 'sourceNames' | 'root' +> & { + /** + * Source names in output: `true`/`false` for every kind, or per kind. + * Defaults to the posture — on for dev and `observe`, off for production + * builds (`locations`: on for dev only); `false` opts out of every kind, + * in dev too. + */ + sourceNames?: boolean | SourceNamesOptions; +}; +type NativeCompiler = typeof import('@solidjs/compiler'); +let nativeCompilerPromise: Promise | undefined; +/** + * Whether the loaded `@solidjs/compiler` takes `sourceNames.locations` and + * `root` (see `detectLocationSupport`). Probed once, when the compiler + * loads, so it is only meaningful after `loadNativeCompiler()` resolved — + * every caller that builds native options awaits the compiler first. + */ +let nativeLocations = false; + +async function loadNativeCompiler() { + try { + return await (nativeCompilerPromise ??= import('@solidjs/compiler').then((compiler) => { + nativeLocations = detectLocationSupport(compiler); + return compiler; + })); + } catch (error) { + nativeCompilerPromise = undefined; + const reason = error instanceof Error ? `\n\nCause: ${error.message}` : ''; + throw new Error( + '@solidjs/vite-plugin: failed to load @solidjs/compiler, which is required ' + + 'in every mode (it drives the lazy, refresh, and server-function transforms; ' + + 'compiler: "babel" only switches the JSX transform). Your platform should get ' + + 'a prebuilt native binary or the @solidjs/compiler-wasm32-wasi fallback ' + + '— check that optional dependencies were installed.' + + reason, + ); + } +} + +/** Configuration options for @solidjs/vite-plugin. */ +export interface Options { + /** + * A [picomatch](https://github.com/micromatch/picomatch) pattern, or array of patterns, which specifies the files + * the plugin should operate on. Relative patterns are resolved against the + * Vite root, not the invocation directory. + */ + include?: FilterPattern; + /** + * A [picomatch](https://github.com/micromatch/picomatch) pattern, or array of patterns, which specifies the files + * to be ignored by the plugin. Relative patterns are resolved against the + * Vite root, not the invocation directory. + */ + exclude?: FilterPattern; + /** + * Resolve Solid's development builds under `vite dev` — the `development` + * export condition of `solid-js` and `@solidjs/web`, which carry the extra + * checks, warnings and diagnostics. Has no effect on `vite build`. Set to + * `false` to serve the production builds in dev instead. + * + * @default true + */ + dev?: boolean; + /** + * Resolve Solid's observe builds: the production-speed runtime that keeps + * the diagnostics and attribution channels (`OBSERVE`) alive for + * production observability tooling. Adds the `observe` export condition + * to every environment (client and server, inlined and externalized) and + * turns on the compiler's `sourceNames` (components, bindings, and + * primitives) so graph labels survive minification. Applies to `vite + * build` and preview; under `vite dev` the + * `development` condition still wins (the dev build is a superset). + * + * @default false + */ + observe?: boolean; + /** + * Dev-serve only: expose Solid's diagnostic and attribution channels to + * out-of-process consumers (agents, tests, curl). Injects a client module + * that installs the in-page bridge from the app's own + * `@solidjs/diagnostics`, and serves a `/__solid/diagnostics` endpoint on + * the dev server that forwards capture control (`begin`/`end`), + * `whyDidRun`, and cost queries to the page over the Vite WebSocket. No + * effect on builds or preview. + * + * Omitted (the default), the surface auto-enables when the app declares + * `@solidjs/diagnostics` in its package.json — adding the dev dependency + * is the whole setup. `true` forces it on (erroring if the package is + * missing); `false` opts out entirely. Never active in test mode + * (vitest) or on builds/preview. + * + * @default undefined (auto-detect) + */ + diagnostics?: boolean; + /** + * Dev-serve only: paint Solid's records — re-runs, interactions, holds, + * async flights, navigations, server-function calls — as custom tracks + * in the Chrome Performance panel (`@solidjs/web/performance-tracks`), + * without the app calling `enablePerformanceTracks()` itself. Injects a + * client module that enables the tracks ahead of the app's entry (a + * `` script for index.html apps, an import at the top of the + * start-mode client entry), so hydration and the first interaction are + * on the timeline. The dev server already writes its side of the work + * (`Server-Timing`), which the tracks read back. + * + * Omitted or `true` enables under `vite dev` with the adapter's defaults; + * an object enables with those options passed through + * (`PerformanceTracksOptions` from `@solidjs/web/performance-tracks`: + * `minMs`, `rich`, `attribution` — plain data, serialized into the + * injected module); `false` opts out. Never active on `vite build` — + * not for `dev: true` or `observe` builds (an observe app enables its + * own tracks in production) — in test mode (vitest), or on preview. + * With `dev: false` (and no `observe`) the production runtime is served + * and the adapter is a no-op. + * + * @default true + */ + performanceTracks?: PerformanceTracksOption; + /** + * Whether the app is server-rendered — one meaning everywhere. + * + * Without {@link start}: the legacy transform-only flag, unchanged. + * `true` enables the SSR transforms (hydratable client code, SSR server + * code) — you provide the entries and the server yourself. With `false`, + * client code is non-hydratable DOM output, and modules loaded on the + * server (for example through `ssrLoadModule` to prerender a page) get + * non-hydratable SSR output. + * + * With {@link start}: selects the start mode. `true` is SSR start mode + * (per-request streaming render + hydration); `false`/omitted is client + * mode (a static document shell + client-side `render()`). Flipping a + * start-mode project between SPA and SSR is toggling this one boolean. + * + * The flag describes the app's initial document, not the internal + * pipelines — client mode still compiles the document shell through the + * SSR transforms to serve/prerender it. + * + * Objects are no longer accepted: start-mode options moved to {@link start} + * (`ssr: { ... }` from 3.0.0-next.23 and earlier becomes + * `start: { ... }, ssr: true`). + * + * @default false + */ + ssr?: boolean; + + /** + * Start mode — Start as a mode of the plugin: it owns entries, dev + * serving, and the build — no index.html, no mount file, no server + * wiring. `start: true` is the zero-config spelling, sugar for the empty + * options bag `start: {}` (both mean the identical start mode with + * defaults; `false`/absent is off). Conventions (shared by both modes, + * so projects flip between them by toggling {@link ssr}): `src/App.*` + * (or `start.app`) is the root component; `src/Document.*` (or + * `start.document`) is the optional document shell; authored + * `src/entry-server.*` / `src/entry-client.*` (or `start.entryServer` / + * `start.entryClient`) replace the generated entries. + * + * With `ssr: true` — SSR start mode: + * + * - Dev: a middleware on the Vite dev server streams the rendered app for + * HTML-accepting GET requests — `vite` just works, no server file. + * - Build: a plain `vite build` produces both bundles (client to + * `dist/client`, server to `dist/server` via the environments/builder + * API). The server bundle's entry is `virtual:solid-ssr-handler`, whose + * `handleRequest(request)` export maps a web `Request` to a streamed + * `Response`; its default `{ fetch(request) }` export provides the same + * handler in the Fetchable shape used by deployment integrations. + * The normal `ssr` environment exposes it as the `index` service entry + * so provider Vite plugins can supply the runtime and build orchestration. + * - `start.node: true` additionally emits a ready-to-run Node server, + * `dist/server/node.js` (`node dist/server/node.js`; PORT/HOST), that + * serves the client build statically and dispatches the rest through + * `handleRequest` — see {@link StartOptions.node}. Node is the one + * mainstream runtime without a fetch-shaped server API; every other + * host consumes the `{ fetch }` export directly. + * - With `serverFunctions` also enabled, the prod handler serves the + * server-function endpoint too (in dev the server-function middleware + * already runs first). + * + * Without `ssr: true` — client mode: + * + * - Dev: every HTML-accepting GET streams the rendered document shell + * (without the app — history-fallback semantics); the generated client + * entry `render()`s the app into it. + * - Build: `vite build` emits a static `dist/client` — the shell is + * prerendered once through the built handler into + * `dist/client/index.html` with the hashed entry script and CSS links — + * deployable to any static host. No server bundle remains unless + * `serverFunctions` is enabled, in which case `dist/server` is kept and + * its `handleRequest` serves the endpoint (pages stay static) — and + * `start.node` then emits `dist/server/node.js` serving the static + * client with an `index.html` history fallback plus the endpoint. + * - Client code stays non-hydratable (`generate: 'dom'`), exactly like a + * plain SPA; server-only options (`entryServer`, `external`) are inert. + * - `vite preview` serves the static build with history fallback (and + * dispatches the server-function endpoint through the kept handler). + * + * @default undefined + */ + start?: boolean | StartOptions; + + /** + * JSX compiler backend to use. The default `"native"` compiles through + * `@solidjs/compiler`; `"babel"` is the escape hatch running + * `@solidjs/babel-plugin` instead — if native output ever differs from your + * expectations, set `compiler: "babel"` and file an issue (the behavioral + * diff between the modes is the bug report). Platforms without a prebuilt + * native binary (e.g. StackBlitz WebContainers) automatically use the wasm + * fallback; the compiler package itself is required in every mode. + * + * @default "native" + */ + compiler?: Compiler; + + /** + * This will inject HMR runtime in dev mode. Has no effect in prod. If + * set to `false`, it won't inject the runtime in dev. + * + * @default true + * @deprecated use `refresh` instead + */ + hot?: boolean; + /** + * This registers additional extensions that should be processed by + * @solidjs/vite-plugin. Experimental `.tsrx` is always registered as + * TypeScript TSRX and does not need to be listed here. + * + * @default undefined + */ + extensions?: (string | [string, ExtensionOptions])[]; + /** + * Other front ends, by file extension (`{ ".rx": rx }`): a module of that + * extension is compiled by the front end instead of Solid's JSX compiler, + * then takes the route lowered `.tsrx` takes (see `Frontend`). Its + * extension joins the default server-functions filter and the dependency + * scan. + * + * @default undefined + */ + frontends?: Record; + /** + * Pass any additional babel transform options. They will be merged with + * the transformations required by Solid. + * + * Note: with `compiler: "native"` the plugin is normally fully Babel-free + * (native lazy/refresh/JSX passes). Supplying custom babel options + * reintroduces a Babel support pass ahead of the native JSX transform to + * host them. For `.tsrx` only, native TSRX lowering runs first and the + * support pass receives the generated ordinary JavaScript. + * + * @default {} + */ + babel?: + | babel.TransformOptions + | ((source: string, id: string, ssr: boolean) => babel.TransformOptions) + | ((source: string, id: string, ssr: boolean) => Promise); + /** + * Pass any additional [@solidjs/babel-plugin](https://github.com/solidjs/solid/tree/main/packages/babel-plugin) options. + * They will be merged with the plugin's Solid defaults. + * + * @default {} + */ + solid?: SolidOptions; + + /** + * Enable `"use server"` server function compilation (experimental). Pass + * `true` for the defaults (runtime from @solidjs/web/server-functions) or + * an options object to customize. The directive transform sub-plugins are + * emitted ahead of the JSX transform in the returned plugin array. + * + * Zero-config setup: in dev, a middleware on the Vite server handles the + * endpoint (default `/_server`, joined with `base`) end to end — no + * server-function code needed in the server entry. For production SSR + * builds, import `virtual:solid-server-function-handler` in the server + * entry and mount its `handleServerFunctionRequest(request)` export on the + * endpoint; it eagerly imports every module containing server functions so + * registrations survive tree-shaking. + * + * Hosts whose own server environment should own endpoint dispatch in dev + * (e.g. @cloudflare/vite-plugin, so functions run in workerd with + * bindings) can keep this option and set + * `serverFunctions: { devMiddleware: false }` — see + * {@link ServerFunctionsOptions.devMiddleware}. A server-only module can + * be pinned into the handler graph for pre-dispatch runtime registration + * via {@link ServerFunctionsOptions.configure}. + * + * Meta-frameworks that need to control plugin ordering themselves (e.g. + * relative to a file-system router) and dispatch requests through their + * own server should use the standalone `serverFunctions()` export instead, + * which never installs the dev middleware. + * + * The object form's `components` flag additionally enables server + * components (experimental) — `"use server"` functions returning a + * component, served over the same endpoint. They come essentially for + * free: the endpoint transform is installed automatically, and with + * SSR start mode (the `start` option with `ssr: true`) and generated entries + * the document wiring is emitted too. See + * {@link ServerFunctionsOptions.components}. + * + * @default undefined + */ + serverFunctions?: boolean | ServerFunctionsOptions; + + /** Options for the solid-refresh HMR transform (dev only). */ + refresh?: RefreshOptions; +} + +/** Options for the solid-refresh HMR transform (dev only). */ +export interface RefreshOptions { + /** + * Disable the refresh transform entirely (equivalent to the deprecated + * `hot: false`). + */ + disabled?: boolean; + /** + * Emit per-component `signature`/`dependencies` metadata so edits only + * remount components whose code actually changed. + * + * @default true + */ + granular?: boolean; +} + +function getExtension(filename: string): string { + const index = filename.lastIndexOf('.'); + return index < 0 ? '' : filename.substring(index); +} + +/** + * The path the transform compiles for `id`. This is normally `id` without its + * query. Some plugins put the extension after the query instead, such as + * unplugin-icons' `~icons/mdi/folder?width=2em.tsx`. Then the full id is used, + * as it was before queries were stripped. #206 + */ +function getTransformPath(id: string, isHandled: (path: string) => boolean): string { + const cleanId = id.replace(/\?.*$/, ''); + return cleanId !== id && !isHandled(cleanId) && isHandled(id) ? id : cleanId; +} +// The packages whose dev/production server builds are selected by the +// `development` export condition. A dependency on either means the package +// consumes the runtime and must resolve it through Vite in dev. +const SOLID_RUNTIME_PKGS = ['solid-js', '@solidjs/web']; + +// Tooling that declares solid-js as a peer but never runs inside the SSR +// module runner. Kept out of the crawl entirely: classifying them as +// semi-framework would also crawl THEIR dependencies, which vitefu deep- +// includes in the client optimizer (`@solidjs/vite-plugin > @babel/core` +// pre-bundled for the browser — ~2.6 MB of dead weight per cold start). +// Mirrors vite-plugin-svelte's isCommonDepWithoutSvelteField list. +const NON_RUNTIME_SOLID_PKGS = ['@solidjs/vite-plugin', 'vite', 'vitest', 'eslint-plugin-solid']; +const NON_RUNTIME_SOLID_PREFIXES = [ + 'vite-plugin-', + 'eslint-plugin-', + 'prettier-plugin-', + '@types/', +]; +function isNonRuntimeSolidPkg(name: string): boolean { + const bare = name.slice(name.lastIndexOf('/') + 1); + return ( + NON_RUNTIME_SOLID_PKGS.includes(name) || + NON_RUNTIME_SOLID_PREFIXES.some((p) => (p.startsWith('@') ? name : bare).startsWith(p)) + ); +} + +function containsSolidField(fields: Record) { + const keys = Object.keys(fields); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key === 'solid') return true; + if (typeof fields[key] === 'object' && fields[key] != null && containsSolidField(fields[key])) + return true; + } + return false; +} + +/** + * Locate a bare package the way Vite (and Node without `NODE_PATH`) does: walk + * `/node_modules/` up from `root`. `require.resolve` can't be used + * for this — it also consults `NODE_PATH`, which pnpm's bin shims (`pnpm + * vitest`, `pnpm test`) point at the hoisted virtual store + * (`node_modules/.pnpm/node_modules`), where every transitive dependency of the + * whole tree is reachable. + */ +function findPackageDir(name: string, root: string): string | undefined { + let dir = root; + while (true) { + const candidate = path.join(dir, 'node_modules', name); + if (existsSync(path.join(candidate, 'package.json'))) return candidate; + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +/** + * Whether `id` looks like a package specifier (`name`, `name/sub`, + * `@scope/name/sub`), by syntax alone. Relative and absolute paths, ids with a + * protocol or drive (`virtual:`, `node:`, `C:`), subpath imports (`#x`) and + * aliases like `~/x` or `@/x` don't. + */ +function isPackageSpecifier(id: string): boolean { + if (!id || /^[./\\#~\0]/.test(id) || id.includes(':')) return false; + if (id.startsWith('@')) return /^@[^/]+\/[^/]/.test(id); + return true; +} + +function getJestDomExport(setupFiles: string[], root: string) { + if (setupFiles?.some((file) => /jest-dom/.test(file))) return undefined; + + // Resolve from the project root, not from this plugin's own location. With pnpm's + // isolated node_modules layout the plugin can reach a jest-dom that only exists as a + // transitive dependency (e.g. of Storybook), while Vitest resolves `setupFiles` from the + // project root, where it isn't installed, and fails to load it. + // https://github.com/solidjs/solid-vite-plugin/issues/231 + // The bare specifier (not the resolved path) is injected on purpose: `require.resolve` picks + // jest-dom's CommonJS entry, which Vitest refuses to load, while Vitest itself resolves the + // specifier to the ESM entry. + const packageDir = findPackageDir('@testing-library/jest-dom', root); + if (!packageDir) return undefined; + // Check the subpath against THIS copy: resolving from inside the package + // self-references its `exports` map (v6+), or falls back to its own files + // for versions without one (v5's `extend-expect`). `NODE_PATH` still + // participates in that lookup, so make sure the hit landed in the package + // Vite will resolve rather than in some hoisted copy of another version. + const realPackageDir = realpathSync(packageDir); + const packageRequire = createRequire(path.join(packageDir, 'package.json')); + return ['@testing-library/jest-dom/vitest', '@testing-library/jest-dom/extend-expect'].find( + (specifier) => { + try { + const resolved = realpathSync(packageRequire.resolve(specifier)); + return resolved.startsWith(realPackageDir + path.sep); + } catch (e) { + return false; + } + }, + ); +} + +/** + * The options for one JSX compile. `jsxCompiler` names the backend the + * result goes to, which decides whether `sourceNames.locations` and `root` + * can be included (the native compiler rejects options it does not know, + * so its probe result gates them; the babel plugin ignores unknown keys); + * for `'native'` the compiler must already be loaded (`loadNativeCompiler` + * resolved) for the probe to have run. `root` is the resolved Vite root + * the locations are made relative to. + */ +function getSolidOptions( + options: Partial, + isSsr: boolean, + dev: boolean, + observe: boolean, + isTestMode: boolean, + jsxCompiler: Compiler, + root: string, +): SolidOptions & { root?: string } { + let solidOptions: Pick; + + if (isTestMode) { + // Vitest compiles with the client posture regardless of the app's `ssr` + // flag: component tests exercise DOM code and nothing hydrates in a + // test, so hydratable output would look for markers that aren't there. + // `generate` still follows the transform's own ssr flag, so explicit + // node-environment tests (renderToString) keep their server codegen. + solidOptions = { generate: isSsr ? 'ssr' : 'dom', hydratable: false }; + } else if (options.start && !options.ssr) { + // Client start mode: client code compiles exactly like a plain SPA + // (dom, non-hydratable — nothing hydrates); only the document shell + // render goes through the SSR transforms, also non-hydratable since + // the shell is inert HTML the client never claims. + solidOptions = { generate: isSsr ? 'ssr' : 'dom', hydratable: false }; + } else if (options.ssr) { + if (isSsr) { + solidOptions = { generate: 'ssr', hydratable: true }; + } else { + solidOptions = { generate: 'dom', hydratable: true }; + } + } else { + // A client-rendered app can still load modules on the server, for + // example to prerender static pages with `ssrLoadModule`. DOM output + // cannot run there, so server transforms use the SSR generate. Nothing + // hydrates the result, so it stays non-hydratable. + solidOptions = { generate: isSsr ? 'ssr' : 'dom', hydratable: false }; + } + + // Server components (serverFunctions.components) turn on the SSR-side + // attribute-slot transform: ref/on* positions on intrinsic elements + // compile to one guarded `ssrClaim` hole per element instead of dropping + // (the runtime marks them `_s:on:` / `_s:ref` for the client + // frame's slot reads), a dynamic `class`/`style` compiles to a + // whole-attribute `ssrElementAttribute` hole so a client attribute slot + // can own it, and a spread element's named ref/on* ride `ssrElement`'s + // trailing `claims` thunk. That is the 2.0.0-rc.13 compiler ↔ runtime + // contract (the earlier `_bnd` marker is gone; rc.12 was never + // published), which is why the compiler and runtime floors move + // together. SSR-only by construction (the dom generate ignores the + // flag), and apps without the flag compile byte-for-byte as before. + const serverComponents = + typeof options.serverFunctions === 'object' && !!options.serverFunctions.components; + + // Solid-specific defaults (moduleName "@solidjs/web", the control-flow + // builtIns, contextToCustomElements, wrapConditionals) are baked into both + // backends — @solidjs/compiler and @solidjs/babel-plugin — so only the + // posture this plugin actually decides is passed. + // Source names: the dev and observe runtimes label each component's owner + // (``) and each compiled binding effect (`span.textContent`) for + // diagnostics and attribution paths. Without the compiler carrying the + // source names, a minified build labels owners by whatever the minifier + // left of `Comp.name` and bindings as `effect`. Both generates emit the + // component name — the ssr generate from the compilers that carry + // solidjs/solid#3441 (2.0.0-rc.9), so server findings and boundary + // records locate by component too — and the production runtime ignores + // the arguments, so they are only emitted for the postures whose runtime + // reads them. Primitive names are the separate transformSourceNames pass + // (see getSourceNames / the transform hook), not a JSX-compiler option. + // + // The compilers default their own `sourceNames` on under `dev` (rc.10), so + // the resolved value is always passed — `false` when both kinds are off — + // and the plugin's table (getSourceNames), not the compiler default, + // decides. That is what makes `solid.sourceNames: false` an opt-out in dev + // rather than a no-op. `locations` rides inside the same object, with + // `root` (the Vite root the paths are relative to) beside it, on a + // compiler that takes them: see getJsxSourceNamesOptions. + const { sourceNames: _userSourceNames, ...userSolidOptions } = options.solid || {}; + const names = getSourceNames(options.solid?.sourceNames, dev, observe); + return { + ...solidOptions, + ...(serverComponents && solidOptions.generate === 'ssr' ? { serverComponents: true } : {}), + dev, + ...getJsxSourceNamesOptions(names, locationTarget(jsxCompiler, root)), + ...userSolidOptions, + }; +} + +/** + * What a compiler can do with locations: the babel plugin ignores keys it + * does not know, so it always takes them; the native compiler answers + * through its load-time probe. + */ +function locationTarget(compiler: Compiler, root: string): LocationTarget { + return { supportsLocations: compiler === 'babel' || nativeLocations, root }; +} + +/** + * Script modules the JSX gate returns early for: `.ts`/`.js` and the `m`/`c` + * variants. Primitive naming applies here (plain JavaScript in and out; a + * `.d.ts` has nothing to name), and so does the lazy module-URL pipeline — + * a `routes.ts` is not JSX, but its `lazy()` callsites and an SSR module's + * `$$moduleUrl` export still have to be written. Refresh and the JSX + * transform stay on the JSX path. + */ +const PRIMITIVES_ONLY_MODULE = /\.[mc]?[jt]s$/i; +const DECLARATION_MODULE = /\.d\.[mc]?ts$/i; +/** + * Cheap pre-check ahead of the native call: the pass only names calls that + * resolve to imports from these modules, so source without either string + * cannot change. + */ +const PRIMITIVE_SOURCES = ['solid-js', '@solidjs/signals']; + +let warnedMissingSourceNamesPass = false; +let warnedMissingLocations = false; + +/** + * Warn once when the user asked for `locations` in so many words and the + * native compiler predates the kind — the dev default stays silent, like + * every other default-on nicety an older compiler cannot provide. Call + * after `loadNativeCompiler()` resolved (the probe sets `nativeLocations`). + */ +function warnMissingLocations(ctx: { warn(message: string): void }, options: Partial) { + if (nativeLocations || warnedMissingLocations) return; + if (!requestedLocations(options.solid?.sourceNames)) return; + warnedMissingLocations = true; + ctx.warn( + '@solidjs/vite-plugin: the installed @solidjs/compiler does not take the `locations` ' + + 'source-names kind, so `solid.sourceNames.locations` is ignored and diagnostics ' + + 'carry no file:line:col. Update @solidjs/compiler.', + ); +} + +/** + * Run the compiler's `transformSourceNames` pass, or leave the code alone + * (warning once) on a compiler predating it — the pass is a default-on + * nicety for dev/observe, not something to fail a build over. `names` is + * the resolved `solid.sourceNames` (its `locations` flag goes to the pass) + * and `root` the Vite root locations are made relative to. + */ +async function transformPrimitiveNames( + ctx: { warn(message: string): void }, + compiler: NativeCompiler, + code: string, + filename: string, + names: ResolvedSourceNames, + root: string, +): Promise<{ code: string; map: ChainableMap } | null> { + if (!PRIMITIVE_SOURCES.some((source) => code.includes(source))) return null; + if (typeof compiler.transformSourceNamesAsync !== 'function') { + if (!warnedMissingSourceNamesPass) { + warnedMissingSourceNamesPass = true; + ctx.warn( + '@solidjs/vite-plugin: the installed @solidjs/compiler has no transformSourceNames ' + + 'pass, so primitives keep their generic labels (`signal`, `computed`) in ' + + 'diagnostics. Update @solidjs/compiler, or set `solid.sourceNames.primitives: false`.', + ); + } + return null; + } + const result = await compiler.transformSourceNamesAsync( + code, + getPrimitiveNamesOptions(names, locationTarget('native', root), filename), + ); + // Nothing to name: the pass hands the source back verbatim, with no map. + if (result.code === code) return null; + return { code: result.code, map: result.map }; +} + +async function getBabelUserOptions( + options: Partial, + source: string, + id: string, + isSsr: boolean, +) { + if (!options.babel) return {}; + if (typeof options.babel !== 'function') return options.babel; + + const babelOptions = options.babel(source, id, isSsr); + return babelOptions instanceof Promise ? await babelOptions : babelOptions; +} + +function normalizeSourceMap( + map: string | babel.TransformOptions['inputSourceMap'] | null | undefined, +) { + if (typeof map === 'string') return JSON.parse(map); + return map || null; +} + +type ChainableMap = string | babel.TransformOptions['inputSourceMap'] | null | undefined; + +/** + * Merges the sourcemaps of sequential whole-file transforms (given in + * application order, earliest first) into one map tracing back to the + * original source. + */ +function combineSourcemaps(maps: ChainableMap[]) { + const chain = maps.filter((map): map is NonNullable => !!map); + if (chain.length === 0) return null; + if (chain.length === 1) return normalizeSourceMap(chain[0]); + // remapping expects most-recent-first. + return JSON.parse(remapping(chain.reverse() as any, () => null).toString()); +} + +function toPosixPath(p: string): string { + return p.split(path.sep).join('/'); +} + +function tryRealpath(p: string): string | null { + try { + return realpathSync.native(p); + } catch { + return null; + } +} + +/** The `input` a build environment's config resolves to, in any spelling. */ +function configuredBuildInput(build: any): unknown { + if (!build) return undefined; + return build.rolldownOptions?.input ?? build.rollupOptions?.input ?? build.lib?.entry; +} + +/** + * The genuine entries of a client build, derived from its configured input + * (`build.rollupOptions.input` as a string / array / record, or Vite's + * default `index.html`). Rollup and rolldown only ever flag two kinds of + * chunk `isEntry`: those facades and chunks plugins emit with + * `emitFile({ type: 'chunk' })` — so this is exactly the knowledge that + * tells a real application entry apart from an emitted lazy facade. + * + * `moduleIds` — every spelling the entry's facade module id can take: as + * written (virtual ids resolve to themselves), resolved against the root + * (Vite resolves relative file inputs there), and the real path of either + * (Vite's resolver follows symlinks). + * `manifestKeys` — the manifest.json keys Vite derives from those facades + * (root-relative, `\0` stripped), matching Vite's own `getChunkName`. + */ +function resolveConfiguredEntries(input: unknown, root: string) { + const raw: string[] = + input == null + ? ['index.html'] + : typeof input === 'string' + ? [input] + : Array.isArray(input) + ? input + : Object.values(input as Record); + const moduleIds = new Set(); + for (const id of raw) { + if (typeof id !== 'string') continue; + const clean = id.replace(/\0/g, ''); + const candidates = [clean, path.resolve(root, clean)]; + for (const candidate of candidates) { + moduleIds.add(candidate); + moduleIds.add(toPosixPath(candidate)); + const real = tryRealpath(candidate); + if (real) { + moduleIds.add(real); + moduleIds.add(toPosixPath(real)); + } + } + } + const manifestKeys = new Set(); + for (const id of moduleIds) manifestKeys.add(toPosixPath(path.relative(root, id))); + return { + moduleIds, + manifestKeys, + isEntryModule(id: string | null | undefined): boolean { + if (!id) return false; + const clean = id.replace(/\0/g, ''); + if (moduleIds.has(clean) || moduleIds.has(toPosixPath(clean))) return true; + const real = tryRealpath(clean); + return !!real && (moduleIds.has(real) || moduleIds.has(toPosixPath(real))); + }, + }; +} + +interface NormalizeLazyEntriesOptions { + /** + * Is this record a genuine configured entry? Such records keep `isEntry` + * no matter what dynamically imports them. + */ + isConfiguredEntry: (key: string, record: any) => boolean; + /** + * Records already known to be emitted lazy facades (reclassified + * explicitly by their emit references); everything else the sweep strips + * is reported through `warn` because it could be an entry the input + * matching missed. + */ + knownLazyKeys?: Set; + warn?: (message: string) => void; + /** + * Also flag dynamic-import targets that already lost `isEntry` as + * `isDynamicEntry` — repairs the flag rolldown drops (see below) on the + * serialized manifest. + */ + repairDynamicEntries?: boolean; +} + +/** + * Chunks emitted for lazy() targets are marked `isEntry` by Rollup even + * though they are semantically dynamic entries. Reclassify any entry that is + * dynamically imported by another chunk so the runtime's entry-asset + * detection (which keys off `isEntry`) can't pick a lazy facade instead of + * the real client entry. Works on both the Vite manifest.json shape and the + * raw Rollup output bundle — both key entries by name and expose + * `dynamicImports` / `isEntry` with the same meaning. + * + * Being a dynamic-import target alone does not make a chunk a lazy facade, + * though: the real client entry becomes one whenever it absorbs a module + * that is also dynamically imported somewhere else. Solid 2 produces that + * shape on its own — `@solidjs/web/frames/client` lazily imports the + * serialization decoder (`loadCodec()`), so a static import of + * `@solidjs/web/serialization/decode` anywhere in the client graph merges + * the decoder into the entry chunk, and the entry then lists itself (or is + * listed by another lazy chunk) under `dynamicImports`. Stripping `isEntry` + * there leaves the bundle with no entry at all ("No entry file found" + * downstream, e.g. TanStack Start's manifest capture, #342). Genuine + * configured entries are therefore never reclassified, and a chunk's + * dynamic import of itself is not an edge worth acting on. + * + * Rolldown caveat: of the flags written here only `isEntry` is synced back + * to the native bundle after the hook (rolldown's `update_output_chunk` + * copies `code`, `map`, `imports`, `dynamicImports`, `isEntry` and the file + * name; `isDynamicEntry` is kept from the original chunk). Later plugins + * and Vite's manifest plugin therefore see reclassified facades as neither + * entry nor dynamic entry under rolldown. The manifest `load` path repairs + * `isDynamicEntry` on the plugin's own manifest module, the one place it + * controls end to end. + */ +function normalizeEmittedLazyEntries( + manifest: Record, + { isConfiguredEntry, knownLazyKeys, warn, repairDynamicEntries }: NormalizeLazyEntriesOptions, +) { + const dynamicKeys = new Map(); + for (const key in manifest) { + const imports: string[] | undefined = manifest[key].dynamicImports; + if (!imports) continue; + for (const dep of imports) { + // A chunk that absorbed one of its own lazy targets imports itself; + // that says nothing about whether it is an entry. + if (dep !== key && !dynamicKeys.has(dep)) dynamicKeys.set(dep, key); + } + } + for (const [key, importer] of dynamicKeys) { + const entry = manifest[key]; + if (!entry || entry.type === 'asset') continue; + if (isConfiguredEntry(key, entry)) continue; + if (entry.isEntry) { + entry.isEntry = false; + entry.isDynamicEntry = true; + if (warn && !knownLazyKeys?.has(key)) { + warn( + `[@solidjs/vite-plugin] Reclassified the entry chunk "${key}" as a dynamic entry ` + + `because "${importer}" dynamically imports it and it does not match a configured ` + + `build input. If "${key}" is the application entry, its chunk absorbed a module ` + + 'that is also imported dynamically elsewhere (for example a static import of ' + + '"@solidjs/web/serialization/decode" alongside Solid\'s own lazy import of it); ' + + 'list the entry in `build.rollupOptions.input` so the plugin can recognize it.', + ); + } + } else if (repairDynamicEntries && !entry.isDynamicEntry) { + entry.isDynamicEntry = true; + } + } +} + +/** + * The manifest key of THE client entry — the chunk whose `