Skip to content

Commit d473bef

Browse files
committed
perf: serve a page's module bundle as an immutable file in dev
Every page and fragment carried its client module bundle inline, so each navigation re-downloaded and re-parsed the same code (the calendar fragment was 241KB). serve now links /_stx/modules.<hash>.js, cached as immutable, and a fragment links the same file.
1 parent d7eb120 commit d473bef

5 files changed

Lines changed: 260 additions & 3 deletions

File tree

‎packages/bun-plugin/src/serve.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,9 @@ export function staticCacheControl(pathname: string, production: boolean = isPro
556556
/** `/_stx/runtime.js`, `/_stx/router.<16 hex>.js`, … — the shared client scripts. */
557557
export const SHARED_SCRIPT_PATH = /^\/_stx\/(runtime|router)(?:\.([0-9a-f]{16}))?\.js$/
558558

559+
/** `/_stx/modules.<16 hex>.js` — a page's module bundle, named by its content. */
560+
export const MODULE_BUNDLE_PATH = /^\/_stx\/modules\.([0-9a-f]{16})\.js$/
561+
559562
/**
560563
* `Cache-Control` for the shared runtime and router scripts.
561564
*
@@ -3813,6 +3816,29 @@ function __stxOverlay(errs){
38133816
// (see sharedScriptCacheControl). The unhashed and stale-hash
38143817
// URLs still answer with the current script, for HTML
38153818
// rendered before this or before the last deploy.
3819+
// A page's module bundle, linked by content hash
3820+
// (registerServeModuleBundle): the same bytes forever under
3821+
// its URL, so cached immutably.
3822+
const moduleBundle = path.match(MODULE_BUNDLE_PATH)
3823+
if (moduleBundle) {
3824+
const stx = await stxModule
3825+
const content = stx.getServeModuleBundle(moduleBundle[1])
3826+
if (content === undefined)
3827+
return new Response('/* stx: unknown module bundle */', { status: 404, headers: { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store', ...corsHeaders } })
3828+
const etag = `"${moduleBundle[1]}"`
3829+
const headers = {
3830+
'Content-Type': 'application/javascript; charset=utf-8',
3831+
'Cache-Control': 'public, max-age=31536000, immutable',
3832+
'ETag': etag,
3833+
...corsHeaders,
3834+
}
3835+
if (req.headers.get('if-none-match') === etag)
3836+
return new Response(null, { status: 304, headers })
3837+
if (req.method === 'HEAD')
3838+
return new Response(null, { headers })
3839+
return new Response(content, { headers })
3840+
}
3841+
38163842
const sharedScript = path.match(SHARED_SCRIPT_PATH)
38173843
if (sharedScript) {
38183844
const stx = await stxModule
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* A page's module bundle is served as a file, end to end against a real
3+
* serve() subprocess.
4+
*
5+
* Inlined, the bundle travelled in the page and in every SPA fragment of it,
6+
* so a phone re-downloaded and re-parsed it (up to ~250KB) on each navigation
7+
* and could cache none of it. Serve mode now links it under a content hash and
8+
* answers that URL immutably.
9+
*/
10+
import { afterAll, beforeAll, describe, expect, it, setDefaultTimeout } from 'bun:test'
11+
import { mkdtemp, rm } from 'node:fs/promises'
12+
import { tmpdir } from 'node:os'
13+
import path from 'node:path'
14+
15+
setDefaultTimeout(60_000)
16+
17+
const PORT = 46_800 + (process.pid % 600)
18+
const BASE = `http://localhost:${PORT}`
19+
const SERVE_SRC = path.join(import.meta.dir, '..', 'src', 'serve.ts')
20+
21+
let dir: string
22+
let proc: ReturnType<typeof Bun.spawn> | null = null
23+
24+
beforeAll(async () => {
25+
dir = await mkdtemp(path.join(tmpdir(), 'stx-module-file-'))
26+
await Bun.write(path.join(dir, 'lib', 'greeting.ts'), `export const GREETING = 'module-bundle-marker'\n`)
27+
await Bun.write(path.join(dir, 'components', 'Hello.stx'), `<p id="hello">hello</p>
28+
<script client>
29+
import { GREETING } from '../lib/greeting'
30+
window.__greeting = GREETING
31+
</script>
32+
`)
33+
await Bun.write(path.join(dir, 'layouts', 'default.stx'), `<!DOCTYPE html>
34+
<html lang="en">
35+
<head><title>Bundle</title></head>
36+
<body>
37+
<main>
38+
@yield('content')
39+
</main>
40+
</body>
41+
</html>
42+
`)
43+
await Bun.write(path.join(dir, 'views', 'hello.stx'), `@extends('layouts/default')
44+
@section('content')
45+
<Hello />
46+
@endsection
47+
`)
48+
await Bun.write(path.join(dir, 'driver.ts'), `import { serve } from ${JSON.stringify(SERVE_SRC)}
49+
50+
serve({ patterns: ['views'], port: ${PORT}, componentsDir: 'components' })
51+
`)
52+
53+
proc = Bun.spawn(['bun', 'driver.ts'], { cwd: dir, stdout: 'pipe', stderr: 'pipe' })
54+
const deadline = Date.now() + 30_000
55+
while (true) {
56+
try {
57+
await fetch(`${BASE}/definitely-not-a-page`)
58+
break
59+
}
60+
catch {
61+
if (Date.now() > deadline)
62+
throw new Error('serve() did not start')
63+
await Bun.sleep(100)
64+
}
65+
}
66+
})
67+
68+
afterAll(async () => {
69+
proc?.kill()
70+
if (dir)
71+
await rm(dir, { recursive: true, force: true })
72+
})
73+
74+
const bundleUrl = (html: string): string | undefined => html.match(/<script\b[^>]*data-stx-modules[^>]*src="(\/_stx\/modules\.[0-9a-f]{16}\.js)"/)?.[1]
75+
76+
describe('the page module bundle as a file', () => {
77+
it('links the bundle instead of inlining it, and serves it immutably', async () => {
78+
const html = await (await fetch(`${BASE}/hello`)).text()
79+
const url = bundleUrl(html)
80+
expect(url).toBeDefined()
81+
expect(html).not.toContain('module-bundle-marker')
82+
83+
const res = await fetch(`${BASE}${url}`)
84+
expect(res.status).toBe(200)
85+
expect(res.headers.get('content-type')).toContain('javascript')
86+
expect(res.headers.get('cache-control')).toContain('immutable')
87+
expect(await res.text()).toContain('module-bundle-marker')
88+
})
89+
90+
it('links the same file from the SPA fragment, which the router loads before the page scripts', async () => {
91+
const full = await (await fetch(`${BASE}/hello`)).text()
92+
const fragment = await (await fetch(`${BASE}/hello`, { headers: { 'X-STX-Router': 'true' } })).text()
93+
expect(bundleUrl(fragment)).toBe(bundleUrl(full))
94+
expect(fragment).not.toContain('module-bundle-marker')
95+
})
96+
97+
it('answers a hash it never produced with a 404, not a cached page', async () => {
98+
const res = await fetch(`${BASE}/_stx/modules.0123456789abcdef.js`)
99+
expect(res.status).toBe(404)
100+
expect(res.headers.get('cache-control')).toBe('no-store')
101+
})
102+
})

‎packages/stx/src/caching.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from 'node:path'
44
import { clearComponentCache, fileExists } from './utils'
55
import { clearRenderMemos } from './render-memo'
66
import { cacheEngineFingerprint } from './engine-fingerprint'
7+
import { stateDir } from './state-dir'
78

89
/**
910
* Cache entry structure
@@ -207,6 +208,64 @@ export async function getServeClientAsset(kind: ServeClientAssetKind, debug = fa
207208
return { content, hash: memo.hash, url: `/_stx/${kind}.${memo.hash}.js` }
208209
}
209210

211+
/**
212+
* Page module bundles (#1957) by content hash, for serve mode to answer at
213+
* `/_stx/modules.<hash>.js`.
214+
*
215+
* Inlined, a page's bundle travelled in its HTML and in every SPA fragment of
216+
* it: a phone re-downloaded and re-parsed up to ~250KB on each navigation and
217+
* could cache none of it. As a file named by its content, it is fetched once
218+
* and cached for good; a page reached again, or one sharing its modules, costs
219+
* nothing. Bounded, oldest first: a long dev session with HMR mints new hashes.
220+
*/
221+
const MODULE_BUNDLE_LIMIT = 500
222+
const _moduleBundles = new Map<string, string>()
223+
224+
/** Where module bundles are also written, so every server process can answer. */
225+
function moduleBundleFile(hash: string): string {
226+
return stateDir(process.cwd(), 'module-bundles', `${hash}.js`)
227+
}
228+
229+
/** Registers a module bundle and returns the URL a page links it under. */
230+
export function registerServeModuleBundle(code: string): string {
231+
const hash = Bun.hash(code).toString(16).padStart(16, '0').slice(0, 16)
232+
const known = _moduleBundles.has(hash)
233+
if (known)
234+
_moduleBundles.delete(hash)
235+
_moduleBundles.set(hash, code)
236+
while (_moduleBundles.size > MODULE_BUNDLE_LIMIT)
237+
_moduleBundles.delete(_moduleBundles.keys().next().value as string)
238+
// On disk too: behind a load balancer the page can be rendered by one
239+
// process and its bundle requested from another. Best effort; memory still
240+
// answers for this process when the disk cannot be written.
241+
if (!known) {
242+
try {
243+
const file = moduleBundleFile(hash)
244+
if (!fs.existsSync(file)) {
245+
fs.mkdirSync(path.dirname(file), { recursive: true })
246+
fs.writeFileSync(file, code)
247+
}
248+
}
249+
catch {}
250+
}
251+
return `/_stx/modules.${hash}.js`
252+
}
253+
254+
/** A module bundle by hash, or undefined for one never rendered on this machine. */
255+
export function getServeModuleBundle(hash: string): string | undefined {
256+
if (!/^[0-9a-f]{16}$/.test(hash))
257+
return undefined
258+
const cached = _moduleBundles.get(hash)
259+
if (cached !== undefined)
260+
return cached
261+
try {
262+
return fs.readFileSync(moduleBundleFile(hash), 'utf8')
263+
}
264+
catch {
265+
return undefined
266+
}
267+
}
268+
210269
/**
211270
* Clear all dev caches. Called during full rebuild or HMR reset.
212271
*/

‎packages/stx/src/process.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,14 @@ async function buildModulesTag(
330330
})
331331
if (!code)
332332
return null
333+
// Serve mode links the bundle as a content-addressed file the Bun serve
334+
// adapter answers immutably (see registerServeModuleBundle), so it is
335+
// downloaded once rather than with every page and SPA fragment. A static
336+
// build has no server to answer that URL and keeps it inline.
337+
if (options.buildMode === 'serve') {
338+
const { registerServeModuleBundle } = await import('./caching')
339+
return `<script data-stx-scoped data-stx-run="always" data-stx-modules src="${registerServeModuleBundle(code)}"></script>`
340+
}
333341
// A module's own string literal can contain the closing tag; escaped, it
334342
// means the same thing to JavaScript and nothing to the HTML parser.
335343
return `<script data-stx-scoped data-stx-run="always" data-stx-modules>${code.replace(/<\/script/gi, '<\\/script')}</script>`

