a07522a189
React 19 SPA + node:http/node:sqlite API in one container. Card data is baked into the image from a Scryfall bulk build, so the runtime never calls Scryfall. No server-side user state: decks live in the browser and travel in the share URL, which is why there is no PVC and replicas: 1 is correct. Adds a README covering the architecture and the reasoning behind it, and publishes the project skills under .claude/skills/. Co-Authored-By: Claude Code <noreply@anthropic.com>
173 lines
5.5 KiB
JavaScript
173 lines
5.5 KiB
JavaScript
/**
|
|
* MAGIK HTTP server.
|
|
*
|
|
* One process serves both halves of the app: the read-only card API backed by
|
|
* `data/cards.db`, and the built SPA from `dist/`. Decks never reach the
|
|
* server — they live in the browser and travel in the URL — so this service
|
|
* holds no user state, needs no database of its own and is safe to scale or
|
|
* replace at any time.
|
|
*/
|
|
|
|
import { createServer } from 'node:http'
|
|
import { createReadStream, existsSync, statSync } from 'node:fs'
|
|
import { extname, join, normalize, resolve, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { createGzip } from 'node:zlib'
|
|
import { pipeline } from 'node:stream/promises'
|
|
import { searchCards, getCard, getCardsByIds, getCardsByNames, cardCount } from './db.mjs'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const DIST = resolve(ROOT, 'dist')
|
|
const PORT = Number(process.env.PORT ?? 8099)
|
|
const HOST = process.env.HOST ?? '0.0.0.0'
|
|
|
|
const MIME = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.webp': 'image/webp',
|
|
'.ico': 'image/x-icon',
|
|
'.woff2': 'font/woff2',
|
|
'.txt': 'text/plain; charset=utf-8',
|
|
}
|
|
const COMPRESSIBLE = new Set(['.html', '.js', '.css', '.json', '.svg', '.txt'])
|
|
|
|
function sendJson(res, status, body) {
|
|
const payload = Buffer.from(JSON.stringify(body))
|
|
res.writeHead(status, {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'content-length': payload.length,
|
|
'cache-control': 'no-store',
|
|
})
|
|
res.end(payload)
|
|
}
|
|
|
|
async function serveStatic(req, res, pathname) {
|
|
const rel = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '')
|
|
let file = join(DIST, rel)
|
|
|
|
// Anything that is not a real file under dist/ falls through to the SPA
|
|
// shell, so client-side routes survive a hard refresh or a shared link.
|
|
if (!file.startsWith(DIST) || !existsSync(file) || statSync(file).isDirectory()) {
|
|
file = join(DIST, 'index.html')
|
|
}
|
|
|
|
if (!existsSync(file)) {
|
|
sendJson(res, 503, { error: 'not built', hint: 'run `pnpm build` before starting the server' })
|
|
return
|
|
}
|
|
|
|
const ext = extname(file)
|
|
const stat = statSync(file)
|
|
const isShell = file.endsWith('index.html')
|
|
// Vite fingerprints hashed asset filenames, so those are safe to cache
|
|
// forever; the shell must always be revalidated.
|
|
const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs)}"`
|
|
|
|
if (req.headers['if-none-match'] === etag) {
|
|
res.writeHead(304, { etag })
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
const headers = {
|
|
'content-type': MIME[ext] ?? 'application/octet-stream',
|
|
etag,
|
|
'cache-control': isShell ? 'no-cache' : 'public, max-age=31536000, immutable',
|
|
'x-content-type-options': 'nosniff',
|
|
}
|
|
|
|
const acceptsGzip = /\bgzip\b/.test(req.headers['accept-encoding'] ?? '')
|
|
if (acceptsGzip && COMPRESSIBLE.has(ext) && stat.size > 1024) {
|
|
res.writeHead(200, { ...headers, 'content-encoding': 'gzip' })
|
|
await pipeline(createReadStream(file), createGzip({ level: 6 }), res).catch(() => {})
|
|
return
|
|
}
|
|
|
|
headers['content-length'] = stat.size
|
|
res.writeHead(200, headers)
|
|
await pipeline(createReadStream(file), res).catch(() => {})
|
|
}
|
|
|
|
const server = createServer(async (req, res) => {
|
|
try {
|
|
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`)
|
|
|
|
if (url.pathname.startsWith('/api/')) {
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
sendJson(res, 405, { error: 'method not allowed' })
|
|
return
|
|
}
|
|
|
|
const { pathname, searchParams } = url
|
|
|
|
if (pathname === '/api/health') {
|
|
sendJson(res, 200, { ok: true, cards: cardCount() })
|
|
return
|
|
}
|
|
if (pathname === '/api/lookup') {
|
|
const names = searchParams.getAll('name')
|
|
sendJson(res, 200, getCardsByNames(names))
|
|
return
|
|
}
|
|
if (pathname === '/api/search') {
|
|
sendJson(res, 200, searchCards(searchParams))
|
|
return
|
|
}
|
|
if (pathname === '/api/cards') {
|
|
// A deck needs many cards at once; ids come in as a repeated query
|
|
// parameter so a large deck never needs a bespoke endpoint.
|
|
const ids = searchParams.getAll('id')
|
|
if (ids.length > 400) {
|
|
sendJson(res, 413, { error: 'too many ids' })
|
|
return
|
|
}
|
|
sendJson(res, 200, { cards: getCardsByIds(ids) })
|
|
return
|
|
}
|
|
|
|
const card = pathname.match(/^\/api\/cards\/([0-9a-f-]{36})$/i)
|
|
if (card) {
|
|
const found = getCard(card[1])
|
|
if (!found) {
|
|
sendJson(res, 404, { error: 'card not found' })
|
|
return
|
|
}
|
|
sendJson(res, 200, found)
|
|
return
|
|
}
|
|
|
|
const commanders = pathname === '/api/commanders'
|
|
if (commanders) {
|
|
searchParams.set('commander', '1')
|
|
searchParams.set('limit', searchParams.get('limit') ?? '60')
|
|
sendJson(res, 200, searchCards(searchParams))
|
|
return
|
|
}
|
|
|
|
sendJson(res, 404, { error: 'unknown endpoint' })
|
|
return
|
|
}
|
|
|
|
await serveStatic(req, res, url.pathname)
|
|
} catch (err) {
|
|
console.error(err)
|
|
if (!res.headersSent) sendJson(res, 500, { error: 'internal error' })
|
|
else res.end()
|
|
}
|
|
})
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
let count = '?'
|
|
try {
|
|
count = cardCount().toLocaleString('en-US')
|
|
} catch {
|
|
/* the health probe reports the missing index; listening must not fail */
|
|
}
|
|
console.log(`MAGIK listening on http://${HOST}:${PORT} (${count} Commander-legal cards)`)
|
|
})
|