Skip to content

Commit b0655fc

Browse files
feat: Phase 1 — externalize shared runtimes for production builds
Added build-assets module that generates fingerprinted static assets: - buildRuntimeAsset(): signals runtime (20-40KB) as external JS file - buildRouterAsset(): SPA router (5-8KB) as external JS file - fingerprint(): SHA-256 content hashing for cache-busting filenames Added buildMode option to StxConfig ('compile' | 'serve'): - injectSignalsRuntime: emits <script src> placeholder in compile mode - injectRouterScript: emits <script src> placeholder in compile mode - Dev mode (undefined buildMode) behavior unchanged 12 new tests covering asset generation, fingerprinting, and compile mode. All 8004 existing tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 290fa99 commit b0655fc

File tree

5 files changed

+230
-2
lines changed

5 files changed

+230
-2
lines changed

packages/stx/src/build-assets.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Build Assets
3+
*
4+
* Generates and fingerprints shared static assets for production builds.
5+
* These assets are identical across all pages and can be cached aggressively.
6+
*
7+
* @module build-assets
8+
*/
9+
10+
import { createHash } from 'node:crypto'
11+
import { generateSignalsRuntime, generateSignalsRuntimeDev } from './signals'
12+
import { getRouterScript } from 'stx-router'
13+
14+
/**
15+
* A built asset with content and fingerprinted filename.
16+
*/
17+
export interface BuiltAsset {
18+
/** The asset content (JS or CSS) */
19+
content: string
20+
/** Fingerprinted filename (e.g. 'runtime.a1b2c3d4.js') */
21+
filename: string
22+
/** Content hash for cache busting */
23+
hash: string
24+
}
25+
26+
/**
27+
* Generate a fingerprint hash from content.
28+
* Uses SHA-256 truncated to 8 hex chars.
29+
*/
30+
export function fingerprint(content: string): string {
31+
return createHash('sha256').update(content).digest('hex').slice(0, 8)
32+
}
33+
34+
/**
35+
* Build the signals runtime as an external asset.
36+
* This is identical for every page — no need to inline 20-40KB per page.
37+
*/
38+
export function buildRuntimeAsset(debug = false): BuiltAsset {
39+
const content = debug ? generateSignalsRuntimeDev() : generateSignalsRuntime()
40+
const hash = fingerprint(content)
41+
return {
42+
content,
43+
filename: `runtime.${hash}.js`,
44+
hash,
45+
}
46+
}
47+
48+
/**
49+
* Build the SPA router script as an external asset.
50+
* Identical for every page — ~5-8KB.
51+
*/
52+
export function buildRouterAsset(): BuiltAsset {
53+
const content = getRouterScript()
54+
const hash = fingerprint(content)
55+
return {
56+
content,
57+
filename: `router.${hash}.js`,
58+
hash,
59+
}
60+
}
61+
62+
/**
63+
* Generates `<script src>` and `<link href>` tags for externalized assets.
64+
* Used in build mode to replace inline scripts with references.
65+
*/
66+
export function assetTags(assets: { runtime?: BuiltAsset, router?: BuiltAsset, css?: BuiltAsset }): {
67+
runtimeTag: string
68+
routerTag: string
69+
cssTag: string
70+
} {
71+
return {
72+
runtimeTag: assets.runtime
73+
? `<script src="/__stx/${assets.runtime.filename}"></script>`
74+
: '',
75+
routerTag: assets.router
76+
? `<script src="/__stx/${assets.router.filename}"></script>`
77+
: '',
78+
cssTag: assets.css
79+
? `<link rel="stylesheet" href="/__stx/${assets.css.filename}">`
80+
: '',
81+
}
82+
}

packages/stx/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export * from './assets'
3535
export * from './app-shell'
3636
export * from './async-components'
3737
export * from './auth'
38+
export * from './build-assets'
3839
export * from './build-optimizer'
3940
export * from './broadcasting'
4041
export * from './caching'

