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', '