|
| 1 | +import type { Middleware, MiddlewareContext } from '@pyreon/server' |
| 2 | + |
| 3 | +// ─── Types ─────────────────────────────────────────────────────────────────── |
| 4 | + |
| 5 | +/** HTTP methods supported by API routes. */ |
| 6 | +export type HttpMethod = |
| 7 | + | 'GET' |
| 8 | + | 'POST' |
| 9 | + | 'PUT' |
| 10 | + | 'PATCH' |
| 11 | + | 'DELETE' |
| 12 | + | 'HEAD' |
| 13 | + | 'OPTIONS' |
| 14 | + |
| 15 | +/** Context passed to API route handlers. */ |
| 16 | +export interface ApiContext { |
| 17 | + /** The incoming request. */ |
| 18 | + request: Request |
| 19 | + /** Parsed URL. */ |
| 20 | + url: URL |
| 21 | + /** URL path. */ |
| 22 | + path: string |
| 23 | + /** Dynamic route parameters (e.g., { id: "123" }). */ |
| 24 | + params: Record<string, string> |
| 25 | + /** Request headers. */ |
| 26 | + headers: Headers |
| 27 | +} |
| 28 | + |
| 29 | +/** An API route handler function. */ |
| 30 | +export type ApiHandler = (ctx: ApiContext) => Response | Promise<Response> |
| 31 | + |
| 32 | +/** An API route module — exports named HTTP method handlers. */ |
| 33 | +export interface ApiRouteModule { |
| 34 | + GET?: ApiHandler |
| 35 | + POST?: ApiHandler |
| 36 | + PUT?: ApiHandler |
| 37 | + PATCH?: ApiHandler |
| 38 | + DELETE?: ApiHandler |
| 39 | + HEAD?: ApiHandler |
| 40 | + OPTIONS?: ApiHandler |
| 41 | +} |
| 42 | + |
| 43 | +/** A registered API route entry. */ |
| 44 | +export interface ApiRouteEntry { |
| 45 | + /** URL pattern (e.g., "/api/posts/:id"). */ |
| 46 | + pattern: string |
| 47 | + /** The route module with method handlers. */ |
| 48 | + module: ApiRouteModule |
| 49 | +} |
| 50 | + |
| 51 | +// ─── Pattern matching ──────────────────────────────────────────────────────── |
| 52 | + |
| 53 | +/** |
| 54 | + * Match a URL path against an API route pattern. |
| 55 | + * Returns extracted params or null if no match. |
| 56 | + */ |
| 57 | +export function matchApiRoute( |
| 58 | + pattern: string, |
| 59 | + path: string, |
| 60 | +): Record<string, string> | null { |
| 61 | + const patternParts = pattern.split('/').filter(Boolean) |
| 62 | + const pathParts = path.split('/').filter(Boolean) |
| 63 | + const params: Record<string, string> = {} |
| 64 | + |
| 65 | + for (let i = 0; i < patternParts.length; i++) { |
| 66 | + const pp = patternParts[i] |
| 67 | + |
| 68 | + // Catch-all: :param* |
| 69 | + if (pp.endsWith('*')) { |
| 70 | + const paramName = pp.slice(1, -1) |
| 71 | + params[paramName] = pathParts.slice(i).join('/') |
| 72 | + return params |
| 73 | + } |
| 74 | + |
| 75 | + // No more path segments |
| 76 | + if (i >= pathParts.length) return null |
| 77 | + |
| 78 | + // Dynamic segment: :param |
| 79 | + if (pp.startsWith(':')) { |
| 80 | + params[pp.slice(1)] = pathParts[i] |
| 81 | + continue |
| 82 | + } |
| 83 | + |
| 84 | + // Static segment |
| 85 | + if (pp !== pathParts[i]) return null |
| 86 | + } |
| 87 | + |
| 88 | + return patternParts.length === pathParts.length ? params : null |
| 89 | +} |
| 90 | + |
| 91 | +// ─── Middleware ─────────────────────────────────────────────────────────────── |
| 92 | + |
| 93 | +const HTTP_METHODS: HttpMethod[] = [ |
| 94 | + 'GET', |
| 95 | + 'POST', |
| 96 | + 'PUT', |
| 97 | + 'PATCH', |
| 98 | + 'DELETE', |
| 99 | + 'HEAD', |
| 100 | + 'OPTIONS', |
| 101 | +] |
| 102 | + |
| 103 | +/** |
| 104 | + * Create a middleware that dispatches API route requests. |
| 105 | + * API routes are matched by URL pattern and HTTP method. |
| 106 | + */ |
| 107 | +export function createApiMiddleware(routes: ApiRouteEntry[]): Middleware { |
| 108 | + return async (ctx: MiddlewareContext) => { |
| 109 | + for (const route of routes) { |
| 110 | + const params = matchApiRoute(route.pattern, ctx.path) |
| 111 | + if (!params) continue |
| 112 | + |
| 113 | + const method = ctx.req.method.toUpperCase() as HttpMethod |
| 114 | + const handler = route.module[method] |
| 115 | + |
| 116 | + if (!handler) { |
| 117 | + // Route matched but method not supported |
| 118 | + const allowed = HTTP_METHODS.filter((m) => route.module[m]).join(', ') |
| 119 | + return new Response(null, { |
| 120 | + status: 405, |
| 121 | + headers: { |
| 122 | + Allow: allowed, |
| 123 | + 'Content-Type': 'application/json', |
| 124 | + }, |
| 125 | + }) |
| 126 | + } |
| 127 | + |
| 128 | + return handler({ |
| 129 | + request: ctx.req, |
| 130 | + url: ctx.url, |
| 131 | + path: ctx.path, |
| 132 | + params, |
| 133 | + headers: ctx.req.headers, |
| 134 | + }) |
| 135 | + } |
| 136 | + } |
| 137 | +} |
| 138 | + |
| 139 | +// ─── Virtual module generation ─────────────────────────────────────────────── |
| 140 | + |
| 141 | +/** |
| 142 | + * Detect whether a route file is an API route. |
| 143 | + * API routes are `.ts` or `.js` files inside an `api/` directory. |
| 144 | + */ |
| 145 | +export function isApiRoute(filePath: string): boolean { |
| 146 | + const normalized = filePath.replace(/\\/g, '/') |
| 147 | + return ( |
| 148 | + normalized.startsWith('api/') && |
| 149 | + (normalized.endsWith('.ts') || normalized.endsWith('.js')) && |
| 150 | + !normalized.endsWith('.tsx') && |
| 151 | + !normalized.endsWith('.jsx') |
| 152 | + ) |
| 153 | +} |
| 154 | + |
| 155 | +/** |
| 156 | + * Convert an API route file path to a URL pattern. |
| 157 | + * |
| 158 | + * Examples: |
| 159 | + * "api/posts.ts" → "/api/posts" |
| 160 | + * "api/posts/index.ts" → "/api/posts" |
| 161 | + * "api/posts/[id].ts" → "/api/posts/:id" |
| 162 | + * "api/[...path].ts" → "/api/:path*" |
| 163 | + */ |
| 164 | +export function apiFilePathToPattern(filePath: string): string { |
| 165 | + let route = filePath |
| 166 | + // Remove extension |
| 167 | + for (const ext of ['.ts', '.js']) { |
| 168 | + if (route.endsWith(ext)) { |
| 169 | + route = route.slice(0, -ext.length) |
| 170 | + break |
| 171 | + } |
| 172 | + } |
| 173 | + |
| 174 | + const segments = route.split('/') |
| 175 | + const urlSegments: string[] = [] |
| 176 | + |
| 177 | + for (const seg of segments) { |
| 178 | + if (seg === 'index') continue |
| 179 | + |
| 180 | + // Catch-all: [...param] |
| 181 | + const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/) |
| 182 | + if (catchAll) { |
| 183 | + urlSegments.push(`:${catchAll[1]}*`) |
| 184 | + continue |
| 185 | + } |
| 186 | + |
| 187 | + // Dynamic: [param] |
| 188 | + const dynamic = seg.match(/^\[(\w+)\]$/) |
| 189 | + if (dynamic) { |
| 190 | + urlSegments.push(`:${dynamic[1]}`) |
| 191 | + continue |
| 192 | + } |
| 193 | + |
| 194 | + urlSegments.push(seg) |
| 195 | + } |
| 196 | + |
| 197 | + return `/${urlSegments.join('/')}` |
| 198 | +} |
| 199 | + |
| 200 | +/** |
| 201 | + * Generate a virtual module that exports API route entries. |
| 202 | + * Each entry maps a URL pattern to a module with HTTP method handlers. |
| 203 | + */ |
| 204 | +export function generateApiRouteModule( |
| 205 | + files: string[], |
| 206 | + routesDir: string, |
| 207 | +): string { |
| 208 | + const apiFiles = files.filter(isApiRoute) |
| 209 | + |
| 210 | + if (apiFiles.length === 0) { |
| 211 | + return 'export const apiRoutes = []\n' |
| 212 | + } |
| 213 | + |
| 214 | + const imports: string[] = [] |
| 215 | + const entries: string[] = [] |
| 216 | + |
| 217 | + for (let i = 0; i < apiFiles.length; i++) { |
| 218 | + const name = `_api${i}` |
| 219 | + const fullPath = `${routesDir}/${apiFiles[i]}` |
| 220 | + const pattern = apiFilePathToPattern(apiFiles[i]) |
| 221 | + |
| 222 | + imports.push(`import * as ${name} from "${fullPath}"`) |
| 223 | + entries.push(` { pattern: ${JSON.stringify(pattern)}, module: ${name} }`) |
| 224 | + } |
| 225 | + |
| 226 | + return [ |
| 227 | + ...imports, |
| 228 | + '', |
| 229 | + 'export const apiRoutes = [', |
| 230 | + entries.join(',\n'), |
| 231 | + ']', |
| 232 | + ].join('\n') |
| 233 | +} |
0 commit comments