Skip to content

Commit 2dc3154

Browse files
cossssminclaude
andcommitted
fix(serve): serve static.source directories in dev
Closes #1876 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 4d3a431 commit 2dc3154

5 files changed

Lines changed: 181 additions & 7 deletions

File tree

src/build.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { resolveConfig } from './config/index.ts'
88
import { EventManager } from './events/index.ts'
99
import { createRenderer } from './render/createRenderer.ts'
1010
import { normalizeComponentSources } from './utils/componentSources.ts'
11+
import { staticBase } from './utils/staticPaths.ts'
1112
import { buildTemplate, computeContentBase } from './render/buildTemplate.ts'
1213
import type { MaizzleConfig } from './types/index.ts'
1314

@@ -262,10 +263,3 @@ async function copyStatic(config: MaizzleConfig, outputPath: string): Promise<vo
262263
cpSync(file, destPath)
263264
}
264265
}
265-
266-
/** Absolute static (non-glob) prefix of a source pattern, used as the strip base. */
267-
function staticBase(pattern: string): string {
268-
const staticPart = pattern.split(/[*{?[]/)[0]
269-
// Treat both separators as trailing: resolved patterns use '\' on Windows.
270-
return resolve(/[/\\]$/.test(staticPart) ? staticPart : dirname(staticPart))
271-
}

src/serve.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import { setActiveRenderer } from './render/active.ts'
2020
import { serveCompatibility } from './server/compatibility.ts'
2121
import { serveLint } from './server/linter.ts'
2222
import { sendEmail } from './server/email.ts'
23+
import { serveStaticFile } from './server/static.ts'
2324
import { normalizeComponentSources } from './utils/componentSources.ts'
2425
import { createWatchedFileMatcher, deriveWatchRoots } from './utils/watchPaths.ts'
26+
import { staticBase } from './utils/staticPaths.ts'
2527
import type { MaizzleConfig } from './types/index.ts'
2628

2729
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -262,6 +264,34 @@ function maizzleDevPlugin(
262264

263265
let isWatchedFile = applyWatchPaths(config)
264266

267+
/**
268+
* Serve the `static.source` directories at `/<static.destination>`,
269+
* mirroring what `build` copies to the output dir, so templates that
270+
* reference `/images/logo.png` resolve in the preview iframe. Each
271+
* positive pattern's glob-free prefix is mounted (looked up on disk
272+
* per request, so files added after startup are served) and added
273+
* to the watcher so the preview refreshes on changes. Runs again
274+
* after a config reload; the middleware below reads the current
275+
* mounts on every request.
276+
*/
277+
const applyStaticMounts = (cfg: MaizzleConfig) => {
278+
const sources = cfg.static?.source ?? ['public/**/*.*']
279+
const destination = (cfg.static?.destination ?? 'public').replace(/^\/+|\/+$/g, '')
280+
const prefix = destination ? `/${destination}` : ''
281+
const bases = [...new Set(sources.filter(s => !s.startsWith('!')).map(staticBase))]
282+
283+
for (const base of bases) {
284+
server.watcher.add(base)
285+
}
286+
287+
return {
288+
mounts: bases.map(base => ({ prefix, base })),
289+
isStaticFile: createWatchedFileMatcher(sources, process.cwd()),
290+
}
291+
}
292+
293+
let { mounts: staticMounts, isStaticFile } = applyStaticMounts(config)
294+
265295
/**
266296
* Serialize watcher work onto one chain. The change handler closes and
267297
* recreates the renderer across awaits; without serialization a second
@@ -281,6 +311,8 @@ function maizzleDevPlugin(
281311
await renderer.invalidateAll()
282312
bumpGeneration()
283313
server.ws.send({ type: 'custom', event: 'maizzle:templates-changed' })
314+
} else if (isStaticFile(file)) {
315+
server.ws.send({ type: 'custom', event: 'maizzle:template-updated', data: { file } })
284316
}
285317
}))
286318

@@ -289,6 +321,8 @@ function maizzleDevPlugin(
289321
await renderer.invalidateAll()
290322
bumpGeneration()
291323
server.ws.send({ type: 'custom', event: 'maizzle:templates-changed' })
324+
} else if (isStaticFile(file)) {
325+
server.ws.send({ type: 'custom', event: 'maizzle:template-updated', data: { file } })
292326
}
293327
}))
294328

@@ -313,6 +347,7 @@ function maizzleDevPlugin(
313347
// content, components.source, root, or server.watch values keep
314348
// emitting events without a server restart.
315349
isWatchedFile = applyWatchPaths(config)
350+
;({ mounts: staticMounts, isStaticFile } = applyStaticMounts(config))
316351

317352
/**
318353
* Push UI-relevant config bits so the dev UI reacts to live edits
@@ -333,6 +368,7 @@ function maizzleDevPlugin(
333368
if (
334369
isTemplateFile(file)
335370
|| isWatchedFile(file)
371+
|| isStaticFile(file)
336372
) {
337373
server.ws.send({ type: 'custom', event: 'maizzle:template-updated', data: { file } })
338374
}
@@ -385,6 +421,19 @@ function maizzleDevPlugin(
385421
next()
386422
})
387423

424+
// Static assets from `static.source`, mounted at `/<static.destination>`
425+
server.middlewares.use((req: any, res: any, next: any) => {
426+
const url: string = req.url || '/'
427+
428+
for (const { prefix, base } of staticMounts) {
429+
if (url === prefix || url.startsWith(`${prefix}/`) || url.startsWith(`${prefix}?`)) {
430+
if (serveStaticFile(base, url.slice(prefix.length) || '/', res)) return
431+
}
432+
}
433+
434+
next()
435+
})
436+
388437
// Dev UI fallback (after Vite's middleware)
389438
return () => {
390439
server.middlewares.use(async (req: any, res: any, next: any) => {

src/server/static.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { createReadStream, statSync } from 'node:fs'
2+
import { extname, resolve, sep } from 'node:path'
3+
import type { ServerResponse } from 'node:http'
4+
5+
const MIME: Record<string, string> = {
6+
'.png': 'image/png',
7+
'.jpg': 'image/jpeg',
8+
'.jpeg': 'image/jpeg',
9+
'.gif': 'image/gif',
10+
'.webp': 'image/webp',
11+
'.avif': 'image/avif',
12+
'.svg': 'image/svg+xml',
13+
'.ico': 'image/x-icon',
14+
'.bmp': 'image/bmp',
15+
'.css': 'text/css',
16+
'.js': 'text/javascript',
17+
'.html': 'text/html',
18+
'.txt': 'text/plain',
19+
'.json': 'application/json',
20+
'.pdf': 'application/pdf',
21+
'.woff': 'font/woff',
22+
'.woff2': 'font/woff2',
23+
'.ttf': 'font/ttf',
24+
'.otf': 'font/otf',
25+
'.mp4': 'video/mp4',
26+
'.webm': 'video/webm',
27+
'.mp3': 'audio/mpeg',
28+
}
29+
30+
/**
31+
* Stream the file at `urlPath` (mount prefix already stripped) from
32+
* `base`. Returns false when there is nothing to serve — missing file,
33+
* directory, or a path escaping `base` — so the caller can fall through.
34+
*/
35+
export function serveStaticFile(base: string, urlPath: string, res: ServerResponse): boolean {
36+
let pathname: string
37+
38+
try {
39+
pathname = decodeURIComponent(urlPath.split('?')[0])
40+
} catch {
41+
return false
42+
}
43+
44+
const file = resolve(base, `.${pathname}`)
45+
46+
if (file !== base && !file.startsWith(base + sep)) {
47+
return false
48+
}
49+
50+
let size: number
51+
52+
try {
53+
const stat = statSync(file)
54+
if (!stat.isFile()) return false
55+
size = stat.size
56+
} catch {
57+
return false
58+
}
59+
60+
res.setHeader('Content-Type', MIME[extname(file).toLowerCase()] ?? 'application/octet-stream')
61+
res.setHeader('Content-Length', size)
62+
res.setHeader('Cache-Control', 'no-cache')
63+
createReadStream(file).pipe(res)
64+
65+
return true
66+
}

src/tests/serve.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,63 @@ describe('serve dev server', () => {
2727
rmSync(tempDir, { recursive: true, force: true })
2828
})
2929

30+
const baseUrl = () => server!.resolvedUrls!.local[0].replace(/\/$/, '')
31+
32+
it('serves static.source directories at /<static.destination>', async () => {
33+
mkdirSync(join(tempDir, 'images'), { recursive: true })
34+
writeFileSync(join(tempDir, 'images', 'logo.png'), 'png-bytes')
35+
36+
server = await serve({ config: { static: { source: ['images/**/*.*'], destination: 'images' } }, port: 3157, silent: true })
37+
38+
const res = await fetch(`${baseUrl()}/images/logo.png`)
39+
expect(res.status).toBe(200)
40+
expect(await res.text()).toBe('png-bytes')
41+
42+
expect((await fetch(`${baseUrl()}/images/missing.png`)).status).toBe(404)
43+
}, 30000)
44+
45+
it('serves the default public/ directory at / and /public', async () => {
46+
mkdirSync(join(tempDir, 'public'), { recursive: true })
47+
writeFileSync(join(tempDir, 'public', 'logo.png'), 'png-bytes')
48+
49+
server = await serve({ port: 3157, silent: true })
50+
51+
expect((await fetch(`${baseUrl()}/logo.png`)).status).toBe(200)
52+
expect((await fetch(`${baseUrl()}/public/logo.png`)).status).toBe(200)
53+
}, 30000)
54+
55+
it('serves static files added after the server started', async () => {
56+
server = await serve({ config: { static: { source: ['images/**/*.*'], destination: 'images' } }, port: 3157, silent: true })
57+
58+
mkdirSync(join(tempDir, 'images'), { recursive: true })
59+
writeFileSync(join(tempDir, 'images', 'late.png'), 'late')
60+
61+
const res = await fetch(`${baseUrl()}/images/late.png`)
62+
expect(res.status).toBe(200)
63+
expect(await res.text()).toBe('late')
64+
}, 30000)
65+
66+
it('re-mounts static directories when the config changes', async () => {
67+
writeFileSync(join(tempDir, 'maizzle.config.js'), "export default { static: { source: ['images/**/*.*'], destination: 'images' } }\n")
68+
mkdirSync(join(tempDir, 'images'), { recursive: true })
69+
mkdirSync(join(tempDir, 'assets'), { recursive: true })
70+
writeFileSync(join(tempDir, 'images', 'a.png'), 'a')
71+
writeFileSync(join(tempDir, 'assets', 'b.png'), 'b')
72+
73+
server = await serve({ port: 3157, silent: true })
74+
75+
expect((await fetch(`${baseUrl()}/images/a.png`)).status).toBe(200)
76+
expect((await fetch(`${baseUrl()}/assets/b.png`)).status).toBe(404)
77+
78+
writeFileSync(join(tempDir, 'maizzle.config.js'), "export default { static: { source: ['assets/**/*.*'], destination: 'assets' } }\n")
79+
server.watcher.emit('change', resolve(tempDir, 'maizzle.config.js'))
80+
81+
await vi.waitFor(async () => {
82+
expect((await fetch(`${baseUrl()}/assets/b.png`)).status).toBe(200)
83+
expect((await fetch(`${baseUrl()}/images/a.png`)).status).toBe(404)
84+
}, { timeout: 15000, interval: 100 })
85+
}, 30000)
86+
3087
it('refreshes the active renderer when the config file changes', async () => {
3188
writeFileSync(join(tempDir, 'maizzle.config.js'), 'export default {}\n')
3289

src/utils/staticPaths.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { dirname, resolve } from 'node:path'
2+
3+
/** Absolute static (non-glob) prefix of a source pattern, used as the strip base. */
4+
export function staticBase(pattern: string): string {
5+
const staticPart = pattern.split(/[*{?[]/)[0]
6+
// Treat both separators as trailing: resolved patterns use '\' on Windows.
7+
return resolve(/[/\\]$/.test(staticPart) ? staticPart : dirname(staticPart))
8+
}

0 commit comments

Comments
 (0)