Skip to content

Commit d62e473

Browse files
committed
Add cache headers to applet bundles and workspace file streams
Both the applet-bundle route and the workspace media-file (`fileUrl`) route sent NO cache headers, so a TLS-terminating reverse proxy (Cloudflare) applied its default extension-based edge caching. The stable `index.js` entry url then served a stale bundle after every `moi bundle`, and private workspace media could be stored on a shared edge. Split caching by whether the url is stable or content-hashed: - Applet entry `index.js` (stable url across rebuilds): ETag from (size, mtime) + `no-cache`, with 304 on `If-None-Match`. The edge must revalidate, so an unchanged bundle costs a 304 and a rebuilt one busts — no more stale bundles behind a proxy. - Applet chunks/assets (`chunk-<hash>.js`, `<stem>-<hash>.<ext>`): the url is a fingerprint of the bytes, so `public, max-age=31536000, immutable`. Cached forever at edge and browser (mirrors the SPA chunks). - Workspace media (`/fs/*`): the user's own, mutable files — `private, no-cache` + ETag (304 on match) so they are NEVER stored by a shared edge and the browser revalidates cheaply. Headers ride the 200, 206 and 304 paths. Mirrors the existing `/preview/*` route. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpGBXW5iHAsc8K2543Z7BU
1 parent a50cb8b commit d62e473

5 files changed

Lines changed: 133 additions & 11 deletions

File tree

server/api.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ one.get('/widgets/*', c => {
104104
const id = c.req.param('id')
105105
const { name, file } = parseAppletTail(c.req.url, id, 'widgets')
106106
if (!name) return c.text('Not found', 404)
107-
return serveWidget(name, file, c.get('ws').path, apiBaseFor(id))
107+
return serveWidget(name, file, c.get('ws').path, apiBaseFor(id), c.req.header('if-none-match'))
108108
})
109109

110110
// Views — full-screen agent apps. Mirrors the widget pair above: the exact path
@@ -115,7 +115,7 @@ one.get('/views/*', c => {
115115
const id = c.req.param('id')
116116
const { name, file } = parseAppletTail(c.req.url, id, 'views')
117117
if (!name) return c.text('Not found', 404)
118-
return serveView(name, file, c.get('ws').path, apiBaseFor(id))
118+
return serveView(name, file, c.get('ws').path, apiBaseFor(id), c.req.header('if-none-match'))
119119
})
120120

