diff --git a/.changeset/client-file-name-dot-runs.md b/.changeset/client-file-name-dot-runs.md
new file mode 100644
index 00000000..621f3d27
--- /dev/null
+++ b/.changeset/client-file-name-dot-runs.md
@@ -0,0 +1,5 @@
+---
+'@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 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/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..dc1d3141
--- /dev/null
+++ b/examples/start-ssr/src/routes/[...rest].tsx
@@ -0,0 +1,21 @@
+// 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';
+// 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
+
+
+ );
+}
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/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 a06cf87d..88cee040 100644
--- a/examples/start-ssr/test/run.mjs
+++ b/examples/start-ssr/test/run.mjs
@@ -124,6 +124,16 @@
// 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),
+// - 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; 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` 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),
@@ -141,7 +151,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';
@@ -160,6 +170,7 @@ import {
import http from 'node:http';
import os from 'node:os';
import {
+ build as viteBuild,
createServer,
createServerHotChannel,
createServerModuleRunner,
@@ -2464,6 +2475,264 @@ async function runExtraInputMode() {
}
}
+// 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 built file names,
+// after the default or the user's sanitizer. The default build must carry no
+// `..` 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` 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()} ===`);
+ const port = 3185;
+ 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 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 = '';
+ 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}`,
+ );
+ 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,
+ 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}`,
+ );
+ // 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', '