🌳
pt0/deployF/staticSiteF/devserverWithRoutesAI.mts
1import fs from 'fs/promises'
2import { existsSync } from 'fs'
3import http from 'http'
4import path from 'path'
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[]}) => {
20 await maybeKillExisting(svcPortNo)
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))
31 }
33 for (const {prefix, dir} of routes) {
34 await fs.symlink(dir, path.join(tmpDir, prefix))
35 }
37 const notifyReload = () => {
38 for (const res of reloadClients) res.write(`data: reload\n\n`)
39 reloadClients.clear()
40 }
42 watchFilesDebounced(root, async (event, filePath) => {
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(() => {})
49 notifyReload()
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)
60 notifyReload()
61 }
62 await regenerate()
63 if (watchFiles?.length) {
64 watchFilesDebounced(watchFiles, regenerate, {stabilityMs: 100})
65 }
66 }
68 for (const df of dynamicFiles) {
69 await registerDynamicFile(df)
70 if (blogpostPathPrefix && df.path.startsWith(blogpostPathPrefix + '/')) {
71 registeredBlogposts.add(df.path)
72 }
73 }
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)
82 }
83 }
85 if (blogpostsDir && makeBlogpostDynamicFile) {
86 watchFilesDebounced(blogpostsDir, async (event, filePath) => {
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)
99 }, {depth: 1})
100 }
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))
107 }
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'})
117 res.end(result)
118 }).catch(err => {
119 console.error(`[onDemand ${prefix}] ${err.message}`)
120 next()
121 })
122 })
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(/^\//, '')
130 if (!isMediaRelPath(relPath)) return next()
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()
135 next()
136 }
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}`)
143 return
144 }
145 throw err
146 }
147 process.on('uncaughtException', transientStreamErrHandler('uncaughtException'))
148 process.on('unhandledRejection', transientStreamErrHandler('unhandledRejection'))
150 const server = httpServer.createServer({
151 root: tmpDir,
152 cache: -1,
153 before: [sseMiddleware, ...onDemandMiddleware, mediaFallbackMiddleware],
154 logFn: (req: any) => console.log(`${req.method} ${req.url}`)
155 })
157 if (codeWatchFiles.length) {
158 let restartTimer: ReturnType<typeof setTimeout> | null = null
159 watchFilesDebounced(codeWatchFiles, (_event, filePath) => {
160 if (restartTimer) return
161 console.log(`[devserver] Code change (${path.basename(filePath)}), restarting...`)
162 restartTimer = setTimeout(() => process.exit(codeChangeExitCode), 100)
163 }, {stabilityMs: 300})
164 }
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`)
170 process.exit(1)
171 }
172 throw err
173 })
175 const cleanup = async () => {
176 server.close()
177 await fs.rm(tmpDir, {recursive: true, force: true}).catch(() => {})
178 process.exit(0)
179 }
180 process.on('SIGINT', cleanup)
181 process.on('SIGTERM', cleanup)
183 server.listen(svcPortNo, '127.0.0.1', () => {
184 console.log(`http://${host}:${svcPortNo}`)
185 })
187 await new Promise(() => {}) // keep alive