/**
* JSON API Router with Content Negotiation
*
* Pattern-matched routes. Returns JSON by default.
* If Accept: text/html AND a renderer is provided, returns SSR HTML.
*
* Usage:
* const router = new Router('/prefix')
* router.get('/api/repos', handler)
* router.get('/repos/:name', handler, { render: renderRepoPage })
*
* // In HTTP server:
* const handled = await router.handle(req, res)
*/
export class Router {
constructor (prefix = '', opts = {}) {
this.prefix = prefix
this._routes = []
this._cors = opts.cors === true
this._corsOrigin = opts.corsOrigin || '*'
}
get (pattern, handler, opts) { this._routes.push({ method: 'GET', pattern, handler, opts }) }
post (pattern, handler, opts) { this._routes.push({ method: 'POST', pattern, handler, opts }) }
patch (pattern, handler, opts) { this._routes.push({ method: 'PATCH', pattern, handler, opts }) }
del (pattern, handler, opts) { this._routes.push({ method: 'DELETE', pattern, handler, opts }) }
async handle (req, res) {
const method = req.method
const url = req.url.replace(this.prefix, '') || '/'
const [pathStr, queryString] = url.split('?')
// CORS preflight
if (this._cors && method === 'OPTIONS') {
setCorsHeaders(res, this._corsOrigin)
res.writeHead(204)
res.end()
return true
}
for (const route of this._routes) {
if (route.method !== method) continue
const params = match(route.pattern, pathStr)
if (params === null) continue
req.params = params
req.query = parseQuery(queryString)
try {
const data = await route.handler(req, res)
// If handler already sent response, we're done
if (res.writableEnded) return true
// Content negotiation
if (data !== undefined) {
const wantsHtml = req.headers.accept && req.headers.accept.includes('text/html')
if (wantsHtml && route.opts && route.opts.render) {
// SSR: render HTML from data
html(res, route.opts.render(data), 200, this._cors ? this._corsOrigin : null)
} else {
// JSON (default)
json(res, data, 200, this._cors ? this._corsOrigin : null)
}
}
} catch (err) {
if (!res.writableEnded) {
json(res, { error: err.message }, 500, this._cors ? this._corsOrigin : null)
}
}
return true
}
return false
}
}
function match (pattern, pathStr) {
const patternParts = pattern.split('/').filter(Boolean)
const pathParts = pathStr.split('/').filter(Boolean)
if (patternParts.length !== pathParts.length) return null
const params = {}
for (let i = 0; i < patternParts.length; i++) {
if (patternParts[i].startsWith(':')) {
params[patternParts[i].slice(1)] = decodeURIComponent(pathParts[i])
} else if (patternParts[i] !== pathParts[i]) {
return null
}
}
return params
}
function parseQuery (qs) {
if (!qs) return {}
const params = {}
for (const pair of qs.split('&')) {
const [k, v] = pair.split('=')
if (k) params[decodeURIComponent(k)] = v ? decodeURIComponent(v) : ''
}
return params
}
// Response helpers
export function json (res, data, status = 200, corsOrigin = null) {
const body = JSON.stringify(data)
const headers = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}
if (corsOrigin) headers['Access-Control-Allow-Origin'] = corsOrigin
res.writeHead(status, headers)
res.end(body)
}
export function html (res, content, status = 200, corsOrigin = null) {
const body = typeof content === 'string' ? content : String(content)
const headers = {
'Content-Type': 'text/html; charset=utf-8',
'Content-Length': Buffer.byteLength(body)
}
if (corsOrigin) headers['Access-Control-Allow-Origin'] = corsOrigin
res.writeHead(status, headers)
res.end(body)
}
export async function readBody (req, maxSize = 10 * 1024 * 1024) {
const chunks = []
let size = 0
for await (const chunk of req) {
size += chunk.length
if (size > maxSize) throw new Error('Body too large')
chunks.push(chunk)
}
return Buffer.concat(chunks)
}
export async function readJson (req) {
const body = await readBody(req)
return JSON.parse(body.toString())
}
/**
* Parse pagination from query params
* @param {Object} query - parsed query object (req.query)
* @param {{ defaultLimit?: number, maxLimit?: number }} opts
* @returns {{ limit: number, offset: number }}
*/
export function parsePagination (query, opts = {}) {
const maxLimit = opts.maxLimit || 100
const defaultLimit = opts.defaultLimit || 20
let limit = parseInt(query.limit, 10)
let offset = parseInt(query.offset, 10)
if (isNaN(limit) || limit < 1) limit = defaultLimit
if (limit > maxLimit) limit = maxLimit
if (isNaN(offset) || offset < 0) offset = 0
return { limit, offset }
}
function setCorsHeaders (res, origin) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.setHeader('Access-Control-Max-Age', '86400')
}
// ── HTTP Caching Helpers ─────────────────────────────────────
/**
* Set Cache-Control header
* @param {Object} res
* @param {{ maxAge?: number, public?: boolean, immutable?: boolean, noCache?: boolean }} opts
*/
export function cacheControl (res, opts = {}) {
const parts = []
if (opts.noCache) { parts.push('no-cache', 'no-store'); res.setHeader('Cache-Control', parts.join(', ')); return }
if (opts.public !== false) parts.push('public')
if (opts.maxAge != null) parts.push(`max-age=${opts.maxAge}`)
if (opts.immutable) parts.push('immutable')
res.setHeader('Cache-Control', parts.join(', '))
}
/**
* Send response with ETag. Returns true if 304 Not Modified was sent.
* @param {Object} req
* @param {Object} res
* @param {string} etag — ETag value (without quotes)
* @param {Function} sendFn — called with (res) if content needs to be sent
* @returns {boolean} true if 304 was sent
*/
export function withEtag (req, res, etag, sendFn) {
const quoted = `"${etag}"`
res.setHeader('ETag', quoted)
const ifNoneMatch = req.headers['if-none-match']
if (ifNoneMatch === quoted) {
res.writeHead(304)
res.end()
return true
}
sendFn(res)
return false
}
/**
* Send JSON response with short-lived cache (for dynamic data that changes on push)
*/
export function jsonCached (res, data, maxAge = 10) {
const body = JSON.stringify(data)
res.writeHead(200, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': `public, max-age=${maxAge}`
})
res.end(body)
}