Summary
Apps configuring the built-in static plugin with a root mount (urlPath: '/') serve only the exact path / — every sub-path (/index.html, /assets/*, …) bypasses the static handler and falls through to the REST router's 404. Reported from production instances after auto-upgrade to 5.1.18 from 5.0.x, where this config worked; it broke every deployed app using root-mounted static assets with an explicit urlPath: '/'.
Sibling of #1583 / #1730: #1584 fixed the subpath mounts (urlPath: 'app', '/app') but not the root mount.
Root cause
server/middlewareChain.ts:
normalizeUrlPath('/') returns '/' (truthy), so a root-mounted handler is grouped as a sub-route in resolveRoutedChains instead of joining the default chain.
matchesRoute prefix-matches with a segment boundary:
if (pathname !== urlPath && !pathname.startsWith(urlPath + '/')) return false;
For urlPath === '/' the boundary check builds '//'. No real pathname starts with '//', so only the exact request / matches; all sub-paths skip the chain.
- Latent second defect: if a
'/' route did match a sub-path, stripPrefix(request, '/') would slice one character off the pathname ('/index.html' → 'index.html'), producing a slash-less path.
The mount value comes from the Scope server proxy (components/Scope.ts): urlPath: scopeConfig.urlPath ? resolveBaseURLPath(pluginName, scopeConfig.urlPath) : undefined, and resolveBaseURLPath(name, '/') → '/'.
Affected versions
Broken since v5.1.0 (9dcf7b4, "Add urlPath/host routing with per-route middleware chains" — the Scope proxy began passing the configured urlPath as a route constraint). Present on main and the v5.1 line including 5.1.18. Worked in 5.0.x, where static registered its handler unconditionally and matched the full request path against its own maps. Not covered by #1584's integration tests (subpath mount only).
Repro
static:
files: 'web/**'
urlPath: '/'
GET / → 200 (index)
GET /index.html → 404
GET /<anything> → 404
Removing urlPath (unset) works — the breakage is specifically the explicit root value.
Fix direction
normalizeUrlPath should treat the root mount as "no path constraint" (return undefined for '/'): a '/' mount constrains nothing, so root-mounted handlers join the default chain — restoring 5.0.x semantics — and no prefix strip occurs (which also neutralizes the stripPrefix slice hazard, since normalizeUrlPath(prefix) ?? '' becomes ''). A host-constrained root mount ({ host, urlPath: '/' }) degrades correctly to a host-only sub-route.
Summary
Apps configuring the built-in
staticplugin with a root mount (urlPath: '/') serve only the exact path/— every sub-path (/index.html,/assets/*, …) bypasses the static handler and falls through to the REST router's 404. Reported from production instances after auto-upgrade to 5.1.18 from 5.0.x, where this config worked; it broke every deployed app using root-mounted static assets with an expliciturlPath: '/'.Sibling of #1583 / #1730: #1584 fixed the subpath mounts (
urlPath: 'app','/app') but not the root mount.Root cause
server/middlewareChain.ts:normalizeUrlPath('/')returns'/'(truthy), so a root-mounted handler is grouped as a sub-route inresolveRoutedChainsinstead of joining the default chain.matchesRouteprefix-matches with a segment boundary:urlPath === '/'the boundary check builds'//'. No real pathname starts with'//', so only the exact request/matches; all sub-paths skip the chain.'/'route did match a sub-path,stripPrefix(request, '/')would slice one character off the pathname ('/index.html'→'index.html'), producing a slash-less path.The mount value comes from the Scope server proxy (
components/Scope.ts):urlPath: scopeConfig.urlPath ? resolveBaseURLPath(pluginName, scopeConfig.urlPath) : undefined, andresolveBaseURLPath(name, '/')→'/'.Affected versions
Broken since v5.1.0 (9dcf7b4, "Add urlPath/host routing with per-route middleware chains" — the Scope proxy began passing the configured
urlPathas a route constraint). Present onmainand the v5.1 line including 5.1.18. Worked in 5.0.x, where static registered its handler unconditionally and matched the full request path against its own maps. Not covered by #1584's integration tests (subpath mount only).Repro
GET /→ 200 (index)GET /index.html→ 404GET /<anything>→ 404Removing
urlPath(unset) works — the breakage is specifically the explicit root value.Fix direction
normalizeUrlPathshould treat the root mount as "no path constraint" (returnundefinedfor'/'): a'/'mount constrains nothing, so root-mounted handlers join the default chain — restoring 5.0.x semantics — and no prefix strip occurs (which also neutralizes thestripPrefixslice hazard, sincenormalizeUrlPath(prefix) ?? ''becomes''). A host-constrained root mount ({ host, urlPath: '/' }) degrades correctly to a host-only sub-route.