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', ' /\[[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), + buildError || `file: ${userChunk}`, + ); + dotted = buildError ? [buildError] : 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' }); + 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}`, + ); + + // 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, + '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 +6177,7 @@ const ALL_MODES = [ 'builder-order', 'builder-prepare', 'extra-input', + 'file-names', 'frames', 'babel-hmr', 'external', @@ -5935,6 +6205,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..1918f0ba 100644 --- a/examples/start-ssr/vite.config.ts +++ b/examples/start-ssr/vite.config.ts @@ -65,6 +65,13 @@ 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 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. // - SOLID_PERF_TRACKS (perf-tracks mode) sets `performanceTracks`: `0` opts @@ -76,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: { @@ -134,6 +142,18 @@ export default defineConfig({ }, } : {}), + ...(process.env.SANITIZE_FILE_NAME === 'custom' || process.env.SANITIZE_FILE_NAME === 'off' + ? { + build: { + rollupOptions: { + output: { + sanitizeFileName: + process.env.SANITIZE_FILE_NAME === 'off' ? false : dotSanitizeFileName, + }, + }, + }, + } + : {}), ...(process.env.BUILD_SSR_FIRST ? { builder: { @@ -272,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 b752f409..d203c3fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1042,6 +1042,47 @@ 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 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-.`. 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) => { + 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[] { if (typeof options.ssr === 'object') { throw new Error( @@ -1877,6 +1918,29 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { } }, + 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) { if (!isBuild || !isClientBuild(this)) return; clientOutDir = outputOptions.dir ?? null;