// 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 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. const FONT_PROXY_PREFIX = '/__font-proxy' const PROXIED_FONT_HOSTS = ['fonts.google.com', 'fonts.googleapis.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 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') 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': ua ?? 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`) })