ReviewOS

stacks/stx

fix(ssg): `/assets/*` resolves in dev through a serve-only rewrite and 404s in the build output

#1876
Closed glennmichael123 opened this 11 days ago ·
11 days ago

Summary

The dev/prod server rewrites /assets/* to /resources/assets/* (and back) when resolving a static request. The static build does no such thing — it copies publicDir and nothing else. A URL that resolves correctly under stx dev therefore has no counterpart in dist/, and the only way to notice is to diff the build output against the served tree.

Impact (real app)

analyticshq's marketing layout loads a script from the Stacks/Laravel-style asset root:

resources/layouts/marketing.stx:63

<script src="/assets/scripts/site-mode.js" defer></script>

The file lives at resources/assets/scripts/site-mode.js, which is exactly where buddy make:* and the Stacks conventions put it, and the serve path finds it. In the static build:

$ find dist/assets -maxdepth 1 -type d
dist/assets
dist/assets/images     # mirrored from public/assets/images
dist/assets/fonts      # mirrored from public/assets/fonts
$ ls dist/assets/scripts
ls: dist/assets/scripts: No such file or directory

That script drives the coming-soon gate (it reads body.dataset.siteMode), so on a static deploy the gate silently stops working on all 27 marketing pages. Nothing in the build reports it.

Evidence in stx

Serve path — packages/bun-plugin/src/serve.ts

:798  function assetRequestPaths(pathname: string): string[] {
:800    pathname,
:801    pathname.replace(/^\/assets\//, '/resources/assets/'),
:802    pathname.replace(/^\/resources\/assets\//, '/assets/'),

and :3111-3112 — if (path.startsWith('/assets/') || path.startsWith('/resources/assets/')) routes through it. resources/assets is also watched for HMR at :1083.

Build path — packages/stx/src/ssg.ts

:1286  if (cfg.publicDir && fs.existsSync(cfg.publicDir)) {
:1287    await copyDirectory(cfg.publicDir, cfg.outputDir)
:1288    console.log(`Copied public assets from ${cfg.publicDir}`)

grep -n 'resources/assets' packages/stx/src/ssg.ts → no matches. The build has no concept of the asset root the serve path invented.

Prior art

Vite has exactly two rules and both halves of the pipeline obey them: public/ is copied verbatim, and anything referenced from source is a hashed build asset. The same module graph feeds vite dev and vite build, so a URL cannot resolve in one and not the other. Laravel Vite compiles resources/ into public/build with a manifest that both the dev server and the built app read.

Ask

  1. Have the SSG copy resources/assets/ into dist/assets/ (mirroring the serve-time rewrite) so the two paths agree.
  2. Better: make /assets/* a single documented resolution rule shared by serve.ts and ssg.ts, rather than a rewrite that exists only on one side.
  3. Emit a build warning for any src=/href= in the generated HTML that points at a same-origin path with no file in the output directory. This class of bug is invisible until deploy.
11 days ago

Fixed in 08874ce.

Verified on a real build of exactly the shape you described:

AT HEAD:  dist/assets/scripts/site-mode.js  MISSING
NOW:      dist/assets/scripts/site-mode.js  present
          .output/public/assets/scripts/site-mode.js  present

Fixed in all three builders — ssg.ts, site-builder/build.ts and production-builder.ts — since each has its own copy mechanism and only serve.ts had ever heard of the convention.

One deliberate choice

The convention lives in a single module rather than being restated three times. Three copy sites with three different mechanisms (async copyDirectory, cpSync, copyPublicAssets) is exactly the shape where a rule gets fixed in one place and left behind in the other two — which is how this class of bug keeps recurring in this repo.

There is also a test asserting the constants still match what serve() rewrites to. If those drift, the build would cheerfully copy from a directory the server never reads, and the symptom would be identical to today's.

Additive throughout: publicDir copies exactly as before, and a project without resources/assets builds unchanged. Both are covered.

Worth knowing if you pull this

This adds the first new src module in a while, and packages/stx/dist is a gitignored build artifact. package-exports.test.ts asserts every source module has a built counterpart, so a stale local dist fails it until bun run build runs. I rebuilt here, but anyone with an older dist will see that one test fail until they do the same.

I did not add the build-time warning you suggested for "directory exists but nothing copies it" — with the copy in place there is nothing left to warn about. If you meant a broader "asset referenced but not emitted" check, that is a different and more useful feature, and worth its own issue.

Full suite 10972 pass; the one failure is the pre-existing crosswind-arbitrary-values case.

Sign in to comment on this issue.