From c41d013176794f36a9070dd2234150739fef84e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sat, 3 Oct 2026 04:09:11 -0300 Subject: [PATCH 1/6] fix: keep dot runs out of client chunk and asset names A catch-all route module such as `[...404].tsx` built to `_...404_-.js`, with a `_..-.css` asset named after it. Hosts and middleware that reject any URL containing `..` refused both files, so the lazy route failed to hydrate. Client builds now wrap `output.sanitizeFileName` in an `outputOptions` hook: the user's sanitizer (or the bundler default) runs first, then every run of dots collapses to one. `sanitizeFileName: false` is left alone, and server output is unchanged. The start-ssr example gains a lazy `[...rest]` route and a file-names mode covering the default build, a user sanitizer and the opt-out. Fixes #391 --- .changeset/client-file-name-dot-runs.md | 5 + examples/start-ssr/src/App.tsx | 11 ++ examples/start-ssr/src/routes/[...rest].tsx | 12 ++ examples/start-ssr/src/routes/catch-all.css | 3 + examples/start-ssr/test/run.mjs | 157 +++++++++++++++++++- examples/start-ssr/vite.config.ts | 19 +++ src/index.ts | 51 +++++++ 7 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 .changeset/client-file-name-dot-runs.md create mode 100644 examples/start-ssr/src/routes/[...rest].tsx create mode 100644 examples/start-ssr/src/routes/catch-all.css diff --git a/.changeset/client-file-name-dot-runs.md b/.changeset/client-file-name-dot-runs.md new file mode 100644 index 00000000..a208b846 --- /dev/null +++ b/.changeset/client-file-name-dot-runs.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +Client chunk and asset file names no longer contain `..`. A catch-all route module such as `[...404].tsx` built to `_...404_-.js`, and hosts, CDNs or middleware that reject any URL containing `..` refused that chunk, so the lazy route failed to hydrate. The client build now runs the configured `output.sanitizeFileName` (or the bundler default) and then collapses every run of dots to one: the chunk becomes `_.404_-.js`. A custom `sanitizeFileName` still runs, with the collapse applied after it; `sanitizeFileName: false` is left alone. Server output is unchanged. Fixes #391. diff --git a/examples/start-ssr/src/App.tsx b/examples/start-ssr/src/App.tsx index c0a0742f..0510eecd 100644 --- a/examples/start-ssr/src/App.tsx +++ b/examples/start-ssr/src/App.tsx @@ -36,6 +36,9 @@ const LazyOutside = lazy(() => import('../../start-ssr-external/LazyOutside')); // imported module that is a genuine entry too, like a filesystem router's // `buildInputs` route modules. const LazyExtraInput = lazy(() => import('./ExtraInput')); +// A catch-all route module (`[...rest]`): its chunk and CSS asset names must +// not carry the `..` that traversal guards reject (#391). +const LazyCatchAll = lazy(() => import('./routes/[...rest]')); function LazyAssetsSection() { return ( @@ -120,6 +123,14 @@ export default function App() { ); } + // The catch-all route module, reached as a lazy route (#391). + if (pathname === '/catch-all') { + return ( + catch-all…

}> + +
+ ); + } const [count, setCount] = createSignal(0); const [message, setMessage] = createSignal(''); diff --git a/examples/start-ssr/src/routes/[...rest].tsx b/examples/start-ssr/src/routes/[...rest].tsx new file mode 100644 index 00000000..fa1ab63f --- /dev/null +++ b/examples/start-ssr/src/routes/[...rest].tsx @@ -0,0 +1,12 @@ +// A filesystem router's catch-all route module (`[...rest]`), reached as a +// lazy route on /catch-all. Bundlers derive the chunk name from the file +// name, and the default sanitizer only swaps the brackets, so this chunk +// (and the CSS asset named after it) used to build as `_...rest_-`. +// Hosts and middleware whose traversal guard rejects any URL containing +// `..` (server.js here does) then refused the chunk and the route failed to +// hydrate (#391). +import './catch-all.css'; + +export default function CatchAllRoute() { + return
CATCH-ALL-PAGE
; +} diff --git a/examples/start-ssr/src/routes/catch-all.css b/examples/start-ssr/src/routes/catch-all.css new file mode 100644 index 00000000..54f4430f --- /dev/null +++ b/examples/start-ssr/src/routes/catch-all.css @@ -0,0 +1,3 @@ +#catch-all { + color: rgb(10, 90, 10); +} diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index a06cf87d..87267619 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -124,6 +124,12 @@ // entry: the built handler boots the real entry chunk and links the entry // graph's stylesheet even though the extra input is an `isEntry` record // sorting ahead of it (#353), +// - client file names carry no `..` (file-names mode): the catch-all route +// module src/routes/[...rest].tsx builds to a chunk and a CSS asset whose +// names collapse the dot run, and server.js (which refuses any URL +// containing `..`) serves both; a user `sanitizeFileName` +// (SANITIZE_FILE_NAME=custom) still runs, with the collapse after it, and +// `sanitizeFileName: false` (SANITIZE_FILE_NAME=off) is left alone (#391), // - `start.node` (node mode, START_NODE=1): the build emits a ready-to-run // Node server entry, dist/server/node.js, beside server.js — statics // (immutable assets, must-revalidate otherwise, HEAD, no traversal), @@ -141,7 +147,7 @@ // // Requires the plugin built (pnpm build at the repo root) and Google Chrome. // Usage: node test/run.mjs -// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|render-mode|base|builder-order|builder-prepare|extra-input|babel-hmr|frames|external|observe|perf-tracks|detect|vitest|node] +// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|render-mode|base|builder-order|builder-prepare|extra-input|file-names|babel-hmr|frames|external|observe|perf-tracks|detect|vitest|node] // (default: all) import { spawn, execSync, execFileSync } from 'node:child_process'; @@ -2464,6 +2470,153 @@ async function runExtraInputMode() { } } +// Client file names (#391): a filesystem router's catch-all route module, +// src/routes/[...rest].tsx, builds to a chunk named after its file, and the +// bundler's default sanitizer only swaps the brackets: the chunk came out as +// `_...rest_-.js` and the CSS asset Vite names after it as +// `_..-.css`. server.js, like many hosts and middleware, refuses every +// URL containing `..`, so the lazy route's preload fell through to SSR and +// came back as HTML. The plugin now collapses dot runs in client file names, +// after the default or the user's sanitizer. The default build must carry no +// `..` anywhere under dist/client, keep the catch-all's chunk and CSS under +// the collapsed names (the CSS keeps its extension) and have server.js serve +// both. SANITIZE_FILE_NAME=custom rebuilds with a user `sanitizeFileName` +// the collapse composes with instead of replacing; SANITIZE_FILE_NAME=off +// rebuilds with `sanitizeFileName: false`, the opt-out the plugin leaves +// alone (raw names). +async function runFileNamesMode() { + const mode = 'file-names'; + console.log(`\n=== ${mode.toUpperCase()} ===`); + const port = 3184; + const origin = `http://localhost:${port}`; + const routeKey = 'src/routes/[...rest].tsx'; + const clientDir = path.join(exampleDir, 'dist/client'); + const build = (env) => { + rmSync(path.join(exampleDir, 'dist'), { recursive: true, force: true }); + execSync('pnpm run build', { cwd: exampleDir, stdio: 'pipe', env }); + return JSON.parse(readFileSync(path.join(clientDir, '.vite/manifest.json'), 'utf-8')); + }; + // Every path under dist/client (files and directories), slash-separated. + const dottedClientPaths = () => + readdirSync(clientDir, { recursive: true }) + .map((p) => p.split(path.sep).join('/')) + .filter((p) => p.includes('..')); + + let server; + let serverLog = ''; + try { + console.log(' building…'); + let manifest = build(process.env); + let dotted = dottedClientPaths(); + record(mode, 'build', 'no dist/client path contains ".."', !dotted.length, dotted.join(', ')); + const chunk = manifest[routeKey]?.file; + const css = manifest[routeKey]?.css?.[0]; + record( + mode, + 'build', + 'catch-all chunk emitted under the collapsed name (_.rest_-.js)', + !!chunk && + /^assets\/_\.rest_-[\w-]+\.js$/.test(chunk) && + existsSync(path.join(clientDir, chunk)), + `file: ${chunk}`, + ); + record( + mode, + 'build', + 'catch-all CSS asset emitted without ".." and keeps its .css extension', + !!css && !css.includes('..') && css.endsWith('.css') && existsSync(path.join(clientDir, css)), + `css: ${css}`, + ); + + server = startProcess('node', ['server.js'], { + cwd: exampleDir, + env: { ...process.env, PORT: String(port), NODE_ENV: 'production' }, + }); + server.stdout.on('data', (d) => (serverLog += d)); + server.stderr.on('data', (d) => (serverLog += d)); + await waitForHttp(origin + '/', 30000, { headers: { accept: 'text/html' } }); + const page = await fetchStreamed(origin + '/catch-all'); + record( + mode, + 'prod', + 'catch-all route SSRs and links its chunk and CSS', + page.status === 200 && + page.html.includes('CATCH-ALL-PAGE') && + !!chunk && + page.html.includes(`/${chunk}`) && + !!css && + page.html.includes(`/${css}`), + `status ${page.status}`, + ); + // server.js skips its static lookup for any URL containing `..`, so an + // undotted name is what lets the asset through instead of the SSR page. + for (const [name, file, type, marker] of [ + ['chunk', chunk, 'application/javascript', 'CATCH-ALL-PAGE'], + ['CSS', css, 'text/css', 'catch-all'], + ]) { + if (!file) { + record(mode, 'prod', `server.js serves the catch-all ${name}`, false, 'no manifest record'); + continue; + } + const res = await fetch(`${origin}/${file}`); + const body = await res.text(); + record( + mode, + 'prod', + `server.js serves the catch-all ${name}`, + res.status === 200 && res.headers.get('content-type') === type && body.includes(marker), + `GET /${file} → ${res.status} ${res.headers.get('content-type')}`, + ); + } + try { + process.kill(-server.pid, 'SIGTERM'); + } catch {} + server = null; + + console.log(' building with a user sanitizeFileName…'); + manifest = build({ ...process.env, SANITIZE_FILE_NAME: 'custom' }); + const customChunk = manifest[routeKey]?.file; + record( + mode, + 'custom', + 'user sanitizeFileName runs and the collapse follows it (~.rest~-.js)', + !!customChunk && /^assets\/~\.rest~-[\w-]+\.js$/.test(customChunk), + `file: ${customChunk}`, + ); + dotted = dottedClientPaths(); + record(mode, 'custom', 'no dist/client path contains ".."', !dotted.length, dotted.join(', ')); + + console.log(' building with sanitizeFileName: false…'); + manifest = build({ ...process.env, SANITIZE_FILE_NAME: 'off' }); + const rawChunk = manifest[routeKey]?.file; + record( + mode, + 'off', + 'sanitizeFileName: false is left alone (raw [...rest]-.js)', + !!rawChunk && /^assets\/\[\.\.\.rest\]-[\w-]+\.js$/.test(rawChunk), + `file: ${rawChunk}`, + ); + } catch (e) { + record( + mode, + 'run', + 'mode completed', + false, + String(e) + (serverLog ? `\nserver: ${serverLog.slice(-2000)}` : ''), + ); + } finally { + if (server) { + try { + process.kill(-server.pid, 'SIGTERM'); + } catch {} + } + // Leave dist in the standard state for anyone poking at it. + try { + execSync('pnpm run build', { cwd: exampleDir, stdio: 'pipe' }); + } catch {} + } +} + // Builder-mode preparation: BUILD_PRE_WIPE=1 installs a nitro-v3-shaped // host in vite.config.ts — a pre-order `buildApp` hook that rm -rf's dist // before anything builds (nitro's `nitro:prepare`) and a post-order @@ -5908,6 +6061,7 @@ const ALL_MODES = [ 'builder-order', 'builder-prepare', 'extra-input', + 'file-names', 'frames', 'babel-hmr', 'external', @@ -5935,6 +6089,7 @@ for (const mode of modes) { else if (mode === 'builder-order') await runBuilderOrderMode(); else if (mode === 'builder-prepare') await runBuilderPrepareMode(); else if (mode === 'extra-input') await runExtraInputMode(); + else if (mode === 'file-names') await runFileNamesMode(); else if (mode === 'frames') await runFramesMode(); else if (mode === 'babel-hmr') await runBabelHmrMode(); else if (mode === 'external') await runExternalMode(); diff --git a/examples/start-ssr/vite.config.ts b/examples/start-ssr/vite.config.ts index fa74b14d..6e867850 100644 --- a/examples/start-ssr/vite.config.ts +++ b/examples/start-ssr/vite.config.ts @@ -65,6 +65,11 @@ import solidPlugin from '@solidjs/vite-plugin'; // module App.tsx also lazily imports — as a further client build input, // the shape filesystem-routing's `buildInputs` produces for every route // module (#353). Vite merges the plugin's injected entry into this array. +// - SANITIZE_FILE_NAME (file-names mode) sets the build's +// `output.sanitizeFileName`: `custom` is a user function (unsafe +// characters become `~`, which the bundler default never produces) that +// the plugin's client dot-run collapse must compose with; `off` is +// `false`, the opt-out the plugin leaves alone (#391). // - START_NODE=1 (node mode) sets `start.node`: the build emits the // ready-to-run Node server entry dist/server/node.js beside server.js. // - SOLID_PERF_TRACKS (perf-tracks mode) sets `performanceTracks`: `0` opts @@ -134,6 +139,20 @@ export default defineConfig({ }, } : {}), + ...(process.env.SANITIZE_FILE_NAME + ? { + build: { + rollupOptions: { + output: { + sanitizeFileName: + process.env.SANITIZE_FILE_NAME === 'off' + ? false + : (name: string) => name.replace(/[^\w.-]/g, '~'), + }, + }, + }, + } + : {}), ...(process.env.BUILD_SSR_FIRST ? { builder: { diff --git a/src/index.ts b/src/index.ts index b752f409..3dd22cf7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1042,6 +1042,41 @@ function stampClientEntry( return ordered; } +// The bundler's default `output.sanitizeFileName`: characters outside the +// URL-safe set become `_`, except the `:` of a leading Windows drive letter. +// Rolldown ports Rollup's rule natively and exports no JS copy, so a +// sanitizer that runs the default first has to restate it. Keep in step with +// rollup/src/utils/sanitizeFileName.ts and +// rolldown/crates/rolldown_utils/src/sanitize_filename.rs. +const INVALID_FILE_NAME_CHARS = /[\u0000-\u001F"#$%&*+,:;<=>?[\]^`{|}\u007F]/g; +const WINDOWS_DRIVE_LETTER = /^[a-z]:/i; + +function defaultSanitizeFileName(name: string): string { + const driveLetter = WINDOWS_DRIVE_LETTER.exec(name)?.[0] ?? ''; + return driveLetter + name.slice(driveLetter.length).replace(INVALID_FILE_NAME_CHARS, '_'); +} + +/** + * The client build's `output.sanitizeFileName`: the user's sanitizer, or the + * default when none is set, then every run of dots collapsed to one. Chunk + * and asset names come from file names, and the default only swaps the + * brackets of a catch-all route module: `[...404].tsx` built to + * `_...404_-.js`, plus a CSS asset Vite names after that chunk. Hosts, + * CDNs and middleware whose traversal guard rejects any URL containing `..` + * refused the lazy route's chunk, and its hydration broke (#391). A run + * collapses to a single dot instead of being dropped because the bundler + * splits `[name]` and `[extname]` off the sanitized name: an asset whose own + * name has dots against its extension (`logo..png`) would otherwise lose it + * and build to `logopng-.`. + */ +function collapseDotRuns( + sanitizeFileName: true | ((name: string) => string) | undefined, +): (name: string) => string { + const sanitize = + typeof sanitizeFileName === 'function' ? sanitizeFileName : defaultSanitizeFileName; + return (name) => sanitize(name).replace(/\.{2,}/g, '.'); +} + export default function solidPlugin(options: Partial = {}): Plugin[] { if (typeof options.ssr === 'object') { throw new Error( @@ -1877,6 +1912,22 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { } }, + outputOptions(outputOptions) { + // Client builds only: their file names become URLs (see + // collapseDotRuns); server output is never fetched by URL. This hook + // sees the final output options, so it wraps whatever sanitizer the + // user or another plugin set, wherever it was configured. + // `sanitizeFileName: false` is left as is: it is the one spelling that + // asks for raw names, and wrapping it too would leave no way out. + if (!isBuild || !isClientBuild(this) || outputOptions.sanitizeFileName === false) { + return null; + } + return { + ...outputOptions, + sanitizeFileName: collapseDotRuns(outputOptions.sanitizeFileName), + }; + }, + generateBundle(outputOptions, bundle) { if (!isBuild || !isClientBuild(this)) return; clientOutDir = outputOptions.dir ?? null; From db0745b48f08cd3e593c0c3542a64db0a78a9e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sat, 3 Oct 2026 04:55:02 -0300 Subject: [PATCH 2/6] fix: collapse dot runs in server builds as well The collapse only ran for client builds, but the server bundle writes the URLs of the assets it imports, computed with its own sanitizer. An asset named with a dot run (`logo..png`) then built to `logo-.png` on the client while the server-rendered markup pointed at `logo.-.png`, a file nobody wrote, and hydration keeps the server's attribute. The wrapper now applies to every build environment, so both sides name assets the same way. Server chunk names collapse too. The catch-all route in start-ssr renders `mark..svg`, and the file-names mode checks that its server-rendered `src` names a file under dist/client that server.js serves, and that no path under dist/server contains `..`. --- .changeset/client-file-name-dot-runs.md | 2 +- examples/start-ssr/src/routes/[...rest].tsx | 11 +++- examples/start-ssr/src/routes/mark..svg | 3 + examples/start-ssr/test/run.mjs | 68 ++++++++++++++++----- src/index.ts | 33 +++++----- 5 files changed, 83 insertions(+), 34 deletions(-) create mode 100644 examples/start-ssr/src/routes/mark..svg diff --git a/.changeset/client-file-name-dot-runs.md b/.changeset/client-file-name-dot-runs.md index a208b846..afb15c6b 100644 --- a/.changeset/client-file-name-dot-runs.md +++ b/.changeset/client-file-name-dot-runs.md @@ -2,4 +2,4 @@ '@solidjs/vite-plugin': patch --- -Client chunk and asset file names no longer contain `..`. A catch-all route module such as `[...404].tsx` built to `_...404_-.js`, and hosts, CDNs or middleware that reject any URL containing `..` refused that chunk, so the lazy route failed to hydrate. The client build now runs the configured `output.sanitizeFileName` (or the bundler default) and then collapses every run of dots to one: the chunk becomes `_.404_-.js`. A custom `sanitizeFileName` still runs, with the collapse applied after it; `sanitizeFileName: false` is left alone. Server output is unchanged. Fixes #391. +Chunk and asset file names no longer contain `..`. A catch-all route module such as `[...404].tsx` built to `_...404_-.js`, and hosts, CDNs or middleware that reject any URL containing `..` refused that chunk, so the lazy route failed to hydrate. Builds now run the configured `output.sanitizeFileName` (or the bundler default) and then collapse every run of dots to one: the chunk becomes `_.404_-.js`. The server build is named the same way, so the asset URLs it writes into server-rendered markup keep pointing at the files the client build wrote. A custom `sanitizeFileName` still runs, with the collapse applied after it; `sanitizeFileName: false` is left alone. Fixes #391. diff --git a/examples/start-ssr/src/routes/[...rest].tsx b/examples/start-ssr/src/routes/[...rest].tsx index fa1ab63f..dc1d3141 100644 --- a/examples/start-ssr/src/routes/[...rest].tsx +++ b/examples/start-ssr/src/routes/[...rest].tsx @@ -6,7 +6,16 @@ // `..` (server.js here does) then refused the chunk and the route failed to // hydrate (#391). import './catch-all.css'; +// An asset whose own name has a dot run. The server bundle writes its URL +// into the SSR markup, so the server build has to name it exactly as the +// client build does. `?no-inline` keeps it a file at any size. +import markUrl from './mark..svg?no-inline'; export default function CatchAllRoute() { - return
CATCH-ALL-PAGE
; + return ( +
+ CATCH-ALL-PAGE + +
+ ); } diff --git a/examples/start-ssr/src/routes/mark..svg b/examples/start-ssr/src/routes/mark..svg new file mode 100644 index 00000000..68ebeb27 --- /dev/null +++ b/examples/start-ssr/src/routes/mark..svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index 87267619..76347ef7 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -124,10 +124,12 @@ // entry: the built handler boots the real entry chunk and links the entry // graph's stylesheet even though the extra input is an `isEntry` record // sorting ahead of it (#353), -// - client file names carry no `..` (file-names mode): the catch-all route +// - built file names carry no `..` (file-names mode): the catch-all route // module src/routes/[...rest].tsx builds to a chunk and a CSS asset whose // names collapse the dot run, and server.js (which refuses any URL -// containing `..`) serves both; a user `sanitizeFileName` +// containing `..`) serves both; the server build collapses too, so the +// URL it writes for an asset named with a dot run (mark..svg) is the +// file the client build wrote; a user `sanitizeFileName` // (SANITIZE_FILE_NAME=custom) still runs, with the collapse after it, and // `sanitizeFileName: false` (SANITIZE_FILE_NAME=off) is left alone (#391), // - `start.node` (node mode, START_NODE=1): the build emits a ready-to-run @@ -2470,20 +2472,22 @@ async function runExtraInputMode() { } } -// Client file names (#391): a filesystem router's catch-all route module, +// Built file names (#391): a filesystem router's catch-all route module, // src/routes/[...rest].tsx, builds to a chunk named after its file, and the // bundler's default sanitizer only swaps the brackets: the chunk came out as // `_...rest_-.js` and the CSS asset Vite names after it as // `_..-.css`. server.js, like many hosts and middleware, refuses every // URL containing `..`, so the lazy route's preload fell through to SSR and -// came back as HTML. The plugin now collapses dot runs in client file names, +// came back as HTML. The plugin now collapses dot runs in built file names, // after the default or the user's sanitizer. The default build must carry no -// `..` anywhere under dist/client, keep the catch-all's chunk and CSS under -// the collapsed names (the CSS keeps its extension) and have server.js serve -// both. SANITIZE_FILE_NAME=custom rebuilds with a user `sanitizeFileName` -// the collapse composes with instead of replacing; SANITIZE_FILE_NAME=off -// rebuilds with `sanitizeFileName: false`, the opt-out the plugin leaves -// alone (raw names). +// `..` anywhere under dist/client or dist/server, keep the catch-all's chunk +// and CSS under the collapsed names (the CSS keeps its extension) and have +// server.js serve both. The route also renders mark..svg, whose URL the +// server bundle writes itself: it must name a file under dist/client, which +// only holds while both builds collapse. SANITIZE_FILE_NAME=custom rebuilds +// with a user `sanitizeFileName` the collapse composes with instead of +// replacing; SANITIZE_FILE_NAME=off rebuilds with `sanitizeFileName: false`, +// the opt-out the plugin leaves alone (raw names). async function runFileNamesMode() { const mode = 'file-names'; console.log(`\n=== ${mode.toUpperCase()} ===`); @@ -2491,16 +2495,17 @@ async function runFileNamesMode() { const origin = `http://localhost:${port}`; const routeKey = 'src/routes/[...rest].tsx'; const clientDir = path.join(exampleDir, 'dist/client'); + const serverDir = path.join(exampleDir, 'dist/server'); const build = (env) => { rmSync(path.join(exampleDir, 'dist'), { recursive: true, force: true }); execSync('pnpm run build', { cwd: exampleDir, stdio: 'pipe', env }); return JSON.parse(readFileSync(path.join(clientDir, '.vite/manifest.json'), 'utf-8')); }; - // Every path under dist/client (files and directories), slash-separated. - const dottedClientPaths = () => - readdirSync(clientDir, { recursive: true }) - .map((p) => p.split(path.sep).join('/')) - .filter((p) => p.includes('..')); + // Every path under a dist directory (files and directories), + // slash-separated. + const distPaths = (dir) => + readdirSync(dir, { recursive: true }).map((p) => p.split(path.sep).join('/')); + const dottedClientPaths = () => distPaths(clientDir).filter((p) => p.includes('..')); let server; let serverLog = ''; @@ -2527,6 +2532,22 @@ async function runFileNamesMode() { !!css && !css.includes('..') && css.endsWith('.css') && existsSync(path.join(clientDir, css)), `css: ${css}`, ); + const dottedServer = distPaths(serverDir).filter((p) => p.includes('..')); + record( + mode, + 'build', + 'no dist/server path contains ".."', + !dottedServer.length, + dottedServer.join(', '), + ); + const serverChunks = distPaths(serverDir).filter((p) => /(^|\/)_.*rest_-[\w-]+\.js$/.test(p)); + record( + mode, + 'build', + 'server build names the catch-all chunk the same way (_.rest_-.js)', + serverChunks.length > 0 && serverChunks.every((p) => /(^|\/)_\.rest_-[\w-]+\.js$/.test(p)), + `server: ${serverChunks.join(', ')}`, + ); server = startProcess('node', ['server.js'], { cwd: exampleDir, @@ -2548,14 +2569,29 @@ async function runFileNamesMode() { page.html.includes(`/${css}`), `status ${page.status}`, ); + // The server bundle computes this URL with its own sanitizer: it names + // a file under dist/client only while both builds collapse dot runs. + const markTag = page.html.match(/]*\bid="catch-all-mark"[^>]*>/)?.[0]; + const markSrc = markTag?.match(/\bsrc="([^"]*)"/)?.[1]; + record( + mode, + 'prod', + 'SSR src of mark..svg names a file the client build wrote', + !!markSrc && + markSrc.startsWith('/assets/') && + !markSrc.includes('..') && + existsSync(path.join(clientDir, markSrc)), + `src: ${markSrc}; client assets: ${(manifest[routeKey]?.assets ?? []).join(', ')}`, + ); // server.js skips its static lookup for any URL containing `..`, so an // undotted name is what lets the asset through instead of the SSR page. for (const [name, file, type, marker] of [ ['chunk', chunk, 'application/javascript', 'CATCH-ALL-PAGE'], ['CSS', css, 'text/css', 'catch-all'], + ['mark..svg', markSrc?.slice(1), 'image/svg+xml', '.js`, plus a CSS asset Vite names after that chunk. Hosts, - * CDNs and middleware whose traversal guard rejects any URL containing `..` - * refused the lazy route's chunk, and its hydration broke (#391). A run - * collapses to a single dot instead of being dropped because the bundler - * splits `[name]` and `[extname]` off the sanitized name: an asset whose own - * name has dots against its extension (`logo..png`) would otherwise lose it - * and build to `logopng-.`. + * The build's `output.sanitizeFileName`: the user's sanitizer, or the default + * when none is set, then every run of dots collapsed to one. Chunk and asset + * names come from file names, and the default only swaps the brackets of a + * catch-all route module: `[...404].tsx` built to `_...404_-.js`, plus a + * CSS asset Vite names after that chunk. Hosts, CDNs and middleware whose + * traversal guard rejects any URL containing `..` refused the lazy route's + * chunk, and its hydration broke (#391). A run collapses to a single dot + * instead of being dropped because the bundler splits `[name]` and + * `[extname]` off the sanitized name: an asset whose own name has dots + * against its extension (`logo..png`) would otherwise lose it and build to + * `logopng-.`. */ function collapseDotRuns( sanitizeFileName: true | ((name: string) => string) | undefined, @@ -1913,15 +1913,16 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { }, outputOptions(outputOptions) { - // Client builds only: their file names become URLs (see - // collapseDotRuns); server output is never fetched by URL. This hook + // Every build environment, not just the client: client file names + // become URLs (see collapseDotRuns), and the server bundle writes the + // URLs of the assets it imports, computed with its own sanitizer. + // Collapsing on one side only would point server-rendered `src` and + // `href` attributes at files the client build never wrote. This hook // sees the final output options, so it wraps whatever sanitizer the // user or another plugin set, wherever it was configured. // `sanitizeFileName: false` is left as is: it is the one spelling that // asks for raw names, and wrapping it too would leave no way out. - if (!isBuild || !isClientBuild(this) || outputOptions.sanitizeFileName === false) { - return null; - } + if (!isBuild || outputOptions.sanitizeFileName === false) return null; return { ...outputOptions, sanitizeFileName: collapseDotRuns(outputOptions.sanitizeFileName), From 3d018394d21403499f5fbbbff9c04376160909f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sat, 3 Oct 2026 04:56:04 -0300 Subject: [PATCH 3/6] fix: wrap sanitizers set by later plugins' outputOptions hooks The main plugin is pre-enforced, so its `outputOptions` hook ran before every normal plugin's. A later plugin that set `sanitizeFileName` from its own hook replaced the wrapper and brought `..` back. The hook is now post-order: it runs after every pre and normal `outputOptions` hook and wraps whatever they set. A post hook further down the plugin array can still override it. The file-names mode now uses a user sanitizer that turns the brackets into dots, so `[...rest]` only loses its `..` when the collapse runs after the user function; the old `~` sanitizer passed in either order. A new SANITIZE_FILE_NAME=plugin variant sets the same function from a later plugin's `outputOptions` hook. --- .changeset/client-file-name-dot-runs.md | 2 +- examples/start-ssr/test/run.mjs | 49 ++++++++++++++++--------- examples/start-ssr/vite.config.ts | 30 +++++++++++---- src/index.ts | 36 ++++++++++-------- 4 files changed, 76 insertions(+), 41 deletions(-) diff --git a/.changeset/client-file-name-dot-runs.md b/.changeset/client-file-name-dot-runs.md index afb15c6b..6bd6431d 100644 --- a/.changeset/client-file-name-dot-runs.md +++ b/.changeset/client-file-name-dot-runs.md @@ -2,4 +2,4 @@ '@solidjs/vite-plugin': patch --- -Chunk and asset file names no longer contain `..`. A catch-all route module such as `[...404].tsx` built to `_...404_-.js`, and hosts, CDNs or middleware that reject any URL containing `..` refused that chunk, so the lazy route failed to hydrate. Builds now run the configured `output.sanitizeFileName` (or the bundler default) and then collapse every run of dots to one: the chunk becomes `_.404_-.js`. The server build is named the same way, so the asset URLs it writes into server-rendered markup keep pointing at the files the client build wrote. A custom `sanitizeFileName` still runs, with the collapse applied after it; `sanitizeFileName: false` is left alone. Fixes #391. +Chunk and asset file names no longer contain `..`. A catch-all route module such as `[...404].tsx` built to `_...404_-.js`, and hosts, CDNs or middleware that reject any URL containing `..` refused that chunk, so the lazy route failed to hydrate. Builds now run the configured `output.sanitizeFileName` (or the bundler default) and then collapse every run of dots to one: the chunk becomes `_.404_-.js`. The server build is named the same way, so the asset URLs it writes into server-rendered markup keep pointing at the files the client build wrote. A custom `sanitizeFileName` still runs, with the collapse applied after it, whether it comes from the config or from another plugin's `outputOptions` hook; `sanitizeFileName: false` is left alone. Fixes #391. diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index 76347ef7..c0ce1ec6 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -129,9 +129,11 @@ // names collapse the dot run, and server.js (which refuses any URL // containing `..`) serves both; the server build collapses too, so the // URL it writes for an asset named with a dot run (mark..svg) is the -// file the client build wrote; a user `sanitizeFileName` -// (SANITIZE_FILE_NAME=custom) still runs, with the collapse after it, and -// `sanitizeFileName: false` (SANITIZE_FILE_NAME=off) is left alone (#391), +// file the client build wrote; a user `sanitizeFileName` that produces +// dots still runs, with the collapse after it, whether set in the config +// (SANITIZE_FILE_NAME=custom) or from a later plugin's `outputOptions` +// hook (SANITIZE_FILE_NAME=plugin), and `sanitizeFileName: false` +// (SANITIZE_FILE_NAME=off) is left alone (#391), // - `start.node` (node mode, START_NODE=1): the build emits a ready-to-run // Node server entry, dist/server/node.js, beside server.js — statics // (immutable assets, must-revalidate otherwise, HEAD, no traversal), @@ -2485,9 +2487,12 @@ async function runExtraInputMode() { // server.js serve both. The route also renders mark..svg, whose URL the // server bundle writes itself: it must name a file under dist/client, which // only holds while both builds collapse. SANITIZE_FILE_NAME=custom rebuilds -// with a user `sanitizeFileName` the collapse composes with instead of -// replacing; SANITIZE_FILE_NAME=off rebuilds with `sanitizeFileName: false`, -// the opt-out the plugin leaves alone (raw names). +// with a user `sanitizeFileName` that turns the brackets into dots, so only +// a collapse that runs after it leaves the name free of `..`; +// SANITIZE_FILE_NAME=plugin sets that function from a later plugin's +// `outputOptions` hook instead of the config; SANITIZE_FILE_NAME=off rebuilds +// with `sanitizeFileName: false`, the opt-out the plugin leaves alone (raw +// names). async function runFileNamesMode() { const mode = 'file-names'; console.log(`\n=== ${mode.toUpperCase()} ===`); @@ -2609,18 +2614,26 @@ async function runFileNamesMode() { } catch {} server = null; - console.log(' building with a user sanitizeFileName…'); - manifest = build({ ...process.env, SANITIZE_FILE_NAME: 'custom' }); - const customChunk = manifest[routeKey]?.file; - record( - mode, - 'custom', - 'user sanitizeFileName runs and the collapse follows it (~.rest~-.js)', - !!customChunk && /^assets\/~\.rest~-[\w-]+\.js$/.test(customChunk), - `file: ${customChunk}`, - ); - dotted = dottedClientPaths(); - record(mode, 'custom', 'no dist/client path contains ".."', !dotted.length, dotted.join(', ')); + // The user function turns `[...rest]` into `....rest.`: collapsing after + // it gives `.rest.`, collapsing before it would leave `..rest.`, and the + // default sanitizer alone would give `_.rest_`. + for (const [variant, label] of [ + ['custom', 'set in the config'], + ['plugin', "set from a later plugin's outputOptions hook"], + ]) { + console.log(` building with a user sanitizeFileName ${label}…`); + manifest = build({ ...process.env, SANITIZE_FILE_NAME: variant }); + const userChunk = manifest[routeKey]?.file; + record( + mode, + variant, + 'user sanitizeFileName runs and the collapse follows it (.rest.-.js)', + !!userChunk && /^assets\/\.rest\.-[\w-]+\.js$/.test(userChunk), + `file: ${userChunk}`, + ); + dotted = dottedClientPaths(); + record(mode, variant, 'no dist/client path contains ".."', !dotted.length, dotted.join(', ')); + } console.log(' building with sanitizeFileName: false…'); manifest = build({ ...process.env, SANITIZE_FILE_NAME: 'off' }); diff --git a/examples/start-ssr/vite.config.ts b/examples/start-ssr/vite.config.ts index 6e867850..1918f0ba 100644 --- a/examples/start-ssr/vite.config.ts +++ b/examples/start-ssr/vite.config.ts @@ -66,9 +66,11 @@ import solidPlugin from '@solidjs/vite-plugin'; // the shape filesystem-routing's `buildInputs` produces for every route // module (#353). Vite merges the plugin's injected entry into this array. // - SANITIZE_FILE_NAME (file-names mode) sets the build's -// `output.sanitizeFileName`: `custom` is a user function (unsafe -// characters become `~`, which the bundler default never produces) that -// the plugin's client dot-run collapse must compose with; `off` is +// `output.sanitizeFileName`: `custom` is a user function that turns every +// character outside `[\w-]` into a dot, so its own output has dot runs +// and only a collapse that runs after it leaves none; `plugin` sets the +// same function from a later, normal-order plugin's `outputOptions` hook, +// which the solid plugin's post-order hook must still wrap; `off` is // `false`, the opt-out the plugin leaves alone (#391). // - START_NODE=1 (node mode) sets `start.node`: the build emits the // ready-to-run Node server entry dist/server/node.js beside server.js. @@ -81,6 +83,7 @@ import solidPlugin from '@solidjs/vite-plugin'; const jsxCompiler = process.env.SOLID_JSX_COMPILER === 'babel' ? ('babel' as const) : ('native' as const); const serverComponents = !!process.env.SOLID_SERVER_COMPONENTS; +const dotSanitizeFileName = (name: string) => name.replace(/[^\w-]/g, '.'); export default defineConfig({ future: { @@ -139,15 +142,13 @@ export default defineConfig({ }, } : {}), - ...(process.env.SANITIZE_FILE_NAME + ...(process.env.SANITIZE_FILE_NAME === 'custom' || process.env.SANITIZE_FILE_NAME === 'off' ? { build: { rollupOptions: { output: { sanitizeFileName: - process.env.SANITIZE_FILE_NAME === 'off' - ? false - : (name: string) => name.replace(/[^\w.-]/g, '~'), + process.env.SANITIZE_FILE_NAME === 'off' ? false : dotSanitizeFileName, }, }, }, @@ -291,5 +292,20 @@ export default defineConfig({ }, ] satisfies Plugin[]) : []), + // SANITIZE_FILE_NAME=plugin (file-names mode): a normal-order plugin + // after the solid plugin sets the sanitizer from its own `outputOptions` + // hook, the spelling that replaced the solid plugin's wrapper while that + // hook ran in plugin order (#391). + ...(process.env.SANITIZE_FILE_NAME === 'plugin' + ? ([ + { + name: 'test:sanitize-file-name', + apply: 'build', + outputOptions(outputOptions) { + return { ...outputOptions, sanitizeFileName: dotSanitizeFileName }; + }, + }, + ] satisfies Plugin[]) + : []), ], }); diff --git a/src/index.ts b/src/index.ts index d7a35e7e..66891862 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1912,21 +1912,27 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { } }, - outputOptions(outputOptions) { - // Every build environment, not just the client: client file names - // become URLs (see collapseDotRuns), and the server bundle writes the - // URLs of the assets it imports, computed with its own sanitizer. - // Collapsing on one side only would point server-rendered `src` and - // `href` attributes at files the client build never wrote. This hook - // sees the final output options, so it wraps whatever sanitizer the - // user or another plugin set, wherever it was configured. - // `sanitizeFileName: false` is left as is: it is the one spelling that - // asks for raw names, and wrapping it too would leave no way out. - if (!isBuild || outputOptions.sanitizeFileName === false) return null; - return { - ...outputOptions, - sanitizeFileName: collapseDotRuns(outputOptions.sanitizeFileName), - }; + outputOptions: { + // Post order: this runs after every pre and normal `outputOptions` + // hook (and after post hooks earlier in the plugin array), so it wraps + // the sanitizer from the config or from those hooks instead of being + // replaced by a later one. A post hook further down can still override. + order: 'post', + handler(outputOptions) { + // Every build environment, not just the client: client file names + // become URLs (see collapseDotRuns), and the server bundle writes the + // URLs of the assets it imports, computed with its own sanitizer. + // Collapsing on one side only would point server-rendered `src` and + // `href` attributes at files the client build never wrote. + // `sanitizeFileName: false` is left as is: it is the one spelling + // that asks for raw names, and wrapping it too would leave no way + // out. + if (!isBuild || outputOptions.sanitizeFileName === false) return null; + return { + ...outputOptions, + sanitizeFileName: collapseDotRuns(outputOptions.sanitizeFileName), + }; + }, }, generateBundle(outputOptions, bundle) { From 444a2866b93502318354c16230e52dd5eac5eb6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sat, 3 Oct 2026 05:03:22 -0300 Subject: [PATCH 4/6] test: record sanitizer variant build failures instead of aborting Rolldown refuses a `[name]` substitution that starts with `..`, since it reads as a relative path. When the dots a user sanitizer produces are not collapsed (no wrapper on that build, or the collapse running before the user function), the custom and plugin builds fail outright, and the exception ended the file-names mode before the remaining variants ran. Each variant now records the build error as its failure and the mode moves on. --- examples/start-ssr/test/run.mjs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index c0ce1ec6..da4b70f1 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -2616,22 +2616,35 @@ async function runFileNamesMode() { // The user function turns `[...rest]` into `....rest.`: collapsing after // it gives `.rest.`, collapsing before it would leave `..rest.`, and the - // default sanitizer alone would give `_.rest_`. + // default sanitizer alone would give `_.rest_`. Rolldown refuses a + // `[name]` that starts with `..` (it reads as a relative path), so an + // uncollapsed run fails the build outright: record that and move on to + // the next variant. for (const [variant, label] of [ ['custom', 'set in the config'], ['plugin', "set from a later plugin's outputOptions hook"], ]) { console.log(` building with a user sanitizeFileName ${label}…`); - manifest = build({ ...process.env, SANITIZE_FILE_NAME: variant }); - const userChunk = manifest[routeKey]?.file; + let userChunk; + let buildError = ''; + try { + manifest = build({ ...process.env, SANITIZE_FILE_NAME: variant }); + userChunk = manifest[routeKey]?.file; + } catch (e) { + const lines = String(e.stderr || e.message) + .replace(/\x1b\[[0-9;]*m/g, '') + .split('\n'); + buildError = + 'build failed: ' + (lines.find((l) => /\[[A-Z_]+\]/.test(l)) ?? lines[0]).trim(); + } record( mode, variant, 'user sanitizeFileName runs and the collapse follows it (.rest.-.js)', !!userChunk && /^assets\/\.rest\.-[\w-]+\.js$/.test(userChunk), - `file: ${userChunk}`, + buildError || `file: ${userChunk}`, ); - dotted = dottedClientPaths(); + dotted = buildError ? [buildError] : dottedClientPaths(); record(mode, variant, 'no dist/client path contains ".."', !dotted.length, dotted.join(', ')); } From afdbcb89aff7385ea353ceb9fe75dda8a0ff83fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sat, 3 Oct 2026 05:22:21 -0300 Subject: [PATCH 5/6] fix: collapse dot runs only in the last segment of a file name With preserveModules the name carries the module's directories, including `../` segments, and collapsing those makes the bundler reject the name. --- .changeset/client-file-name-dot-runs.md | 2 +- src/index.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.changeset/client-file-name-dot-runs.md b/.changeset/client-file-name-dot-runs.md index 6bd6431d..6f546d43 100644 --- a/.changeset/client-file-name-dot-runs.md +++ b/.changeset/client-file-name-dot-runs.md @@ -2,4 +2,4 @@ '@solidjs/vite-plugin': patch --- -Chunk and asset file names no longer contain `..`. A catch-all route module such as `[...404].tsx` built to `_...404_-.js`, and hosts, CDNs or middleware that reject any URL containing `..` refused that chunk, so the lazy route failed to hydrate. Builds now run the configured `output.sanitizeFileName` (or the bundler default) and then collapse every run of dots to one: the chunk becomes `_.404_-.js`. The server build is named the same way, so the asset URLs it writes into server-rendered markup keep pointing at the files the client build wrote. A custom `sanitizeFileName` still runs, with the collapse applied after it, whether it comes from the config or from another plugin's `outputOptions` hook; `sanitizeFileName: false` is left alone. Fixes #391. +Chunk and asset names derived from file names no longer carry runs of dots. A catch-all route module such as `[...404].tsx` built to `_...404_-.js`, and hosts, CDNs or middleware that reject any URL containing `..` refused that chunk, so the lazy route failed to hydrate. Builds now run the configured `output.sanitizeFileName` (or the bundler default) and then collapse every run of dots to one: the chunk becomes `_.404_-.js`. The server build is named the same way, so the asset URLs it writes into server-rendered markup keep pointing at the files the client build wrote. A custom `sanitizeFileName` still runs, with the collapse applied after it, whether it comes from the config or from another plugin's `outputOptions` hook; `sanitizeFileName: false` is left alone. Fixes #391. diff --git a/src/index.ts b/src/index.ts index 66891862..d203c3fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1067,14 +1067,20 @@ function defaultSanitizeFileName(name: string): string { * instead of being dropped because the bundler splits `[name]` and * `[extname]` off the sanitized name: an asset whose own name has dots * against its extension (`logo..png`) would otherwise lose it and build to - * `logopng-.`. + * `logopng-.`. Only the last path segment is touched: with + * `preserveModules` the name carries the module's directories, and changing a + * directory such as `my..lib` makes the bundler reject the name. */ function collapseDotRuns( sanitizeFileName: true | ((name: string) => string) | undefined, ): (name: string) => string { const sanitize = typeof sanitizeFileName === 'function' ? sanitizeFileName : defaultSanitizeFileName; - return (name) => sanitize(name).replace(/\.{2,}/g, '.'); + return (name) => { + const sanitized = sanitize(name); + const base = Math.max(sanitized.lastIndexOf('/'), sanitized.lastIndexOf('\\')) + 1; + return sanitized.slice(0, base) + sanitized.slice(base).replace(/\.{2,}/g, '.'); + }; } export default function solidPlugin(options: Partial = {}): Plugin[] { From 13a51f73cb0d8b07db075d884ec52b16f0230bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sat, 3 Oct 2026 05:39:39 -0300 Subject: [PATCH 6/6] test: cover a preserveModules build from a directory with a dot run Also moves the file-names mode to port 3185 and says in the changeset that only the last segment of a name is collapsed. --- .changeset/client-file-name-dot-runs.md | 2 +- examples/start-ssr/test/run.mjs | 56 ++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/.changeset/client-file-name-dot-runs.md b/.changeset/client-file-name-dot-runs.md index 6f546d43..621f3d27 100644 --- a/.changeset/client-file-name-dot-runs.md +++ b/.changeset/client-file-name-dot-runs.md @@ -2,4 +2,4 @@ '@solidjs/vite-plugin': patch --- -Chunk and asset names derived from file names no longer carry runs of dots. A catch-all route module such as `[...404].tsx` built to `_...404_-.js`, and hosts, CDNs or middleware that reject any URL containing `..` refused that chunk, so the lazy route failed to hydrate. Builds now run the configured `output.sanitizeFileName` (or the bundler default) and then collapse every run of dots to one: the chunk becomes `_.404_-.js`. The server build is named the same way, so the asset URLs it writes into server-rendered markup keep pointing at the files the client build wrote. A custom `sanitizeFileName` still runs, with the collapse applied after it, whether it comes from the config or from another plugin's `outputOptions` hook; `sanitizeFileName: false` is left alone. Fixes #391. +Chunk and asset names derived from file names no longer carry runs of dots. A catch-all route module such as `[...404].tsx` built to `_...404_-.js`, and hosts, CDNs or middleware that reject any URL containing `..` refused that chunk, so the lazy route failed to hydrate. Builds now run the configured `output.sanitizeFileName` (or the bundler default) and then collapse every run of dots in the last segment of the name to one: the chunk becomes `_.404_-.js`. Directories are left alone: with `preserveModules` they are part of the name. The server build is named the same way, so the asset URLs it writes into server-rendered markup keep pointing at the files the client build wrote. A custom `sanitizeFileName` still runs, with the collapse applied after it, whether it comes from the config or from another plugin's `outputOptions` hook; `sanitizeFileName: false` is left alone. Fixes #391. diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index da4b70f1..88cee040 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -170,6 +170,7 @@ import { import http from 'node:http'; import os from 'node:os'; import { + build as viteBuild, createServer, createServerHotChannel, createServerModuleRunner, @@ -2496,7 +2497,7 @@ async function runExtraInputMode() { async function runFileNamesMode() { const mode = 'file-names'; console.log(`\n=== ${mode.toUpperCase()} ===`); - const port = 3184; + const port = 3185; const origin = `http://localhost:${port}`; const routeKey = 'src/routes/[...rest].tsx'; const clientDir = path.join(exampleDir, 'dist/client'); @@ -2658,6 +2659,59 @@ async function runFileNamesMode() { !!rawChunk && /^assets\/\[\.\.\.rest\]-[\w-]+\.js$/.test(rawChunk), `file: ${rawChunk}`, ); + + // preserveModules keeps each module's path in its name, `../` segments + // included, so the collapse only touches the last segment: a library + // built from a directory whose path has a dot run is rejected by the + // bundler if the directories change. + const dotLib = path.join(exampleDir, 'test-dot..lib'); + rmSync(dotLib, { recursive: true, force: true }); + mkdirSync(path.join(dotLib, 'src/card'), { recursive: true }); + writeFileSync( + path.join(dotLib, 'src/index.tsx'), + "export { Button } from './Button';\nexport { Card } from './card/Card';\n", + ); + writeFileSync( + path.join(dotLib, 'src/Button.tsx'), + 'export function Button(props) {\n return ;\n}\n', + ); + writeFileSync( + path.join(dotLib, 'src/card/Card.tsx'), + 'export function Card(props) {\n return
{props.title}
;\n}\n', + ); + let libFiles = []; + let libError = ''; + try { + const { default: solid } = await import('@solidjs/vite-plugin'); + await viteBuild({ + configFile: false, + logLevel: 'silent', + root: dotLib, + plugins: [solid()], + build: { + outDir: 'out', + minify: false, + lib: { entry: 'src/index.tsx', formats: ['es'] }, + rolldownOptions: { + external: [/^solid-js/, /^@solidjs\/web/], + output: { preserveModules: true }, + }, + }, + }); + libFiles = distPaths(path.join(dotLib, 'out')).sort(); + } catch (e) { + libError = String(e && e.message ? e.message : e).replace(/\x1b\[[0-9;]*m/g, ''); + } finally { + rmSync(dotLib, { recursive: true, force: true }); + } + record( + mode, + 'preserve-modules', + 'a preserveModules build from a directory with a dot run keeps module paths', + // Lib mode names the files after the package; one per module. + !libError && libFiles.filter((f) => f.endsWith('.js')).length === 3, + libError.slice(0, 300) || libFiles.join(', '), + ); } catch (e) { record( mode,