ReviewOS

stacks/stx

fix(ssg): the static build inlines the runtime into every page — `buildMode` is never set on the SSG path

#1865
Closed glennmichael123 opened this 14 days ago · 1 comment
14 days ago

Summary

stx already knows how to emit its runtimes as external assets: injectSignalsRuntime and injectRouter each have a serve branch (<script src="/_stx/runtime.js">) and a compile branch (/__stx/runtime.__STX_HASH__.js, fingerprinted by the production builder). The SSG sets neither, so it falls through to the inline branch and every generated page carries a full, byte-identical copy of both runtimes. There is no external script, no fingerprinted filename and no source map anywhere in the output.

Impact (real app)

Measured on the analyticshq static build (dist/, 35 HTML files):

blocksizemd5pages
signals runtime118,394 Ba16acf78account, dashboard, index, login, pricing, register
router runtime31,876 B2d35441call 11 measured pages
third scoped block7,883 Bfe68e7756 pages

dist/login.html is 169,987 bytes, of which 158,153 is duplicated runtime. dist/dashboard.html is 182,245 bytes. find dist -type f -name '*.js' returns nothing — there is no src= to any local script, no hashed filename, and no .map file for any client script in the app.

The app had no lever to pull: config/ui.ts has no buildMode key and nothing in the Stacks build path (bunx --bun @stacksjs/stx build --pages resources/views --out dist) passes one.

Evidence in stx

packages/stx/src/runtime-injection.ts

  • :193 if (options.buildMode === 'serve') → <script data-stx-runtime src="/_stx/runtime.js"></script>
  • :208 if (options.buildMode === 'compile') → <script data-stx-runtime src="/__stx/runtime.__STX_HASH__.js"></script>
  • :221-224 fallback → <script data-stx-scoped data-stx-runtime>${runtime}</script> (the whole thing, inline)
  • :289-293 the same three-way split for the router bundle

Who sets buildMode:

  • packages/stx/src/template-compiler.ts:144 → 'compile'
  • packages/bun-plugin/src/serve.ts:1832 → 'serve'
  • packages/stx/src/ssg.ts:645-650 → nothing:
const stxOptions = {
  ...stxConfig,
  debug: false,
  cache: false,
  autoShell: true,
}

The replacement machinery already exists on the other side — packages/stx/src/production-builder.ts:227-228 rewrites runtime.__STX_HASH__.js / router.__STX_HASH__.js to the real fingerprinted filenames. The SSG simply never opts in.

Prior art

Vite, Nuxt and Next all emit shared runtime chunks as hashed files (_nuxt/entry.<hash>.js) linked from every page: downloaded once, cached across navigations, and shipped with source maps. Nothing in those pipelines duplicates the framework runtime per document.

Ask

  1. Have generateStaticSite() set buildMode: 'compile' (or a new 'ssg' mode with the same external-asset semantics) in the stxOptions it passes to processDirectives at ssg.ts:645-650.
  2. Emit dist/__stx/runtime.<hash>.js and dist/__stx/router.<hash>.js from the SSG using the same hashing pass production-builder.ts:227-228 already implements, and rewrite the placeholders in every generated page.
  3. Emit source maps for the runtimes and for bundled client scripts in the build output.
  4. Expose it as a config key (build.inlineRuntime: boolean) so a single-file/offline deployment can still opt back into inlining.

Related: #1814

14 days ago

Fixed in 18ce3c8.

externalizeSharedAssets() (packages/stx/src/build-externalize.ts) runs as a post-pass over emitted HTML in both ssg.ts and site-builder/build.ts, lifting the signals runtime, the router and the generated stylesheet into content-hashed files under _stx/. Content-addressed, so the runtime — byte-identical on every page — collapses to a single asset regardless of page count.

Measured on an SSG build:

BEFORE  app.html 168,019 bytes   index.html 38,380 bytes   (no .js/.css emitted at all)
AFTER   app.html   1,817 bytes   index.html    465 bytes
        _stx/runtime.15ac127c.js  163KB
        _stx/router.d5edb93b.js    38KB
        _stx/crosswind.586ccf05.css 3KB

Two things worth recording from the fix:

  • The router shipped inline on every page, including pages with no client script at all — on index.html it was the only large blob present, which is why that page was 38KB of nothing.
  • The obvious fix — setting buildMode: 'compile' on the SSG path — is a trap. That branch emits /__stx/runtime.__STX_HASH__.js, a placeholder only the production builder knows how to rewrite, and it also suppresses the document shell. You'd get pages referencing a 404 with no <html> wrapper. The post-pass avoids both, and the tests assert every emitted href resolves to a real file.

The pass runs before the asset copies so it only walks pages the build rendered, and it's idempotent (an already-external script has no inline body to match). Covered by packages/stx/test/build/externalize-shared-assets.test.ts.

Sign in to comment on this issue.