SvitsGames

Two Vite defaults that break only after upload

Short version: Vite emits root-absolute asset paths (/assets/index-*.js), which resolve against the host root and 404 when a game is served from a subpath. And public/ is copied wholesale, so assets a feature flag “disabled” ship regardless — measured here, 1.89 MB of MP3s in a build made with VITE_USE_MUSIC=false.

Both are correct behaviour. Both are invisible in npm run dev. Both first appear as somebody else’s broken deployment.

Vite’s asset paths are absolute until you say otherwise

Portals serve a game from a subpath — https://portal.example/games/cook-line/ — and Vite’s default output assumes the site root. base takes an “empty string or ./ (for embedded deployment)”, which is exactly this case.

Building the game twice, once with each setting, and reading all six src and href values out of the emitted index.html (Vite 6.0.7):

Default npm run build vite build --base=./
src="/assets/index-*.js" src="./assets/index-*.js"
href="/assets/index-*.css" href="./assets/index-*.css"
href="/favicon.ico" href="./favicon.ico"
href="/favicon-32.png" href="./favicon-32.png"
href="/apple-touch-icon.png" href="./apple-touch-icon.png"
href="/manifest.webmanifest" href="./manifest.webmanifest"

Every path, including the ones pointing into public/. Under a subpath the left-hand column asks for https://portal.example/assets/index-*.js, gets the portal’s 404, and the player sees a blank screen with nothing useful in the console — the request that failed is the one that would have logged the error.

Dev never shows this, because the dev server serves from the root. So the flag lives in the release script rather than in vite.config.ts:

npx vite build --base=./ >/dev/null || die "vite build failed."

Keeping it out of the config is deliberate: local development keeps ordinary absolute paths, and only the artifact that gets uploaded is relative.

One caveat the docs attach to this and I nearly missed: relative bases require import.meta support, with @vitejs/plugin-legacy as the escape hatch for browsers that lack it. That’s not a constraint on modern portal traffic, but it is a real one if your target list reaches further back than mine does.

A passed flag is not a flag that worked

The release script doesn’t trust the flag it just passed. It greps the output:

if grep -qE '(src|href)="/' "$REPO_ROOT/dist/index.html"; then
  grep -oE '(src|href)="/[^"]*"' "$REPO_ROOT/dist/index.html" >&2
  die "index.html still has absolute asset paths; it will not load from a portal subpath."
fi

Four lines, and they convert “the build succeeded” into “the build produced what the flag was for.” That distinction is the whole reason this post exists: the failure mode isn’t the flag being wrong, it’s the flag being absent — a refactor that swaps release.sh for npm run build, a CI config that calls Vite directly, a new script written by someone who didn’t know. The build still succeeds. The blank screen shows up on the portal.

The absolute-path gate is blind to paths that were never absolute

Here’s something I found by testing rather than reading, and it revises a comment in our own index.html. That comment says a hand-written relative href “would instead be resolved as a source asset at build time and fail.”

It doesn’t. I tried three forms in <link rel="icon"> and built each with --base=./, Vite 6.0.7:

Written in index.html Result Emitted
href="/favicon.ico" builds href="./favicon.ico"
href="favicon.ico" builds href="favicon.ico"
href="./favicon.ico" builds href="./favicon.ico"
href="nope.png" (no such file anywhere) builds href="nope.png"

No error, no warning, no rewrite — including for a file that exists in neither public/ nor the source tree. Relative hrefs are passed through verbatim; only root-absolute ones are rewritten against --base.

Which means the rule in that comment is right and its reasoning is wrong, and the correct reasoning is more useful. Write these root-absolute not because relative ones fail, but because relative ones silently opt out of both mechanisms: they don’t participate in the --base rewrite, and they don’t match (src|href)="/, so the gate that proves the flag worked cannot see them. A relative path is the one shape that is neither transformed nor checked.

I only characterised <link> with rel="icon", apple-touch-icon and manifest, which is what this file has. I don’t know that every tag and rel behaves the same way, and the honest reading of the above is “test the ones you actually use,” not “Vite ignores relative paths.”

public/ has never heard of your feature flag

VITE_USE_MUSIC gates exactly one thing in the source:

export const isMusicEnabled = import.meta.env.VITE_USE_MUSIC === 'true';

That decides whether the loader queues the tracks and whether the cache check passes. It has no bearing on what ships, because public/ is copied into dist/ as-is.

Building with the flag off and listing the audio in the output:

VITE_USE_MUSIC=false npx vite build --base=./
find dist -name '*.mp3' -printf '%s %f\n'
1159017 music_gameplay.mp3
733953 music_rush.mp3

1,892,970 bytes — 1.89 MB — in a build whose configuration says music is off. Silent, unreferenced by the running game, and downloaded by nobody, but present in the upload and counted against the portal’s size budget. public/ in this project is 2.90 MB total, so the disabled feature is roughly two thirds of it.

The generalisation is worth stating on its own: a feature flag controls runtime behaviour, and the bundler controls what ships, and nothing connects the two. Any “optional assets behind a flag” arrangement has this hole. The assets are only optional to the code.

Why unflagged audio in public/ is a build failure, not a warning

The size cost is the boring half of shipping music you meant to exclude. The MP3s in this project are unreplaced placeholders of unrecorded provenance, so a release believing it contains no music is precisely the release that must not be produced. The check is fatal:

AUDIO_COUNT="$(find_audio | wc -l)"
if [ "$AUDIO_COUNT" -gt 0 ] && [ "$MUSIC_FLAG" != "true" ]; then
  find_audio >&2
  die "VITE_USE_MUSIC is '$MUSIC_FLAG' but $AUDIO_COUNT audio file(s) are in the build. Vite copies public/ wholesale — move them out of public/ to leave them out."
fi

Two details in there earn their place.

The error message names the fix. “Move them out of public/” — because the instinct on hitting this is to look for a Vite option that excludes files, and there isn’t one that’s simpler than not putting them there.

It matches seven audio extensions, not .mp3. find looks for .mp3, .ogg, .m4a, .wav, .opus, .webm and .flac. The project’s own notes point at .ogg as the format to re-encode to for a cleaner loop seam, and a gate keyed to one extension would stop firing the moment that happened — while still printing a green check. A licensing check that passes because it stopped looking is worse than no check at all, because it is also an alibi.

What I took from this: settings describe intent, builds describe reality

The two traps are the same trap. In both cases a setting describes intent, the build describes reality, and nothing forces them to agree. --base is intent; the emitted index.html is reality. VITE_USE_MUSIC is intent; the contents of dist/ are reality. Every check in that release script is an assertion about the artifact rather than about the configuration, and that’s the only kind that holds when someone changes how the build is invoked.

The third finding is the one I didn’t go looking for: a comment in the file explaining why to write the icon hrefs a certain way had the right conclusion attached to a mechanism that doesn’t exist. The rule survived a test its stated reason didn’t. That’s the second time in a fortnight a comment in this project turned out to be a plausible story rather than a checked one, which is starting to look like a pattern rather than two incidents.

Measured against Vite 6.0.7. Vite’s docs still list ./ for embedded deployment as of v8.2.0, and the v7→v8 migration guide records no change to base — but I tested 6.0.7 and only 6.0.7, so treat the emitted-path table as version-specific and re-run it on yours.

The other one where the code was right and the comment wasn’t: a Phaser boot splash whose 3-second font gate cites the wrong font-display value. On the site side, an Astro selector that matched nothing and a drop-shadow that never rendered.