/**
* Static File Server + SPA Fallback
*
* Serves files from a web/ directory. Falls back to index.html
* for SPA routing (any path not matching a real file → index.html).
*/
import fs from 'bare-fs'
import path from 'bare-path'
const MIME_TYPES = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.woff': 'font/woff'
}
/**
* Serve static files from a directory
*
* @param {string} webDir — root directory for static files
* @param {Object} opts
* @param {string} opts.prefix — URL prefix (e.g., '/web')
* @param {boolean} opts.spaFallback — serve index.html for unmatched paths (default: true)
* @returns {Function} handler(req, res) → boolean (true if handled)
*/
export function serveStatic (webDir, opts = {}) {
const prefix = opts.prefix || '/web'
const spaFallback = opts.spaFallback !== false
const indexPath = path.join(webDir, 'index.html')
return function handleStatic (req, res) {
if (req.method !== 'GET') return false
const urlPath = req.url.split('?')[0]
// Serve files under prefix
if (urlPath === prefix || urlPath === prefix + '/') {
// Prefix root → serve index.html
try {
const data = fs.readFileSync(indexPath)
res.writeHead(200, { 'Content-Type': 'text/html', 'Content-Length': data.length })
res.end(data)
return true
} catch { return false }
}
if (urlPath.startsWith(prefix + '/')) {
const relPath = urlPath.slice(prefix.length)
const filePath = path.join(webDir, relPath)
try {
const data = fs.readFileSync(filePath)
const ext = path.extname(filePath)
res.writeHead(200, {
'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
'Content-Length': data.length
})
res.end(data)
return true
} catch {
return false
}
}
// SPA fallback — serve index.html for any non-API, non-static path
if (spaFallback && !urlPath.startsWith('/api/') && !urlPath.includes('.')) {
try {
const data = fs.readFileSync(indexPath)
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(data)
return true
} catch {
return false
}
}
return false
}
}