‎packages/stx/test/client-script-bundler/component-bundles-share-modules.test.ts‎

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import path from 'node:path'
1818
import { bundleClientScript, rewriteRegistryImports } from '../../src/client-script-bundler'
1919
import { buildModuleRegistryScript, registeredModuleIdsIn } from '../../src/client-module-registry'
2020
import { processDirectives } from '../../src/process'
21+
import { getServeModuleBundle } from '../../src/caching'
2122
import { generateSignalsRuntimeDev } from '../../src/signals'
2223

2324
// eslint-disable-next-line ts/no-explicit-any
@@ -57,9 +58,22 @@ async function render(dir: string, page = 'page.stx'): Promise<string> {
5758
)
5859
}
5960

60-
/** Run every inline script in document order, the way a browser would. */
61+
/**
62+
* Run every script in document order, the way a browser would: inline ones as
63+
* written, and the page's module bundle, which serve mode links as a file,
64+
* from what the /_stx/modules endpoint would answer.
65+
*/
6166
function execute(html: string): void {
6267
for (const match of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/g)) {
68+
const bundle = match[1].match(/\bsrc="\/_stx\/modules\.([0-9a-f]{16})\.js"/)
69+
if (bundle) {
70+
const code = getServeModuleBundle(bundle[1])
71+
if (code === undefined)
72+
throw new Error(`module bundle ${bundle[1]} was linked but never registered`)
73+
// eslint-disable-next-line no-eval
74+
;(0, eval)(code)
75+
continue
76+
}
6377
if (/\bsrc=/.test(match[1]))
6478
continue
6579
// eslint-disable-next-line no-eval
@@ -92,8 +106,11 @@ describe('components share one module instance per page (#1957)', () => {
92106
})
93107
const html = await render(dir)
94108

95-
// Shipped once, in the page registry, not once per component.
96-
expect(html.match(/counter-module-body/g)).toHaveLength(1)
109+
// Shipped once, in the page registry, not once per component. Serve mode
110+
// links the registry as a file, so count across the page and that file.
111+
const hash = html.match(/\/_stx\/modules\.([0-9a-f]{16})\.js/)?.[1]
112+
const shipped = html + (hash ? getServeModuleBundle(hash) ?? '' : '')
113+
expect(shipped.match(/counter-module-body/g)).toHaveLength(1)
97114
execute(html)
98115
// One instance: the second component sees the first one's increment.
99116
expect(win.__probe).toEqual([['alpha', 1], ['beta', 2]])
@@ -198,6 +215,51 @@ window.__probe = TAG
198215
})
199216
})
200217

