Files
masi a07522a189 MAGIK — Commander deck builder
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>
2026-10-01 21:12:19 -03:00

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)`)
})