/**
* 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