OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-web-ui / lib / router.js

221 lines · javascript ref:
/**
 * 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)
}