openpencil/docker/static-server.mjs

141 lines
4.6 KiB
JavaScript
Raw Normal View History

// Minimal static SPA server for the OpenPencil Vue editor (dist/).
//
// Serves the built SPA from OPENPENCIL_WEB_ROOT (default ./dist) on
// OPENPENCIL_WEB_PORT (default 3100). Implements the SPA fallback that
// Cloudflare Pages provides via dist/_redirects (`/* /index.html 200`), plus
// correct MIME types — notably application/wasm for CanvasKit.
//
// Runs under bun in the container: `bun static-server.mjs`.
import { createServer } from 'node:http'
import { readFile, stat } from 'node:fs/promises'
import { extname, join, normalize } from 'node:path'
const ROOT = process.env.OPENPENCIL_WEB_ROOT ?? join(import.meta.dir, 'dist')
const PORT = Number(process.env.OPENPENCIL_WEB_PORT ?? 3100)
const HOST = process.env.OPENPENCIL_WEB_HOST ?? '0.0.0.0'
2026-09-05 08:25:33 +00:00
// W4C fork delta: same-origin proxy for CORS-hostile online-font endpoints
// (https://fonts.google.com/metadata/fonts and https://fonts.googleapis.com/css2).
// The browser app (which is a W4C fork) rewrites those requests to
// /__font-proxy?url=... so they stay same-origin; we forward them upstream here.
// Mirrors vite/font-proxy.ts.
2026-08-26 22:42:41 +00:00
const FONT_PROXY_PREFIX = '/__font-proxy'
2026-09-05 08:25:33 +00:00
const PROXIED_FONT_HOSTS = ['fonts.google.com', 'fonts.googleapis.com']
2026-08-26 22:42:41 +00:00
const FONT_PROXY_USER_AGENT =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36'
async function handleFontProxy(req, res, hostHeader) {
2026-09-05 08:25:33 +00:00
const url = new URL(req.url ?? '/', `http://${hostHeader}`)
const target = url.searchParams.get('url')
// The browser forwards the original `user-agent` here because it cannot send
// one cross-origin; unifont uses it to pick the glyph format (ttf/otf/woff2).
const ua = url.searchParams.get('ua')
2026-08-26 22:42:41 +00:00
if (!target) {
res.writeHead(400)
res.end('missing url')
return
}
let targetUrl
try {
targetUrl = new URL(decodeURIComponent(target))
} catch {
res.writeHead(400)
res.end('invalid url')
return
}
if (targetUrl.protocol !== 'https:' || !PROXIED_FONT_HOSTS.includes(targetUrl.host)) {
res.writeHead(403)
res.end('proxied host denied')
return
}
try {
const upstream = await fetch(targetUrl, {
headers: {
2026-09-05 08:25:33 +00:00
'user-agent': ua ?? FONT_PROXY_USER_AGENT,
2026-08-26 22:42:41 +00:00
accept: 'application/json, text/plain, */*'
},
redirect: 'follow'
})
const body = Buffer.from(await upstream.arrayBuffer())
const type = upstream.headers.get('content-type')
res.writeHead(upstream.status, {
'content-type': type ?? 'application/octet-stream',
'cache-control': upstream.status === 200 ? 'public, max-age=86400' : 'no-store',
'access-control-allow-origin': '*'
})
res.end(body)
} catch (err) {
res.writeHead(502)
res.end(String(err))
}
}
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.wasm': 'application/wasm',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.txt': 'text/plain; charset=utf-8',
'.webmanifest': 'application/manifest+json'
}
const server = createServer(async (req, res) => {
try {
let pathname
try {
pathname = decodeURIComponent(new URL(req.url ?? '/', `http://${req.headers.host}`).pathname)
} catch {
pathname = '/'
}
2026-08-26 22:42:41 +00:00
if (pathname === FONT_PROXY_PREFIX) {
await handleFontProxy(req, res, req.headers.host)
return
}
if (pathname === '/') pathname = '/index.html'
// Prevent path traversal outside ROOT.
const filePath = normalize(join(ROOT, pathname))
if (!filePath.startsWith(ROOT)) {
res.writeHead(403)
res.end('Forbidden')
return
}
let fullPath = filePath
try {
const s = await stat(fullPath)
if (s.isDirectory()) fullPath = join(fullPath, 'index.html')
} catch {
// 404 → SPA fallback to index.html (vite preview behaviour).
fullPath = join(ROOT, 'index.html')
}
const body = await readFile(fullPath)
res.writeHead(200, {
'content-type': MIME[extname(fullPath)] ?? 'application/octet-stream',
'cache-control': extname(fullPath) === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable'
})
res.end(body)
} catch (err) {
res.writeHead(500)
res.end(`Internal Server Error: ${String(err)}`)
}
})
server.listen(PORT, HOST, () => {
process.stderr.write(`Static server: http://${HOST}:${PORT} (root ${ROOT})\n`)
})