1import fs from 'fs/promises' 2import { existsSync } from 'fs' 5// @ts-expect-error no types for http-server 6import httpServer from 'http-server' 13export const codeChangeExitCode = 44 15const reloadClients = new Set<http.ServerResponse>() 17const reloadInjectScript = `<script>(()=>{const es=new EventSource('/__reload');es.onmessage=()=>location.reload()})()</script>` 19export const startDevserverWithRoutes = async ({root, routes = [], dynamicFiles = [], codeWatchFiles = [], svcPortNo, hostname, blogpostsDir, makeBlogpostDynamicFile, blogpostPathPrefix, blogpostAssetDirs = [], onDemandHandlers = [], mediaBaseUrlA = mediaSiteBaseUrlA}: {root: string, routes?: {prefix: string, dir: string}[], dynamicFiles?: {path: string, generate: () => Promise<string>, watchFiles?: string[]}[], codeWatchFiles?: string[], svcPortNo: number, hostname?: string, blogpostsDir?: string, makeBlogpostDynamicFile?: (p: {slug: string, dir: string | null, mdPath: string}) => any, blogpostPathPrefix?: string, blogpostAssetDirs?: {prefix: string, dir: string}[], onDemandHandlers?: {prefix: string, handler: (urlPath: string) => Promise<string | null>, contentType?: string | ((s: string) => string)}[], mediaBaseUrlA?: string[]}) => { 22 const tmpDir = path.join(ptTmpDir, `devserver-${Date.now()}`) 23 await fs.rm(tmpDir, {recursive: true, force: true}) 24 await fs.mkdir(tmpDir, {recursive: true}) 26 const routePrefixes = new Set(routes.map(r => r.prefix)) 27 const rootEntries = await fs.readdir(root).catch(() => []) 28 for (const entry of rootEntries) { 29 if (routePrefixes.has(entry)) continue 30 await fs.symlink(path.join(root, entry), path.join(tmpDir, entry)) 33 for (const {prefix, dir} of routes) { 34 await fs.symlink(dir, path.join(tmpDir, prefix)) 37 const notifyReload = () => { 38 for (const res of reloadClients) res.write(`data: reload\n\n`) 43 if (event !== 'add' && event !== 'addDir') return 44 const entry = path.basename(filePath) 45 if (routePrefixes.has(entry)) return 46 const linkPath = path.join(tmpDir, entry) 47 if (await fs.stat(linkPath).catch(() => null)) return 48 await fs.symlink(path.join(root, entry), linkPath).catch(() => {}) 50 }, {stabilityMs: 100, depth: 0}) 52 const registeredBlogposts = new Set() 53 const registerDynamicFile = async ({path: filePath, generate, watchFiles}: {path: string, generate: () => Promise<string>, watchFiles?: string[]}) => { 54 const fullPath = path.join(tmpDir, filePath) 55 const regenerate = async () => { 56 const content = await generate() 57 await fs.mkdir(path.dirname(fullPath), {recursive: true}) 58 const injected = filePath.endsWith('.html') ? content.replace('</body>', `${reloadInjectScript}</body>`) : content 59 await fs.writeFile(fullPath, injected) 63 if (watchFiles?.length) { 68 for (const df of dynamicFiles) { 69 await registerDynamicFile(df) 70 if (blogpostPathPrefix && df.path.startsWith(blogpostPathPrefix + '/')) { 71 registeredBlogposts.add(df.path) 75 for (const {prefix, dir} of blogpostAssetDirs) { 76 const destDir = path.join(tmpDir, prefix) 77 await fs.mkdir(destDir, {recursive: true}) 78 for (const f of await fs.readdir(dir)) { 79 if (f.endsWith('.md')) continue 80 const dest = path.join(destDir, f) 81 if (!await fs.stat(dest).catch(() => null)) await fs.symlink(path.join(dir, f), dest) 85 if (blogpostsDir && makeBlogpostDynamicFile) { 87 if (event !== 'add') return 88 const isIndexMd = filePath.endsWith('/index.md') 89 const isFlatMd = !isIndexMd && filePath.endsWith('.md') && path.dirname(filePath) === blogpostsDir 90 if (!isIndexMd && !isFlatMd) return 91 const slug = isIndexMd ? path.basename(path.dirname(filePath)) : path.basename(filePath, '.md') 92 const dfPath = path.join(blogpostPathPrefix!, slug, 'index.html') 93 if (registeredBlogposts.has(dfPath)) return 94 console.log(`New blogpost detected: ${slug}`) 95 const dir = isIndexMd ? path.dirname(filePath) : null 96 const df = makeBlogpostDynamicFile({slug, dir, mdPath: filePath}) 97 registeredBlogposts.add(dfPath) 98 await registerDynamicFile(df) 102 const sseMiddleware = (req: any, res: any, next: () => void) => { 103 if (req.url !== '/__reload') return next() 104 res.writeHead(200, {'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive'}) 105 reloadClients.add(res) 106 req.on('close', () => reloadClients.delete(res)) 109 const onDemandMiddleware = onDemandHandlers.map(({prefix, handler, contentType}) => (req: any, res: any, next: () => void) => { 110 const url = new URL(req.url, 'http://localhost') 111 if (!url.pathname.startsWith(prefix)) return next() 112 const subPath = url.pathname.slice(prefix.length) 113 handler(subPath).then(result => { 114 if (result == null) return next() 115 const ctype = typeof contentType === 'function' ? contentType(subPath) : (contentType || 'text/html; charset=utf-8') 116 res.writeHead(200, {'Content-Type': ctype, 'Cache-Control': 'no-cache'}) 119 console.error(`[onDemand ${prefix}] ${err.message}`) 124 // Media excluded from the git export (audio/json/pdfs/images) is served by the live site at 125 // identical paths — download-through-cache to disk once, then serve statically (ranges/mime). 126 // Server-side fetch: no browser CORS/WAF exposure. Local files always win. 127 const mediaFallbackMiddleware = async (req: any, res: any, next: () => void) => { 128 const url = new URL(req.url, 'http://localhost') 129 const relPath = url.pathname.replace(/^\//, '') 131 const localPath = path.join(tmpDir, relPath) 132 if (existsSync(localPath)) return next() 133 const ok = await proxyMediaCached({relPath, destPath: localPath, baseUrlA: mediaBaseUrlA}).catch(() => false) 134 if (!ok) return next() 138 // Upstream h2 stream resets (e.g. site redeploys) surface as unhandled undici 'error' events, 139 // not rejections — log and continue for known-transient causes, crash loudly otherwise. 140 const transientStreamErrHandler = (label: string) => (err: any) => { 142 console.error(`[devserver] continuing past transient stream error (${label}): ${err?.stack || err}`) 147 process.on('uncaughtException', transientStreamErrHandler('uncaughtException')) 148 process.on('unhandledRejection', transientStreamErrHandler('unhandledRejection')) 150 const server = httpServer.createServer({ 153 before: [sseMiddleware, ...onDemandMiddleware, mediaFallbackMiddleware], 154 logFn: (req: any) => console.log(`${req.method} ${req.url}`) 157 if (codeWatchFiles.length) { 158 let restartTimer: ReturnType<typeof setTimeout> | null = null 160 if (restartTimer) return 161 console.log(`[devserver] Code change (${path.basename(filePath)}), restarting...`) 162 restartTimer = setTimeout(() => process.exit(codeChangeExitCode), 100) 163 }, {stabilityMs: 300}) 166 const host = hostname || '127.0.0.1' 167 server.server.on('error', (err: any) => { 168 if (err.code === 'EADDRINUSE') { 169 console.error(`Port ${svcPortNo} in use. Tip: use --${killExistingKey} to kill the existing process`) 175 const cleanup = async () => { 177 await fs.rm(tmpDir, {recursive: true, force: true}).catch(() => {}) 180 process.on('SIGINT', cleanup) 181 process.on('SIGTERM', cleanup) 183 server.listen(svcPortNo, '127.0.0.1', () => { 184 console.log(`http://${host}:${svcPortNo}`) 187 await new Promise(() => {}) // keep alive