121121
// Workspace file stream — an applet's `fileUrl(path)` resolves here. Streams a
@@ -126,7 +126,12 @@ one.get('/views/*', c => {
126126
one.get('/fs/*', c => {
127127
const id = c.req.param('id')
128128
const tail = new URL(c.req.url).pathname.split(`/api/workspaces/${id}/fs/`)[1] ?? ''
129-
return serveWorkspaceFile(c.get('ws').path, tail, c.req.header('range'))
129+
return serveWorkspaceFile(
130+
c.get('ws').path,
131+
tail,
132+
c.req.header('range'),
133+
c.req.header('if-none-match')
134+
)
130135
})
131136

132137
// Applet RPC — the home for server-function calls from a bundle. The bundle's

server/applets.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,16 +136,33 @@ const CODE_FILE_RE = /\.js$/
136136
// can be requested; `..` is rejected separately.
137137
const ALLOWED_FILE_RE = /^[a-zA-Z0-9_-][a-zA-Z0-9._-]*$/
138138

139+
// The `index.js` entry keeps a STABLE url across rebuilds; every other file in
140+
// the bundle dir (`chunk-<hash>.js`, `<stem>-<hash>.<ext>`) is content-hashed,
141+
// so its url changes whenever its bytes do.
142+
const APPLET_ENTRY = 'index.js'
143+
const JS_CONTENT_TYPE = 'text/javascript; charset=utf-8'
144+
// A hashed file's url is a fingerprint of its bytes, so it can never go stale —
145+
// cache it forever, at the edge and in the browser (mirrors the SPA chunks).
146+
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
147+
139148
// Serve one file from a compiled applet directory: the `index.js` entry, a code
140149
// chunk, or a bundled asset. `apiBase` is the workspace's `/api/workspaces/<id>`
141150
// prefix — substituted for the build-time sentinel in every `.js` so RPC and
142151
// `fileUrl` calls hit the right workspace. Assets stream untouched.
152+
//
153+
// Caching turns on the entry-vs-hashed split. The entry lives at one url across
154+
// rebuilds, so a shared cache (e.g. Cloudflare) that stored it once will hand
155+
// back a STALE bundle after the next `moi bundle` unless we force revalidation:
156+
// it gets an ETag (from size+mtime) + `no-cache`, so an unchanged bundle costs a
157+
// 304 and a rebuilt one busts. Hashed chunks/assets are immutable — their url
158+
// already changes with their bytes — so they cache forever.
143159
export async function serveApplet(
144160
kind: AppletKind,
145161
name: string,
146162
file: string,
147163
workspacePath: string,
148-
apiBase: string
164+
apiBase: string,
165+
ifNoneMatch?: string | null
149166
): Promise<Response> {
150167
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
151168
return new Response('Invalid name', { status: 400 })
@@ -160,11 +177,26 @@ export async function serveApplet(
160177
if (!(await bunFile.exists())) {
161178
return new Response(`"${name}" not built. Run: moi bundle`, { status: 404 })
162179
}
180+
181+
if (file === APPLET_ENTRY) {
182+
const etag = `"${bunFile.size}-${Math.trunc(bunFile.lastModified)}"`
183+
const headers = { 'Content-Type': JS_CONTENT_TYPE, ETag: etag, 'Cache-Control': 'no-cache' }
184+
if (ifNoneMatch === etag) return new Response(null, { status: 304, headers })
185+
const swapped = (await bunFile.text()).replaceAll(APPLET_API_BASE_SENTINEL, apiBase)
186+
return new Response(swapped, { headers })
187+
}
163188
if (CODE_FILE_RE.test(file)) {
164189
const swapped = (await bunFile.text()).replaceAll(APPLET_API_BASE_SENTINEL, apiBase)
165-
return new Response(swapped, { headers: { 'Content-Type': 'text/javascript; charset=utf-8' } })
190+
return new Response(swapped, {
191+
headers: { 'Content-Type': JS_CONTENT_TYPE, 'Cache-Control': IMMUTABLE_CACHE }
192+
})
166193
}
167-
return new Response(bunFile)
194+
return new Response(bunFile, {
195+
headers: {
196+
'Content-Type': bunFile.type || 'application/octet-stream',
197+
'Cache-Control': IMMUTABLE_CACHE
198+
}
199+
})
168200
}
169201

170202
// ---- route helpers ----------------------------------------------------------
@@ -255,30 +287,45 @@ export function resolveWorkspaceMediaFile(workspaceRoot: string, tail: string):
255287
// Range is handled explicitly (slice the BunFile, 206 + Content-Range): Bun's
256288
// implicit range handling for `new Response(Bun.file())` doesn't fire once any
257289
// headers object is attached, and <video>/<audio> seeking needs byte ranges.
290+
//
291+
// Caching: these are the user's own workspace files — private, and rewritten in
292+
// place by the agent (a regenerated clip keeps its path). So they must NEVER be
293+
// stored by a shared/edge cache sitting in front of moi, or one user's video
294+
// could be served to the next, and an edited file could be served stale from a
295+
// stable url (the applet-bundle incident, but with private bytes). `private`
296+
// keeps them off the edge; an ETag (size+mtime) + `no-cache` lets the browser
297+
// revalidate cheaply (304) while guaranteeing a re-fetch when the file changes.
258298
export async function serveWorkspaceFile(
259299
workspaceRoot: string,
260300
tail: string,
261-
range?: string | null
301+
range?: string | null,
302+
ifNoneMatch?: string | null
262303
): Promise<Response> {
263304
const realTarget = resolveWorkspaceMediaFile(workspaceRoot, tail)
264305
if (realTarget instanceof Response) return realTarget
265306
const file = Bun.file(realTarget)
266307

267308
const size = file.size
268309
const type = file.type || 'application/octet-stream'
310+
311+
const etag = `"${size}-${Math.trunc(file.lastModified)}"`
312+
const cache = { ETag: etag, 'Cache-Control': 'private, no-cache' }
313+
if (ifNoneMatch === etag) return new Response(null, { status: 304, headers: cache })
314+
269315
const parsed = range ? parseByteRange(range, size) : null
270316

271317
if (parsed === 'invalid') {
272318
return new Response('Range Not Satisfiable', {
273319
status: 416,
274-
headers: { 'Content-Range': `bytes */${size}`, 'Accept-Ranges': 'bytes' }
320+
headers: { ...cache, 'Content-Range': `bytes */${size}`, 'Accept-Ranges': 'bytes' }
275321
})
276322
}
277323
if (parsed) {
278324
const { start, end } = parsed
279325
return new Response(file.slice(start, end + 1), {
280326
status: 206,
281327
headers: {
328+
...cache,
282329
'Content-Type': type,
283330
'Content-Length': String(end - start + 1),
284331
'Content-Range': `bytes ${start}-${end}/${size}`,
@@ -287,7 +334,12 @@ export async function serveWorkspaceFile(
287334
})
288335
}
289336
return new Response(file, {
290-
headers: { 'Content-Type': type, 'Content-Length': String(size), 'Accept-Ranges': 'bytes' }
337+
headers: {
338+
...cache,
339+
'Content-Type': type,
340+
'Content-Length': String(size),
341+
'Accept-Ranges': 'bytes'
342+
}
291343
})
292344
}
293345

server/test/applets.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,45 @@ describe('serveApplet', () => {
7575
expect(res.status).toBe(200)
7676
})
7777

78+
test('entry index.js revalidates: ETag + no-cache, 304 on match', async () => {
79+
// The entry's url is stable across rebuilds, so an edge cache must revalidate
80+
// it or it serves a stale bundle. It carries an ETag + `no-cache`; a request
81+
// echoing that ETag gets a bodyless 304.
82+
seedApplet('views', 'editor', { 'index.js': 'export default 1' })
83+
const res = await serveApplet('view', 'editor', 'index.js', WS, BASE)
84+
expect(res.status).toBe(200)
85+
expect(res.headers.get('cache-control')).toBe('no-cache')
86+
const etag = res.headers.get('etag')
87+
expect(etag).toBeTruthy()
88+
89+
const revalidated = await serveApplet('view', 'editor', 'index.js', WS, BASE, etag)
90+
expect(revalidated.status).toBe(304)
91+
expect(revalidated.headers.get('etag')).toBe(etag)
92+
expect(await revalidated.text()).toBe('')
93+
})
94+
95+
test('a stale ETag on the entry still serves fresh bytes (200)', async () => {
96+
seedApplet('views', 'editor', { 'index.js': 'export default 1' })
97+
const res = await serveApplet('view', 'editor', 'index.js', WS, BASE, '"stale"')
98+
expect(res.status).toBe(200)
99+
expect(await res.text()).toContain('export default 1')
100+
})
101+
102+
test('hashed chunks and assets are immutable — never revalidated', async () => {
103+
// A content-hashed url is a fingerprint of its bytes, so it can be cached
104+
// forever at the edge and in the browser.
105+
seedApplet('widgets', 'clock', {
106+
'index.js': '//',
107+
'chunk-9f.js': 'export const x=1',
108+
'logo-abc123.png': new Uint8Array([0x89, 0x50, 0x4e, 0x47])
109+
})
110+
const chunk = await serveApplet('widget', 'clock', 'chunk-9f.js', WS, BASE)
111+
expect(chunk.headers.get('cache-control')).toBe('public, max-age=31536000, immutable')
112+
expect(chunk.headers.get('etag')).toBeNull()
113+
const asset = await serveApplet('widget', 'clock', 'logo-abc123.png', WS, BASE)
114+
expect(asset.headers.get('cache-control')).toBe('public, max-age=31536000, immutable')
115+
})
116+
78117
test('400 for a dotfile request', async () => {
79118
seedApplet('views', 'editor', { 'index.js': '//' })
80119
expect((await serveApplet('view', 'editor', '.env', WS, BASE)).status).toBe(400)
@@ -164,6 +203,31 @@ describe('serveWorkspaceFile (fileUrl streaming)', () => {
164203
expect(res.headers.get('content-length')).toBe('4')
165204
})
166205

206+
test('is private + revalidated, never edge-cached (ETag + private, no-cache)', async () => {
207+
seedFile('clips/a.mp4', new Uint8Array([1, 2, 3, 4]))
208+
const res = await serveWorkspaceFile(WS, 'clips/a.mp4')
209+
expect(res.status).toBe(200)
210+
// `private` keeps the user's file off any shared/edge cache; `no-cache`
211+
// forces the browser to revalidate so an agent-rewritten file isn't stale.
212+
expect(res.headers.get('cache-control')).toBe('private, no-cache')
213+
const etag = res.headers.get('etag')
214+
expect(etag).toBeTruthy()
215+
216+
const revalidated = await serveWorkspaceFile(WS, 'clips/a.mp4', undefined, etag)
217+
expect(revalidated.status).toBe(304)
218+
expect(revalidated.headers.get('etag')).toBe(etag)
219+
expect(revalidated.headers.get('cache-control')).toBe('private, no-cache')
220+
expect(await revalidated.text()).toBe('')
221+
})
222+
223+
test('a range request still carries the private cache headers', async () => {
224+
seedFile('clips/a.mp4', new Uint8Array([10, 11, 12, 13, 14, 15]))
225+
const res = await serveWorkspaceFile(WS, 'clips/a.mp4', 'bytes=1-3')
226+
expect(res.status).toBe(206)
227+
expect(res.headers.get('cache-control')).toBe('private, no-cache')
228+
expect(res.headers.get('etag')).toBeTruthy()
229+
})
230+
167231
test('honors a byte range (206 + Content-Range)', async () => {
168232
seedFile('clips/a.mp4', new Uint8Array([10, 11, 12, 13, 14, 15, 16, 17, 18, 19]))
169233
const res = await serveWorkspaceFile(WS, 'clips/a.mp4', 'bytes=2-5')

server/views.ts

44 Bytes
Binary file not shown.

server/widgets.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@ export function serveWidget(
158158
name: string,
159159
file: string,
160160
workspacePath: string,
161-
apiBase: string
161+
apiBase: string,
162+
ifNoneMatch?: string | null
162163
): Promise<Response> {
163-
return serveApplet('widget', name, file, workspacePath, apiBase)
164+
return serveApplet('widget', name, file, workspacePath, apiBase, ifNoneMatch)
164165
}

0 commit comments

Comments
 (0)