OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / routes / index.js

251 lines · javascript ref:
/**
 * Router - Simple pattern-based HTTP router for pear-git
 *
 * Features:
 * - Pattern matching with named parameters (e.g., /:user/:repo)
 * - Middleware chain support
 * - Method-specific handlers (GET, POST, PATCH, DELETE)
 * - Global error handling with AppError support
 */

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

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

  /**
   * Register a GET route
   */
  get(pattern, ...handlers) {
    this.routes.push({ method: 'GET', pattern, handlers })
    return this
  }

  /**
   * Register a POST route
   */
  post(pattern, ...handlers) {
    this.routes.push({ method: 'POST', pattern, handlers })
    return this
  }

  /**
   * Register a PATCH route
   */
  patch(pattern, ...handlers) {
    this.routes.push({ method: 'PATCH', pattern, handlers })
    return this
  }

  /**
   * Register a DELETE route
   */
  delete(pattern, ...handlers) {
    this.routes.push({ method: 'DELETE', pattern, handlers })
    return this
  }

  /**
   * Register route for any method
   */
  all(pattern, ...handlers) {
    this.routes.push({ method: '*', pattern, handlers })
    return this
  }

  /**
   * Convert route pattern to regex with named groups
   *
   * Examples:
   *   '/api/repos' -> /^\/api\/repos$/
   *   '/:user/:repo' -> /^\/(?<user>[^/]+)\/(?<repo>[^/]+)$/
   *   '/:user/:repo/tree/*path' -> /^\/(?<user>[^/]+)\/(?<repo>[^/]+)\/tree\/(?<path>.+)$/
   */
  patternToRegex(pattern) {
    // Escape special regex characters except : and *
    let regexStr = pattern
      .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
      // Convert :param to named capture group
      .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '(?<$1>[^/]+)')
      // Convert *param to greedy capture group (for paths)
      .replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, '(?<$1>.+)')

    return new RegExp('^' + regexStr + '$')
  }

  /**
   * Match a route against the URL
   * Returns { params } if matched, null otherwise
   */
  match(route, method, url) {
    // Check method (wildcard matches all)
    if (route.method !== '*' && route.method !== method) {
      return null
    }

    // Get or create cached regex
    if (!route.regex) {
      route.regex = this.patternToRegex(route.pattern)
    }

    const match = url.match(route.regex)
    if (!match) {
      return null
    }

    // Extract named groups as params
    const params = match.groups || {}

    // Decode URI components (try-catch prevents crash on malformed URLs like %ZZ)
    for (const key in params) {
      if (params[key]) {
        try {
          params[key] = decodeURIComponent(params[key])
        } catch {}
      }
    }

    return { params }
  }

  /**
   * Normalize URL (strip query parameters, handle path prefix)
   */
  normalizeUrl(url) {
    // Strip query parameters
    const queryIndex = url.indexOf('?')
    let pathOnly = queryIndex >= 0 ? url.substring(0, queryIndex) : url

    // Strip path prefix if configured
    if (this.server.pathPrefix && pathOnly.startsWith(this.server.pathPrefix)) {
      pathOnly = pathOnly.substring(this.server.pathPrefix.length) || '/'
    }

    // Strip trailing slash (but keep root /)
    if (pathOnly.length > 1 && pathOnly.endsWith('/')) {
      pathOnly = pathOnly.slice(0, -1)
    }

    return pathOnly
  }

  /**
   * Dispatch request to matching route handler
   * Returns true if a route was matched, false otherwise
   */
  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) {
        // Attach params to request
        req.params = match.params

        // Run through handler chain (middleware + handler)
        let skipped = false
        for (const handler of route.handlers) {
          try {
            const result = await handler(req, res, this.server)
            // If handler returns false, skip this route and try the next one
            if (result === false) {
              skipped = true
              break
            }
            // If response was already sent, stop
            if (res.writableEnded) {
              return true
            }
          } catch (err) {
            // Handle error with global error handler
            this.handleError(err, req, res)
            return true
          }
        }
        if (!skipped) return true
      }
    }

    return false
  }

  /**
   * Global error handler
   * Converts errors to appropriate HTTP responses
   */
  handleError(err, req, res) {
    // Don't try to write if response already sent
    if (res.writableEnded) {
      console.error('[Router] Error after response sent:', err)
      return
    }

    // Handle AppError instances
    if (isAppError(err)) {
      const status = err.status || 500
      const body = JSON.stringify(err.toJSON())

      res.statusCode = status
      res.setHeader('Content-Type', 'application/json')

      // Add WWW-Authenticate header for 401 responses
      if (status === 401) {
        res.setHeader('WWW-Authenticate', 'Bearer realm="Hypernote"')
      }

      // Add Retry-After header for rate limit errors
      if (err.retryAfter) {
        res.setHeader('Retry-After', String(err.retryAfter))
      }

      res.end(body)

      // Log client errors at info level, server errors at error level
      if (status >= 500) {
        console.error(`[Router] Server error (${status}):`, err.message, err.stack)
      } else {
        console.log(`[Router] Client error (${status}): ${err.message}`)
      }
    } else {
      // Handle unexpected errors
      console.error('[Router] Unhandled error:', err)

      res.statusCode = 500
      res.setHeader('Content-Type', 'application/json')
      res.end(JSON.stringify({
        error: 'Internal server error',
        code: 'INTERNAL_ERROR',
        status: 500
      }))
    }
  }

  /**
   * Get the full URL for a route (for reverse routing)
   */
  url(pattern, params = {}) {
    let url = pattern
    for (const [key, value] of Object.entries(params)) {
      url = url.replace(':' + key, encodeURIComponent(value))
      url = url.replace('*' + key, encodeURIComponent(value))
    }
    return url
  }

  /**
   * List all registered routes (for debugging)
   */
  listRoutes() {
    return this.routes.map(r => ({
      method: r.method,
      pattern: r.pattern
    }))
  }
}

export default Router