openpencil/docker/static-server.mjs
Vitali sharp8n b9b04fb28d font-proxy
2026-08-27 01:42:41 +03:00

136 lines
4.3 KiB
JavaScript

// 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'
// W4C fork delta: same-origin proxy for the CORS-hostile online-font catalog
// (https://fonts.google.com/metadata/fonts). 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.
const FONT_PROXY_PREFIX = '/__font-proxy'
const PROXIED_FONT_HOSTS = ['fonts.google.com']
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) {
const target = new URL(req.url ?? '/', `http://${hostHeader}`).searchParams.get('url')
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: {
'user-agent': FONT_PROXY_USER_AGENT,
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 = '/'
}
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`)
})