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