diff --git a/.changeset/support-tsrx-vite.md b/.changeset/support-tsrx-vite.md new file mode 100644 index 0000000..14e87cb --- /dev/null +++ b/.changeset/support-tsrx-vite.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +Add experimental `.tsrx` compilation with native and Babel backends, scoped CSS sidecars, HMR and SSR asset integration, and function-level server functions. diff --git a/README.md b/README.md index 9fb660f..e75509f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Join [solid discord](https://discord.com/invite/solidjs) and check the [troubles - Drop-in installation as a vite plugin - Minimal bundle size - Support typescript (`.tsx`) out of the box +- Experimental TypeScript TSRX (`.tsrx`) support out of the box - Support code splitting out of the box ## Requirements @@ -499,8 +500,8 @@ assets. **Entry resolution** (all paths relative to the Vite root): 1. Explicit `start.entryServer` / `start.entryClient` options. -2. Conventional files: `src/entry-server.{tsx,jsx,ts,js,mjs}` and - `src/entry-client.{tsx,jsx,ts,js,mjs}`. Entry files come in pairs — +2. Conventional files: `src/entry-server.{tsx,jsx,ts,js,mjs,tsrx}` and + `src/entry-client.{tsx,jsx,ts,js,mjs,tsrx}`. Entry files come in pairs — providing only one is an error. The server entry must export `render(request?, context?)` returning a `renderToStream` result, an HTML string, or a `Response`; `context.clientEntry` carries the resolved @@ -509,8 +510,8 @@ assets. the hashed asset (the classic harness convention keeps working). 3. Generated entries (the zero-config path): when no entry files exist, both are generated from a root component — `start.app`, defaulting to - `src/App.{tsx,jsx,ts,js}` (or lowercase `src/app.*`) — wrapped in a - document shell: `start.document`, defaulting to `src/Document.{tsx,jsx}`, + `src/App.{tsx,jsx,ts,js,tsrx}` (or lowercase `src/app.*`) — wrapped in a + document shell: `start.document`, defaulting to `src/Document.{tsx,jsx,tsrx}`, else a built-in minimal shell. A custom document receives the app as `props.children` and must render the full `` document including ``; the client entry script is injected into `` @@ -714,12 +715,40 @@ export default defineConfig({ }); ``` +#### Experimental TSRX + +Files ending in `.tsrx` are recognized automatically as TypeScript TSRX; they +do not need to be listed in `options.extensions`. Both the native and Babel +compiler backends preserve the `.tsrx` filename when invoking their TSRX +frontends. + +Scoped CSS emitted by either backend is exposed as a sibling virtual CSS +sidecar and imported once from the compiled module. The sidecar goes through +Vite's normal CSS pipeline, so extraction and injection work in development, +production builds, and SSR, including client HMR and SSR development style +collection. A file that emits no CSS has no sidecar import. + +The Babel backend chains TSRX source maps through the later lazy-module and +refresh transforms. The native compiler does not currently emit the required +TSRX projection map, so native `.tsrx` transforms return no source map. With +`compiler: "native"` and custom `babel` options, the custom Babel support pass +runs after native TSRX lowering (on ordinary JavaScript); ordinary JSX/TSX +keeps the existing pre-native ordering. + +With `serverFunctions` enabled, function-level `"use server"` directives work +in `.tsrx` with both compiler backends. The plugin lowers TSRX first, then runs +the same native directive transform while retaining the authored `.tsrx` path +for stable client/server function IDs. TSRX's host-defined +`module server { ... }` profile is not supported. + #### options.babel - Type: Babel.TransformOptions - Default: {} Pass any additional [babel transform options](https://babeljs.io/docs/en/options). Those will be merged with the transformations required by Solid. +With the native compiler these options normally run before JSX lowering; for +`.tsrx` only, they run after native TSRX lowering as described above. #### options.solid @@ -744,7 +773,8 @@ Pass any additional [@babel/preset-typescript](https://babeljs.io/docs/en/babel- - Default: [] An array of custom extension that will be passed through the solid compiler. -By default, the plugin only transform `jsx` and `tsx` files. +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. ## `server-only` and `client-only` boundary markers diff --git a/examples/vite-8/src/App.tsx b/examples/vite-8/src/App.tsx index 1454b4a..fc304a8 100644 --- a/examples/vite-8/src/App.tsx +++ b/examples/vite-8/src/App.tsx @@ -1,6 +1,7 @@ import { onSettled } from "solid-js"; import { CounterProvider, useCounter } from "./CounterContext"; import { title } from './UnusedLazyImporter'; +import { TsrxCard } from './TsrxCard.tsrx'; function Count() { const counter = useCounter(); @@ -50,6 +51,7 @@ export default function App() { + ); } diff --git a/examples/vite-8/src/TsrxCard.tsrx b/examples/vite-8/src/TsrxCard.tsrx new file mode 100644 index 0000000..e2a8d30 --- /dev/null +++ b/examples/vite-8/src/TsrxCard.tsrx @@ -0,0 +1,21 @@ +export async function saveCard() { + "use server"; + return "saved"; +} + +export function TsrxCard(props: { label: string }) @{ + <> + +
+ {props.label} +
+ +} diff --git a/examples/vite-8/tests/App.test.tsx b/examples/vite-8/tests/App.test.tsx index 498f770..a1f3b0a 100644 --- a/examples/vite-8/tests/App.test.tsx +++ b/examples/vite-8/tests/App.test.tsx @@ -18,4 +18,9 @@ test('App', async () => { const decrementButton = root.getByText('Decrement'); await decrementButton.click(); await expect.element(count).toHaveTextContent('Counter: 0'); + + const tsrxCard = root.getByTestId('tsrx-card'); + await expect.element(tsrxCard).toHaveTextContent('TSRX scoped styles'); + await expect.element(tsrxCard).toHaveAttribute('data-server-function', 'function'); + await expect.element(tsrxCard).toHaveStyle({ color: 'rgb(12, 34, 56)' }); }); diff --git a/examples/vite-8/vite.config.ts b/examples/vite-8/vite.config.ts index e0fb779..9c1f968 100644 --- a/examples/vite-8/vite.config.ts +++ b/examples/vite-8/vite.config.ts @@ -19,8 +19,11 @@ export default defineConfig({ } }, }, - // Rides the native compiler default. - solidPlugin({ ssr: true }), + solidPlugin({ + ssr: true, + serverFunctions: true, + compiler: process.env.SOLID_COMPILER === 'babel' ? 'babel' : 'native', + }), { name: 'assert-single-entry', enforce: 'post', diff --git a/src/dev-manifest.ts b/src/dev-manifest.ts index fb0a92f..5aa85a2 100644 --- a/src/dev-manifest.ts +++ b/src/dev-manifest.ts @@ -1,6 +1,7 @@ import path from 'path'; import type { DevEnvironment, EnvironmentModuleNode, ViteDevServer } from 'vite'; import { joinBase } from './http.js'; +import { isTsrxCssModule } from './tsrx.js'; /** * Dev-mode asset resolution: the `virtual:solid-manifest` module exports a @@ -155,6 +156,10 @@ const nonAmbientQueryRegExp = /[?&](url|inline|raw)\b/; const NULL_BYTE_PLACEHOLDER = '/@id/__x00__'; +function isCssModuleUrl(url: string): boolean { + return cssFileRegExp.test(url.split('?')[0]!) || isTsrxCssModule(url); +} + // Per Vite's convention virtual module ids are prefixed with `\0`, which // cannot appear in an HTML attribute (the parser replaces it). Serialize the // same placeholder form Vite's own URLs use. Adoption of virtual-module @@ -227,7 +232,7 @@ async function collectModuleDeps( if (!node?.id || deps.has(node)) return; deps.add(node); - const isCss = cssFileRegExp.test(node.url.split('?')[0]); + const isCss = isCssModuleUrl(node.url); if (!isCss && node.file && !node.id.startsWith('\0') && !filter(node.file)) return; if (node.file) onFile?.(node.file); if (isCss) return; @@ -267,8 +272,7 @@ export async function collectDevStyleSources( 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; + if (!isCssModuleUrl(node.url) || nonAmbientQueryRegExp.test(node.url)) continue; const id = wrapId(node.id); if (seen.has(id)) continue; seen.add(id); diff --git a/src/index.ts b/src/index.ts index 094a124..915227e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,17 @@ import { solidDiagnostics } from './diagnostics/index.js'; import { 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 { + cleanModuleId, + isTsrxCssModule, + isTsrxModule, + offsetSourceMapLine, + prependTsrxCssImport, + resolvedTsrxCssModuleId, + resolveTsrxCssModule, + tsrxCssSourceId, + updateTsrxCss, +} from './tsrx.js'; export { devStylePatch } from './dev-manifest.js'; export { serverFunctions }; @@ -26,7 +37,12 @@ export type { ServerFunctionsFilter } from './server-functions/index.js'; export type { StartOptions }; import path from 'path'; import type { FilterPattern, Plugin, ViteDevServer } from 'vite'; -import { createFilter, defaultClientConditions, defaultServerConditions } from 'vite'; +import { + createFilter, + defaultClientConditions, + defaultServerConditions, + transformWithOxc, +} from 'vite'; import { getEnvironmentConsumer, isRunnableEnvironment } from './environment.js'; import { crawlFrameworkPkgs } from 'vitefu'; @@ -317,7 +333,8 @@ export interface Options { hot?: boolean; /** * This registers additional extensions that should be processed by - * @solidjs/vite-plugin. + * @solidjs/vite-plugin. Experimental `.tsrx` is always registered as + * TypeScript TSRX and does not need to be listed here. * * @default undefined */ @@ -329,7 +346,8 @@ export interface Options { * 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. + * host them. For `.tsrx` only, native TSRX lowering runs first and the + * support pass receives the generated ordinary JavaScript. * * @default {} */ @@ -602,6 +620,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { let base = '/'; let clientOutDir: string | null = null; let solidPkgsConfig: Awaited>; + const tsrxCss = new Map(); // The client build's manifest, read back by SSR builds. In builder-mode // (single process, e.g. SolidStart's nitro plugin) the client build runs @@ -694,6 +713,48 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { return code + `\nexport const $$moduleUrl = ${JSON.stringify(relativeId)};\n`; } + function nativeTsrxCss(result: unknown): string { + const css = (result as { css?: unknown }).css; + return typeof css === 'string' ? css : ''; + } + + function babelTsrxCss(result: babel.BabelFileResult): string { + const css = (result.metadata as { css?: unknown } | undefined)?.css; + return typeof css === 'string' ? css : ''; + } + + async function compileTsrxCss(source: string, id: string): Promise { + const solidOptions = getSolidOptions(options, false, replaceDev, isTestMode); + if (options.compiler === 'babel') { + const babelUserOptions = await getBabelUserOptions(options, source, id, false); + const babelOptions = mergeAndConcat(babelUserOptions, { + root: projectRoot, + // Keep .tsrx: the Babel plugin uses it to select its TSRX parser. + filename: id, + sourceFileName: id, + ast: false, + code: false, + sourceMaps: false, + configFile: false, + babelrc: false, + parserOpts: { + plugins: ['jsx', 'decorators', 'typescript'], + }, + plugins: [[solid, solidOptions]], + }) as babel.TransformOptions; + const result = await babel.transformAsync(source, babelOptions); + return result ? babelTsrxCss(result) : ''; + } + + const compiler = await loadNativeCompiler(); + const result = await compiler.transformAsync(source, { + ...solidOptions, + filename: id, + sourceMap: false, + }); + return nativeTsrxCss(result); + } + const mainPlugin: Plugin = { name: 'solid', enforce: 'pre', @@ -789,6 +850,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { dedupe: nestedDeps, }, optimizeDeps: { + extensions: ['.tsrx'], include: [ ...nestedDeps, // Dev refresh wrappers import the solid-js/refresh runtime in @@ -811,7 +873,29 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { ], exclude: solidPkgsConfig.optimizeDeps.exclude, // Keep Solid TSX from injecting React's automatic runtime during scanning. - rolldownOptions: { transform: { jsx: { runtime: 'classic' as const } } }, + rolldownOptions: { + transform: { jsx: { runtime: 'classic' as const } }, + plugins: [ + { + name: 'solid:tsrx-dep-scan', + async transform(source: string, id: string) { + if (!isTsrxModule(id) || isTsrxCssModule(id)) return null; + const compiler = await loadNativeCompiler(); + const result = await compiler.transformAsync(source, { + ...getSolidOptions(options, false, replaceDev, isTestMode), + filename: cleanModuleId(id), + sourceMap: false, + }); + const stripped = await transformWithOxc(result.code, cleanModuleId(id) + '.tsx', { + lang: 'tsx', + sourcemap: false, + target: 'esnext', + }); + return { code: stripped.code, map: null }; + }, + }, + ], + }, }, ...(Object.keys(test).length ? { test } : {}), }; @@ -921,7 +1005,17 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { } as typeof hot.send; }, - hotUpdate({ modules }) { + async hotUpdate({ file, modules, read }) { + if (isTsrxModule(file) && this.environment.name === 'client') { + updateTsrxCss(tsrxCss, file, await compileTsrxCss(await read(), file)); + const cssModule = this.environment.moduleGraph.getModuleById(resolvedTsrxCssModuleId(file)); + if (cssModule) { + this.environment.moduleGraph.invalidateModule(cssModule); + if (!modules.includes(cssModule)) modules = [...modules, cssModule]; + return modules; + } + } + // 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 @@ -944,6 +1038,8 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { }, resolveId(id) { + const tsrxCssId = resolveTsrxCssModule(id); + if (tsrxCssId) return tsrxCssId; if (id === VIRTUAL_MANIFEST_ID) return RESOLVED_VIRTUAL_MANIFEST_ID; }, @@ -957,7 +1053,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)) continue; + if (!(/\.[mc]?[tj]sx?$/i.test(cleanId) || isTsrxModule(cleanId))) continue; if (emittedLazyChunks.has(depId)) continue; emittedLazyChunks.add(depId); emittedLazyChunkRefs.push( @@ -967,6 +1063,8 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { }, load(id) { + const tsrxSource = tsrxCssSourceId(id); + if (tsrxSource) return tsrxCss.get(tsrxSource) ?? ''; if (id === RESOLVED_VIRTUAL_MANIFEST_ID) { if (!isBuild) { return devManifestCode( @@ -1014,6 +1112,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { }, async transform(source, id, transformOptions) { + if (isTsrxCssModule(id)) return null; const isSsr = getEnvironmentConsumer(this.environment, transformOptions) === 'server'; const currentFileExtension = getExtension(id); @@ -1032,8 +1131,9 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // while the transform pipeline below works on the clean file path. const moduleId = id; id = id.replace(/\?.*$/, ''); + const isTsrx = isTsrxModule(id); - if (!(/\.[mc]?[tj]sx$/i.test(id) || allExtensions.includes(currentFileExtension))) { + if (!(/\.[mc]?[tj]sx$/i.test(id) || isTsrx || allExtensions.includes(currentFileExtension))) { return null; } @@ -1043,6 +1143,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // We need to know if the current file extension has a typescript options tied to it const shouldBeProcessedWithTypescript = /\.[mc]?tsx$/i.test(id) || + isTsrx || extensionsToWatch.some((extension) => { if (typeof extension === 'string') { return extension.includes('tsx'); @@ -1070,9 +1171,10 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // extension; custom extensions registered through `options.extensions` // are unknown to it, so borrow a standard one matching the configured // TypeScript-ness. - const nativeFilename = /\.(?:[mc]?[jt]s|[jt]sx)$/i.test(id) - ? id - : id + (shouldBeProcessedWithTypescript ? '.tsx' : '.jsx'); + const nativeFilename = + isTsrx || /\.(?:[mc]?[jt]s|[jt]sx)$/i.test(id) + ? id + : id + (shouldBeProcessedWithTypescript ? '.tsx' : '.jsx'); // Shared native prelude for every mode: the lazy() module-URL pass, // then (dev/client/non-node_modules) the solid-refresh HMR pass, both @@ -1083,6 +1185,111 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { let code = source; const maps: ChainableMap[] = []; + if (isTsrx) { + // Solid lowering preserves authored TypeScript annotations; secondary + // passes therefore parse the generated module as TSX even though no + // template syntax remains. + const generatedFilename = id + '.tsx'; + const babelBaseOptions: babel.TransformOptions = { + root: projectRoot, + filename: id, + sourceFileName: id, + ast: false, + sourceMaps: true, + configFile: false, + babelrc: false, + parserOpts: { + plugins, + }, + }; + let css = ''; + + if (options.compiler !== 'babel') { + const result = await compiler.transformAsync(code, { + ...solidOptions, + filename: id, + sourceMap: true, + }); + code = result.code || ''; + css = nativeTsrxCss(result); + maps.push(result.map); + + if (options.babel) { + // The support pass cannot parse authored TSRX. On this route it + // intentionally sees the lowered ordinary JavaScript instead. + const supportOptions = mergeAndConcat( + babelUserOptions, + babelBaseOptions, + ) as babel.TransformOptions; + // This pass sees native-lowered ordinary JavaScript, so do not + // route it back through Babel's TSRX parser. + supportOptions.filename = generatedFilename; + const supportResult = await babel.transformAsync(code, supportOptions); + if (!supportResult) return undefined; + code = supportResult.code || ''; + maps.push(supportResult.map); + } + } else { + const babelOptions = mergeAndConcat(babelUserOptions, { + ...babelBaseOptions, + plugins: [[solid, solidOptions]], + }) as babel.TransformOptions; + const result = await babel.transformAsync(code, babelOptions); + if (!result) return undefined; + code = result.code || ''; + css = babelTsrxCss(result); + maps.push(result.map); + } + + const lazyResult = await compiler.transformLazyAsync(code, { + filename: generatedFilename, + sourceMap: true, + }); + code = lazyResult.code; + maps.push(lazyResult.map); + + if (needRefresh) { + const refreshResult = await compiler.transformRefreshAsync(code, { + filename: generatedFilename, + bundler: 'vite', + fixRender: true, + ...(typeof options.refresh?.granular === 'boolean' + ? { granular: options.refresh.granular } + : {}), + jsx: false, + importSource: REFRESH_RUNTIME_SOURCE, + sourceMap: true, + }); + code = refreshResult.code; + maps.push(refreshResult.map); + } + + code = injectSsrModuleId(await resolveLazyModuleUrls(this, code, id), moduleId, !!isSsr); + let map = options.compiler === 'babel' ? combineSourcemaps(maps) : null; + updateTsrxCss(tsrxCss, id, css); + if (css) { + code = prependTsrxCssImport(code, id); + map = offsetSourceMapLine(map); + } + // Vite selects its TypeScript stripping by file extension. Since the + // real module identity remains `.tsrx`, strip the annotations here + // after Solid lowering instead of handing typed JavaScript to Rollup. + const stripped = await transformWithOxc( + code, + generatedFilename, + { + lang: 'tsx', + sourcemap: map != null, + target: 'esnext', + }, + map ?? undefined, + ); + return { + code: stripped.code, + map: map == null ? null : stripped.map, + }; + } + const lazyResult = await compiler.transformLazyAsync(code, { filename: nativeFilename, sourceMap: true, @@ -1179,24 +1386,31 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { }, }; - // The directive transform must run before the JSX transform (it operates - // on raw directives, and client-mode module-level extraction must happen - // before templates are generated), so its sub-plugins go first. The - // boundary markers (`server-only` / `client-only`) are always on. - const plugins: Plugin[] = options.serverFunctions - ? [ - boundaryModules(), - ...serverFunctions(options.serverFunctions === true ? {} : options.serverFunctions, { - devMiddleware: true, - externalDevServer, - // 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. - ...(startOptions ? { ssrHandler: SSR_HANDLER_ID } : {}), - }), - mainPlugin, - ] - : [boundaryModules(), mainPlugin]; + // Ordinary modules need the directive transform before JSX. Authored TSRX + // cannot be parsed by that standalone pass, so its companion compiler runs + // after mainPlugin has lowered the file to ordinary JavaScript while keeping + // the original .tsrx id for stable server-function hashes. + const serverFunctionPlugins = options.serverFunctions + ? serverFunctions(options.serverFunctions === true ? {} : options.serverFunctions, { + devMiddleware: true, + externalDevServer, + tsrxAfterSolid: true, + tsrxSourceMap: options.compiler === 'babel', + // 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. + ...(startOptions ? { ssrHandler: SSR_HANDLER_ID } : {}), + }) + : []; + const tsrxServerFunctionPlugin = serverFunctionPlugins.find( + (plugin) => plugin.name === 'solid:server-functions/tsrx-compiler', + ); + const plugins: Plugin[] = [ + boundaryModules(), + ...serverFunctionPlugins.filter((plugin) => plugin !== tsrxServerFunctionPlugin), + mainPlugin, + ...(tsrxServerFunctionPlugin ? [tsrxServerFunctionPlugin] : []), + ]; // The `start` option opts into start-mode serving on top of the transforms; // the `ssr` boolean picks the mode (a bare `ssr: true` keeps the diff --git a/src/server-functions/compile.ts b/src/server-functions/compile.ts index 15f0572..258d3ec 100644 --- a/src/server-functions/compile.ts +++ b/src/server-functions/compile.ts @@ -24,6 +24,8 @@ export interface CompileOptions { directive: string; /** Project root; function IDs hash the root-relative path. */ root: string; + /** Whether to emit a map for this pass. Defaults to true. */ + sourceMap?: boolean; definitions: { register: ImportDefinition; create: ImportDefinition; @@ -80,7 +82,7 @@ export async function compile( mode: options.mode, env: options.env, directive: options.directive, - sourceMap: true, + sourceMap: options.sourceMap !== false, register: options.definitions.register, create: options.definitions.create, }); diff --git a/src/server-functions/index.ts b/src/server-functions/index.ts index faeac2f..1677824 100644 --- a/src/server-functions/index.ts +++ b/src/server-functions/index.ts @@ -19,6 +19,7 @@ import { } from 'vite'; import { getEnvironmentConsumer, isRunnableEnvironment } from '../environment.js'; import { joinBase, sendWebResponse, webRequestFromNode } from '../http.js'; +import { isTsrxModule } from '../tsrx.js'; import { compile, type CompileOptions } from './compile.js'; import xxHash32 from './xxhash32.js'; @@ -28,7 +29,7 @@ import xxHash32 from './xxhash32.js'; * root — not the invocation directory — so running `vite` from outside the * project keeps compiling the same files. Absolute patterns are used as-is. * - * @default include "src/**\/*.{jsx,tsx,ts,js,mjs,cjs}", exclude "node_modules/**\/*.{jsx,tsx,ts,js,mjs,cjs}" + * @default include "src/**\/*.{jsx,tsx,tsrx,ts,js,mjs,cjs}", exclude "node_modules/**\/*.{jsx,tsx,tsrx,ts,js,mjs,cjs}" */ export interface ServerFunctionsFilter { include?: FilterPattern; @@ -155,8 +156,8 @@ export interface ServerFunctionsOptions { components?: boolean; } -const DEFAULT_INCLUDE = 'src/**/*.{jsx,tsx,ts,js,mjs,cjs}'; -const DEFAULT_EXCLUDE = 'node_modules/**/*.{jsx,tsx,ts,js,mjs,cjs}'; +const DEFAULT_INCLUDE = 'src/**/*.{jsx,tsx,tsrx,ts,js,mjs,cjs}'; +const DEFAULT_EXCLUDE = 'node_modules/**/*.{jsx,tsx,tsrx,ts,js,mjs,cjs}'; const DEFAULT_MANIFEST = 'virtual:solid-server-function-manifest'; const DEFAULT_DIRECTIVE = 'use server'; const DEFAULT_RUNTIME = '@solidjs/web/server-functions'; @@ -301,7 +302,13 @@ function invalidateModules( */ export function serverFunctions( options: ServerFunctionsOptions = {}, - internal: { devMiddleware?: boolean; externalDevServer?: boolean; ssrHandler?: string } = {}, + internal: { + devMiddleware?: boolean; + externalDevServer?: boolean; + ssrHandler?: string; + tsrxAfterSolid?: boolean; + tsrxSourceMap?: boolean; + } = {}, ): Plugin[] { const filterInclude = options.filter?.include || DEFAULT_INCLUDE; const filterExclude = options.filter?.exclude || DEFAULT_EXCLUDE; @@ -569,6 +576,63 @@ export function serverFunctions( }); } + async function transformModule( + ctx: any, + code: string, + fileId: string, + opts: unknown, + tsrx: boolean, + ) { + const mode = getEnvironmentConsumer(ctx.environment, opts); + const [id] = fileId.split('?'); + if (!id || !filter(id) || isTsrxModule(id) !== tsrx) return null; + + // The directive has to appear literally, so anything without the + // substring can skip the native parse entirely. + if (!code.includes(directive)) return null; + + const result = await compile(id, code, { + ...(mode === 'server' ? serverOptions : clientOptions), + mode, + env, + root, + sourceMap: !tsrx || !!internal.tsrxSourceMap, + }); + + if (!result.valid) return null; + + const preloader = preload[mode]; + if (preloader) preloader.defer(); + invalidateModules( + currentServer, + mergeManifestRecord(manifest.server, new Set([id])), + manifestId, + ); + + return { + // Appended (not prepended) so the source map for the compiled module + // stays valid; imports hoist and the endpoint is only read at call time. + code: (result.code || '') + endpointConfigureSnippet(mode), + map: result.map, + }; + } + + const compilerPlugin: Plugin = { + name: 'solid:server-functions/compiler', + enforce: 'pre', + transform(code, fileId, opts) { + return transformModule(this, code, fileId, opts, false); + }, + }; + + const tsrxCompilerPlugin: Plugin = { + name: 'solid:server-functions/tsrx-compiler', + enforce: 'pre', + transform(code, fileId, opts) { + return transformModule(this, code, fileId, opts, true); + }, + }; + return [ { name: 'solid:server-functions/setup', @@ -649,51 +713,8 @@ export function serverFunctions( return null; }, }, - { - name: 'solid:server-functions/compiler', - enforce: 'pre', - async transform(code, fileId, opts) { - const mode = getEnvironmentConsumer(this.environment, opts); - const [id] = fileId.split('?'); - if (!filter(id)) { - return null; - } - - // Fast path: the directive has to appear literally, so anything - // without the substring can skip the native parse entirely. - if (!code.includes(directive)) { - return null; - } - - const result = await compile(id!, code, { - ...(mode === 'server' ? serverOptions : clientOptions), - mode, - env, - root, - }); - - if (result.valid) { - const preloader = preload[mode]; - if (preloader) { - preloader.defer(); - } - invalidateModules( - currentServer, - mergeManifestRecord(manifest.server, new Set([id!])), - manifestId, - ); - - return { - // Appended (not prepended) so the source map for the compiled - // module stays valid; imports hoist and the endpoint is only - // read at call time, never during module evaluation. - code: (result.code || '') + endpointConfigureSnippet(mode), - map: result.map, - }; - } - return null; - }, - }, + compilerPlugin, + ...(internal.tsrxAfterSolid ? [tsrxCompilerPlugin] : []), ...startPlugins, ]; } diff --git a/src/ssr/index.ts b/src/ssr/index.ts index 14d700d..f8ba38f 100644 --- a/src/ssr/index.ts +++ b/src/ssr/index.ts @@ -83,7 +83,7 @@ export interface StartOptions { * Root component module for generated entries (the zero-config path). * Resolved relative to the Vite root. * - * @default "src/App.{tsx,jsx,ts,js}" (also probes lowercase "src/app.*") + * @default "src/App.{tsx,jsx,ts,js,tsrx}" (also probes lowercase "src/app.*") */ app?: string; /** Options for development CSS crawling. */ @@ -119,7 +119,7 @@ export interface StartOptions { * dev serving and the build-time prerender). Conventional * `src/entry-server.*` files are likewise ignored there. * - * @default "src/entry-server.{tsx,jsx,ts,js,mjs}" when present, else a + * @default "src/entry-server.{tsx,jsx,ts,js,mjs,tsrx}" when present, else a * generated entry rendering `` */ entryServer?: string; @@ -128,7 +128,7 @@ export interface StartOptions { * (a generated one calls `render()`), and it stands alone — no pairing * rule with a server entry. * - * @default "src/entry-client.{tsx,jsx,ts,js,mjs}" when present, else a + * @default "src/entry-client.{tsx,jsx,ts,js,mjs,tsrx}" when present, else a * generated entry */ entryClient?: string; @@ -140,7 +140,7 @@ export interface StartOptions { * Document costs nothing across the flip; the built-in shell omits it per * mode). Only used when the server entry is generated. * - * @default "src/Document.{tsx,jsx}" when present, else a built-in shell + * @default "src/Document.{tsx,jsx,tsrx}" when present, else a built-in shell */ document?: string; /** @@ -302,9 +302,9 @@ const MANIFEST_ID = 'virtual:solid-manifest'; const SERVER_FUNCTION_HANDLER_ID = 'virtual:solid-server-function-handler'; const STORAGE_SOURCE = '@solidjs/web/storage'; -const ENTRY_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js', '.mjs']; -const APP_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js']; -const DOCUMENT_EXTENSIONS = ['.tsx', '.jsx']; +const ENTRY_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js', '.mjs', '.tsrx']; +const APP_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js', '.tsrx']; +const DOCUMENT_EXTENSIONS = ['.tsx', '.jsx', '.tsrx']; function probe(root: string, stem: string, extensions: string[]): string | null { for (const ext of extensions) { diff --git a/src/tsrx.ts b/src/tsrx.ts new file mode 100644 index 0000000..a411cbf --- /dev/null +++ b/src/tsrx.ts @@ -0,0 +1,95 @@ +export const TSRX_CSS_QUERY = '?solid-tsrx-css&lang.css'; +const NULL_BYTE_PLACEHOLDER = '/@id/__x00__'; + +export function cleanModuleId(id: string): string { + const query = id.indexOf('?'); + return query === -1 ? id : id.slice(0, query); +} + +export function isTsrxModule(id: string): boolean { + return cleanModuleId(id).toLowerCase().endsWith('.tsrx'); +} + +export function isTsrxCssModule(id: string): boolean { + const unwrapped = id.startsWith('\0') + ? id.slice(1) + : id.startsWith(NULL_BYTE_PLACEHOLDER) + ? id.slice(NULL_BYTE_PLACEHOLDER.length) + : id; + const queryIndex = unwrapped.indexOf('?'); + if (queryIndex === -1 || !unwrapped.slice(0, queryIndex).toLowerCase().endsWith('.tsrx')) { + return false; + } + const params = unwrapped.slice(queryIndex + 1).split('&'); + return params.includes('solid-tsrx-css') && params.includes('lang.css'); +} + +export function resolveTsrxCssModule(id: string): string | null { + if (!isTsrxCssModule(id)) return null; + if (id.startsWith('\0')) return id; + if (id.startsWith(NULL_BYTE_PLACEHOLDER)) { + return '\0' + id.slice(NULL_BYTE_PLACEHOLDER.length); + } + return '\0' + id; +} + +export function tsrxCssModuleId(id: string): string { + return cleanModuleId(id) + TSRX_CSS_QUERY; +} + +export function resolvedTsrxCssModuleId(id: string): string { + return '\0' + tsrxCssModuleId(id); +} + +export function tsrxCssSourceId(id: string): string | null { + if (!id.startsWith('\0') || !isTsrxCssModule(id)) return null; + return cleanModuleId(id.slice(1)); +} + +export function updateTsrxCss( + cache: Map, + id: string, + css: string | null | undefined, +): void { + const key = cleanModuleId(id); + if (css) { + cache.set(key, css); + } else { + cache.delete(key); + } +} + +export function prependTsrxCssImport(code: string, id: string): string { + return `import ${JSON.stringify(tsrxCssModuleId(id))};\n${code}`; +} + +export function offsetSourceMapLine(map: T): T { + if ( + map && + typeof map === 'object' && + 'mappings' in map && + typeof (map as { mappings?: unknown }).mappings === 'string' + ) { + return { + ...map, + mappings: ';' + (map as { mappings: string }).mappings, + }; + } + if ( + map && + typeof map === 'object' && + 'sections' in map && + Array.isArray((map as { sections?: unknown }).sections) + ) { + return { + ...map, + sections: ( + map as { sections: Array<{ offset: { line: number; column: number } }> } + ).sections.map((section) => ({ + ...section, + offset: { ...section.offset, line: section.offset.line + 1 }, + })), + }; + } + return map; +}