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 }) @{
+ <>
+
+
+ >
+}
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;
+}