Skip to content

Windows: prod builds silently drop dynamic routes (404) — route patterns leak NTFS-reserved characters into artifact filenames #718

Description

@YevheniiKotyrlo

Summary

On Windows, one build silently drops dynamic routes from the production buildone serve then returns a bare 404 Not Found for them. Which routes die depends on which generated artifact first hits a Windows-illegal filename character:

  • root-level dynamic routes (and route-group-rooted ones like (chat)/[serverId], since the group strips out of the URL) die on their preload asset name;
  • non-SSR dynamic routes at any depth die on their static HTML artifact (…/:param.html);
  • catch-all routes die the same way via *.

Routes that survive (nested +ssr dynamics) still write their preload assets into NTFS alternate data streams — invisible to directory listings, silently dropped by zip/upload/copy tooling, and absent on any non-NTFS deploy target.

Linux/macOS are unaffected (:/* are legal there — which is why CI is green), dev mode is unaffected (no asset files written). Reproduced identically on v1.17.10 and v1.18.0; this is long-standing, not a recent regression.

Your own suite catches it on Windows, in two packages: test-app-cases (dynamic-route-at-root-with-assets and catch-all-route-at-root-with-assets prod phases) and test-spa-shell-routing (8 prod "Route stability on page reload" failures — those reload /dashboard/[appId] and /thread/[id], which are exactly the dropped routes; dev phase is 27/27 green). It's been masked because turbo test without --continue cancels pending tasks at the first failure, and test-warm-routes used to fail first on Windows.

Root cause

cleanUrl() in packages/one/src/utils/cleanUrl.ts builds preload/loader asset filenames from the route's path pattern, escaping only _ and /:

function cleanUrl(path: string) {
  return removeSearch(path)
    .replace(/\/$/, '')
    .replaceAll('_', '__')
    .replaceAll('/', '_')
}

For dynamic routes without concrete params (every dynamic route that doesn't pre-render via generateStaticParams), the path is the Express-style pattern — /:param — so : flows into generated filesystem paths, and the static-HTML writer has the same issue with …/:param.html. On NTFS : is the alternate-data-stream separator (and * is a wildcard), with two distinct failure modes:

  1. Colon-led path segment → hard failure → route dropped. build.ts catches the fs error per page, warns, and continues (builtRoutes never gets the route, so it's missing from routeToBuildInfo/pathToRoute, and one serve 404s). The build still exits 0, so nothing fails loudly:
 [build] page /(authed)/dashboard/[appId].tsx (with 1 routes)
  ⚠ skipping page /dashboard/:appId: ENOENT: no such file or directory, open '…\dist\static\dashboard\:appId.html'
 [build] page /(chat)/[serverId]/index.tsx (with 1 routes)
  ⚠ skipping page /:serverId/: ENOENT: no such file or directory, open '…\dist\client\assets\:serverId_97792940_preload.js'
 [build] page /[...path]+ssr.tsx (with 1 routes)
  ⚠ skipping page /*: ENOENT: no such file or directory, open '…\dist\client\assets\*_94387799_preload.js'
  1. Colon mid-segment → NTFS alternate data stream → invisible asset. The write "succeeds", but the bytes land in a stream on a zero-byte stub file. Forensics from a real build (blog/[slug]+ssr.tsx):
PS> Get-Item dist/client/assets/blog_ -Stream *
FileName                          Stream                        Length
--------                          ------                        ------
…\dist\client\assets\blog_        :$DATA                             0
…\dist\client\assets\blog_        slug_22480461_preload.js         398
…\dist\client\assets\blog_        slug_22480461_preload_css.js    1712

One subtlety worth knowing: serving that dist from the same Windows machine still returns 200 for the stream-trapped URL (Node reads ADS back through the same colon path), so local smoke tests pass — the loss only shows up in listings, copies, and non-NTFS deploys.

Repro (Windows)

cd tests/test-app-cases
ONE_ROUTER_ROOT=app-cases/dynamic-route-at-root-with-assets/app bun run build:web
# build log: ⚠ skipping page /:path: ENOENT … open '…\assets\:path_…_preload.js'
node -e "console.log(Object.keys(require('./dist/buildInfo.json').routeToBuildInfo))"
# → [ './index.tsx' ]   — [path]+ssr.tsx is gone
bun run serve & curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/hello-world
# → 404

Control experiments: renaming the param ([slug]) at root → still dropped; the same file one level down (sub/[path]+ssr.tsx) → registered and serves (its preloads land in ADS as above); tests/test-spa-shell-routing → 4 routes dropped per the log excerpt above, and curl http://localhost:<port>/dashboard/test-app → 404 straight from one serve.

Fix direction

Encode all NTFS-reserved characters (: * ? " < > |, plus leading/trailing dots and spaces) wherever route-derived strings become filesystem names — cleanUrl() for preload/loader assets (write sites: cli/buildPage.ts urlPathToFilePath(...) joins), with the matching decode in getPathFromLoaderPath(), plus the static-HTML artifact path for non-prerendered dynamic routes. Note it's not only the :param/* pattern markers: concrete param values from generateStaticParams() flow through the same sink, so a CMS-supplied slug containing a reserved character trips the identical failure on fully-concrete SSG paths — sanitization should cover the whole generated name. Serve-side matching (PRELOAD_JS_POSTFIX_REGEX etc.) is postfix-based and keeps working. A regression test asserting Windows-legal artifact names for dynamic routes, catch-alls, and hostile param values would lock it in.

Happy to send a PR with the encode/decode pair + test if you'd take it.

Environment

  • one 1.17.10 and 1.18.0 (both reproduce), Windows 11, Bun 1.3.13, Node 25
  • Filesystem: NTFS (the :/ADS semantics are the trigger)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions