openpencil/docker/static-server.mjs
Vitali sharp8n c6a65c4878 feat(web): ship the Vue editor web build with online font providers
- Add Dockerfile.web-vue + docker/ container: static SPA on :3100 and the
  MCP Streamable-HTTP server on :7600 in one image, replacing the retired
  Rust web host used by the w4c iframe integration
- Allow online font providers (Google Fonts, Fontsource, Bunny, Fontshare)
  to load in the browser via native CORS-enabled fetch instead of gating
  them on the Tauri fetch proxy
- Rename webFontProvidersRequireDesktopApp to webFontLoadFailed and reword
  the toast for connection failures; drop the unconditional toast from the
  font family picker
- Support OPENPENCIL_MCP_HOST so the MCP server binds 0.0.0.0 in containers
2026-08-22 00:33:54 +03:00

81 lines
2.5 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'
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 === '/') 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`)
})