packages/stx/src/process.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,21 @@ function injectSignalsRuntime(template: string, options: StxOptions): string {
634634
return template
635635
}
636636

637+
// Build mode: emit a placeholder reference instead of inlining the full runtime.
638+
// The production builder will replace this with a fingerprinted <script src> tag.
639+
if (options.buildMode === 'compile') {
640+
const runtimeScript = `<script src="/__stx/runtime.__STX_HASH__.js"></script>`
641+
const firstScriptInDoc = template.indexOf('<script')
642+
if (firstScriptInDoc !== -1) {
643+
return template.slice(0, firstScriptInDoc) + runtimeScript + '\n' + template.slice(firstScriptInDoc)
644+
}
645+
if (template.includes('</head>')) {
646+
const idx = template.indexOf('</head>')
647+
return template.slice(0, idx) + runtimeScript + '\n' + template.slice(idx)
648+
}
649+
return runtimeScript + '\n' + template
650+
}
651+
637652
const runtime = options.debug ? generateSignalsRuntimeDev() : generateSignalsRuntime()
638653
const runtimeScript = `<script data-stx-scoped>${runtime}</script>`
639654

@@ -666,7 +681,7 @@ function injectSignalsRuntime(template: string, options: StxOptions): string {
666681
* The router is provided by the canonical router in packages/router/src/client.ts.
667682
* It guards against double-initialization so it's safe to inject alongside @stxRouter.
668683
*/
669-
export function injectRouterScript(template: string): string {
684+
export function injectRouterScript(template: string, options?: StxOptions): string {
670685
// Only inject into full HTML pages (not template fragments or components)
671686
if (!template.includes('</body>')) {
672687
return template
@@ -677,7 +692,10 @@ export function injectRouterScript(template: string): string {
677692
return template
678693
}
679694

680-
const routerScript = `<script>${getRouterScript()}</script>`
695+
// Build mode: emit a placeholder reference instead of inlining the full router script.
696+
const routerScript = options?.buildMode === 'compile'
697+
? `<script src="/__stx/router.__STX_HASH__.js"></script>`
698+
: `<script>${getRouterScript()}</script>`
681699

682700
// Use string concatenation to avoid $-interpretation in .replace()
683701
const bodyCloseIdx = template.lastIndexOf('</body>')

packages/stx/src/types/config-types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,20 @@ export interface StxConfig {
538538
*/
539539
strict?: boolean | StrictModeConfig
540540

541+
/**
542+
* Build mode for the processing pipeline.
543+
* - `undefined` (default): dev mode, inline everything per request
544+
* - `'compile'`: build mode, emit asset reference placeholders instead of inline code
545+
* - `'serve'`: production serve mode, hydrate pre-compiled templates
546+
*/
547+
buildMode?: 'compile' | 'serve'
548+
549+
/**
550+
* Output directory for production builds.
551+
* @default '.output'
552+
*/
553+
outputDir?: string
554+
541555
/**
542556
* App shell file for single-shell mode.
543557
* When set (or auto-detected as 'app.stx'), the shell wraps all pages.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Tests for build assets — externalized runtime, router, and CSS
3+
*/
4+
import { describe, expect, it } from 'bun:test'
5+
import { buildRuntimeAsset, buildRouterAsset, fingerprint, assetTags } from '../../src/build-assets'
6+
import { processDirectives, defaultConfig } from '../../src/index'
7+
8+
describe('Build Assets', () => {
9+
describe('fingerprint', () => {
10+
it('should produce a deterministic 8-char hex hash', () => {
11+
const hash = fingerprint('hello world')
12+
expect(hash).toHaveLength(8)
13+
expect(hash).toMatch(/^[0-9a-f]{8}$/)
14+
expect(fingerprint('hello world')).toBe(hash) // deterministic
15+
})
16+
17+
it('should produce different hashes for different content', () => {
18+
expect(fingerprint('hello')).not.toBe(fingerprint('world'))
19+
})
20+
})
21+
22+
describe('buildRuntimeAsset', () => {
23+
it('should produce a valid JS asset with fingerprinted filename', () => {
24+
const asset = buildRuntimeAsset()
25+
expect(asset.content).toContain('state')
26+
expect(asset.content).toContain('derived')
27+
expect(asset.content).toContain('effect')
28+
expect(asset.filename).toMatch(/^runtime\.[0-9a-f]{8}\.js$/)
29+
expect(asset.hash).toHaveLength(8)
30+
})
31+
32+
it('should produce valid JS that parses', () => {
33+
const asset = buildRuntimeAsset()
34+
expect(() => new Function(asset.content)).not.toThrow()
35+
})
36+
37+
it('should produce deterministic output', () => {
38+
const a = buildRuntimeAsset()
39+
const b = buildRuntimeAsset()
40+
expect(a.hash).toBe(b.hash)
41+
expect(a.filename).toBe(b.filename)
42+
})
43+
44+
it('should produce different output for debug mode', () => {
45+
const prod = buildRuntimeAsset(false)
46+
const dev = buildRuntimeAsset(true)
47+
expect(dev.content.length).toBeGreaterThan(prod.content.length)
48+
expect(dev.hash).not.toBe(prod.hash)
49+
})
50+
})
51+
52+
describe('buildRouterAsset', () => {
53+
it('should produce a valid JS asset', () => {
54+
const asset = buildRouterAsset()
55+
expect(asset.content).toContain('__stxRouter')
56+
expect(asset.filename).toMatch(/^router\.[0-9a-f]{8}\.js$/)
57+
})
58+
59+
it('should be deterministic', () => {
60+
const a = buildRouterAsset()
61+
const b = buildRouterAsset()
62+
expect(a.hash).toBe(b.hash)
63+
})
64+
})
65+
66+
describe('assetTags', () => {
67+
it('should generate script src tags for assets', () => {
68+
const runtime = buildRuntimeAsset()
69+
const router = buildRouterAsset()
70+
const tags = assetTags({ runtime, router })
71+
72+
expect(tags.runtimeTag).toContain('/__stx/runtime.')
73+
expect(tags.runtimeTag).toContain('.js')
74+
expect(tags.runtimeTag).toContain('<script src=')
75+
expect(tags.routerTag).toContain('/__stx/router.')
76+
expect(tags.routerTag).toContain('<script src=')
77+
})
78+
79+
it('should return empty strings for missing assets', () => {
80+
const tags = assetTags({})
81+
expect(tags.runtimeTag).toBe('')
82+
expect(tags.routerTag).toBe('')
83+
expect(tags.cssTag).toBe('')
84+
})
85+
})
86+
87+
describe('Build mode in processDirectives', () => {
88+
const defaultOpts = { partialsDir: '/tmp', componentsDir: '/tmp' }
89+
90+
it('should emit script src placeholder in compile mode for signals runtime', async () => {
91+
const html = `<script>const count = state(0)</script><div>{{ count() }}</div>`
92+
const result = await processDirectives(html, {}, '/test.stx', {
93+
...defaultConfig,
94+
...defaultOpts,
95+
buildMode: 'compile',
96+
}, new Set())
97+
98+
expect(result).toContain('/__stx/runtime.__STX_HASH__.js')
99+
expect(result).not.toContain('window.stx.state') // no inline runtime
100+
})
101+
102+
it('should inline runtime in dev mode (no buildMode)', async () => {
103+
const html = `<script>const count = state(0)</script><div>{{ count() }}</div>`
104+
const result = await processDirectives(html, {}, '/test.stx', {
105+
...defaultConfig,
106+
...defaultOpts,
107+
}, new Set())
108+
109+
// Dev mode: inline runtime
110+
expect(result).not.toContain('/__stx/runtime.')
111+
})
112+
})
113+
})

0 commit comments

Comments
 (0)