OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking / lib / router.js

118 lines · javascript ref:
/**
 * Router — pattern-based HTTP router.
 * Same pattern as hypernote/pear-git.
 */

import { isAppError } from './errors.js'

export class Router {
  constructor (server) {
    this.server = server
    this.routes = []
  }

  get (pattern, ...handlers) {
    this.routes.push({ method: 'GET', pattern, handlers })
    return this
  }

  post (pattern, ...handlers) {
    this.routes.push({ method: 'POST', pattern, handlers })
    return this
  }

  put (pattern, ...handlers) {
    this.routes.push({ method: 'PUT', pattern, handlers })
    return this
  }

  delete (pattern, ...handlers) {
    this.routes.push({ method: 'DELETE', pattern, handlers })
    return this
  }

  all (pattern, ...handlers) {
    this.routes.push({ method: '*', pattern, handlers })
    return this
  }

  patternToRegex (pattern) {
    const regexStr = pattern
      .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
      .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '(?<$1>[^/]+)')
      .replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, '(?<$1>.+)')
    return new RegExp('^' + regexStr + '$')
  }

  match (route, method, url) {
    if (route.method !== '*' && route.method !== method) return null
    if (!route.regex) route.regex = this.patternToRegex(route.pattern)
    const match = url.match(route.regex)
    if (!match) return null
    const params = match.groups || {}
    for (const key in params) {
      if (params[key]) {
        try { params[key] = decodeURIComponent(params[key]) } catch {}
      }
    }
    return { params }
  }

  normalizeUrl (url) {
    const queryIndex = url.indexOf('?')
    let pathOnly = queryIndex >= 0 ? url.substring(0, queryIndex) : url
    if (this.server.pathPrefix && pathOnly.startsWith(this.server.pathPrefix)) {
      pathOnly = pathOnly.substring(this.server.pathPrefix.length) || '/'
    }
    if (pathOnly.length > 1 && pathOnly.endsWith('/')) {
      pathOnly = pathOnly.slice(0, -1)
    }
    return pathOnly
  }

  async dispatch (req, res) {
    const url = this.normalizeUrl(req.url)
    const method = req.method

    for (const route of this.routes) {
      const match = this.match(route, method, url)
      if (match) {
        req.params = match.params
        let skipped = false
        for (const handler of route.handlers) {
          try {
            const result = await handler(req, res, this.server)
            if (result === false) { skipped = true; break }
            if (res.writableEnded) return true
          } catch (err) {
            this.handleError(err, req, res)
            return true
          }
        }
        if (!skipped) return true
      }
    }
    return false
  }

  handleError (err, req, res) {
    if (res.writableEnded) return

    if (isAppError(err)) {
      res.statusCode = err.status || 500
      res.setHeader('Content-Type', 'application/json')
      if (err.status === 401) res.setHeader('WWW-Authenticate', 'Bearer realm="hyper-booking"')
      if (err.retryAfter) res.setHeader('Retry-After', String(err.retryAfter))
      res.end(JSON.stringify(err.toJSON()))
      if (err.status >= 500) {
        console.error(`[Router] ${err.status}: ${err.message}`)
      }
    } else {
      console.error('[Router] Unhandled:', err)
      res.statusCode = 500
      res.setHeader('Content-Type', 'application/json')
      res.end(JSON.stringify({ error: 'Internal server error', code: 'INTERNAL_ERROR', status: 500 }))
    }
  }
}