141 lines
4.6 KiB
JavaScript
141 lines
4.6 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 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`)
|
|
})
|