218+
describe('the page module bundle as a file (serve mode)', () => {
219+
// Inlined, a page's bundle travelled in its HTML and in every SPA fragment:
220+
// a phone re-downloaded and re-parsed it on each navigation and could cache
221+
// none of it. Served as a file named by its content, it is fetched once.
222+
const files = {
223+
'functions/counter.ts': COUNTER,
224+
'components/First.stx': counterComponent('first'),
225+
'components/Second.stx': counterComponent('second'),
226+
'page.stx': '<First />\n<Second />\n',
227+
'other.stx': '<Second />\n<First />\n',
228+
}
229+
230+
it('links the bundle by content hash instead of inlining it', async () => {
231+
const html = await render(project(files))
232+
const tag = html.match(/<script\b[^>]*data-stx-modules[^>]*>([\s\S]*?)<\/script>/)
233+
expect(tag).not.toBeNull()
234+
expect(tag![0]).toMatch(/src="\/_stx\/modules\.[0-9a-f]{16}\.js"/)
235+
expect(tag![1]).toBe('')
236+
expect(html).not.toContain('counter-module-body')
237+
})
238+
239+
it('gives two pages that need the same modules the same file, so it is cached once', async () => {
240+
const dir = project(files)
241+
const url = (html: string) => html.match(/\/_stx\/modules\.[0-9a-f]{16}\.js/)![0]
242+
expect(url(await render(dir, 'page.stx'))).toBe(url(await render(dir, 'other.stx')))
243+
})
244+
245+
it('answers the file from disk as well, for a process that never rendered the page', async () => {
246+
const html = await render(project(files))
247+
const hash = html.match(/\/_stx\/modules\.([0-9a-f]{16})\.js/)![1]
248+
const { stateDir } = await import('../../src/state-dir')
249+
const onDisk = await Bun.file(stateDir(process.cwd(), 'module-bundles', `${hash}.js`)).text()
250+
expect(onDisk).toBe(getServeModuleBundle(hash)!)
251+
expect(getServeModuleBundle('not-a-hash')).toBeUndefined()
252+
})
253+
254+
it('keeps the bundle inline outside serve mode, where no server answers the URL', async () => {
255+
const dir = project(files)
256+
const source = await Bun.file(path.join(dir, 'page.stx')).text()
257+
const html = await processDirectives(source, {}, path.join(dir, 'page.stx'), { componentsDir: path.join(dir, 'components'), root: dir }, new Set<string>())
258+
expect(html).not.toContain('/_stx/modules.')
259+
expect(html).toContain('counter-module-body')
260+
})
261+
})
262+
201263
describe('registry import rewriting', () => {
202264
it('binds every import form Bun emits', () => {
203265
const reads: string[] = []

0 commit comments

Comments
 (0)