UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

586 lines (524 loc) 18.1 kB
import { Readable as NodeStream } from "node:stream"; import { ReadableStream as NodeWebStream } from "node:stream/web"; import { $inject, Alepha, coerceScalar, isFileLike, isTypeFile, type Middleware, PipelineHandler, z, } from "alepha"; import { CryptoProvider } from "alepha/crypto"; import { $logger } from "alepha/logger"; import { RouterProvider } from "alepha/router"; import type { RouteMethod } from "../constants/routeMethods.ts"; import { errorNameByStatus, HttpError } from "../errors/HttpError.ts"; import { ValidationError } from "../errors/ValidationError.ts"; import type { ServerReply } from "../helpers/ServerReply.ts"; import type { RequestConfigSchema, ResponseKind, ServerRequest, ServerRequestConfig, ServerRoute, ServerRouteInput, ServerRouteMatcher, } from "../interfaces/ServerRequest.ts"; import { ServerRequestParser } from "../services/ServerRequestParser.ts"; import { ServerTimingProvider } from "./ServerTimingProvider.ts"; /** * Main router for all routes server side. * * Reminder: * - $route => generic route * - $action => action route (for API calls) * - $page => React route (for React SSR) */ export class ServerRouterProvider extends RouterProvider<ServerRouteMatcher> { protected readonly log = $logger(); protected readonly alepha = $inject(Alepha); protected readonly crypto = $inject(CryptoProvider); protected readonly routes: ServerRoute[] = []; protected readonly serverTimingProvider = $inject(ServerTimingProvider); protected readonly serverRequestParser = $inject(ServerRequestParser); protected readonly queryKeysCache = new WeakMap<object, string[]>(); protected readonly globalMiddlewareRegistry: GlobalMiddlewareEntry[] = []; /** * Get cached keys for a query schema, computing them lazily on first access. */ protected getQuerySchemaKeys(schema: { properties: object }): string[] { let keys = this.queryKeysCache.get(schema.properties); if (!keys) { keys = Object.keys(schema.properties); this.queryKeysCache.set(schema.properties, keys); } return keys; } public pushMiddleware( pattern: string, middleware: Middleware[], options?: PushMiddlewareOptions, ): void { this.globalMiddlewareRegistry.push({ pattern, middleware, ...options }); for (const route of this.getRoutes(pattern)) { if (this.matchesMiddlewareFilter(route, options)) { route.handler.use(...middleware); } } } /** * Check if a route passes the middleware filter options (method, exclude). */ protected matchesMiddlewareFilter( route: ServerRoute, options?: PushMiddlewareOptions, ): boolean { if (options?.method) { const methods = Array.isArray(options.method) ? options.method : [options.method]; if ( route.method && !methods.includes(route.method.toUpperCase() as RouteMethod) ) { return false; } } if (options?.exclude) { for (const exclude of options.exclude) { if (route.path === exclude || route.path.startsWith(`${exclude}/`)) { return false; } } } return true; } /** * Check if a route path matches a pattern. * Wildcard `*` at end = prefix match, otherwise exact match. */ protected matchesPattern(routePath: string, pattern: string): boolean { if (pattern.endsWith("*")) { return routePath.startsWith(pattern.slice(0, -1)); } return routePath === pattern; } /** * Get all registered routes, optionally filtered by a pattern. * * Pattern accept simple wildcard '*' at the end. * Example: '/api/*' will match all routes starting with '/api/' but '/api/' will match only that exact route. */ public getRoutes(pattern?: string): ServerRoute[] { if (pattern) { return this.routes.filter((route) => this.matchesPattern(route.path, pattern), ); } return this.routes; } /** * Create a new server route. * * Accepts both `PipelineHandler` and plain handler functions. * Plain functions are auto-wrapped in `PipelineHandler`. */ public createRoute<TConfig extends RequestConfigSchema = RequestConfigSchema>( input: ServerRouteInput<TConfig>, ): void { // Auto-wrap plain function handlers in PipelineHandler if (!(input.handler instanceof PipelineHandler)) { (input as any).handler = new PipelineHandler(input.handler); } const route = input as unknown as ServerRoute<TConfig>; route.method ??= "GET"; route.method = route.method.toUpperCase() as RouteMethod; this.routes.push(route); for (const entry of this.globalMiddlewareRegistry) { if (this.matchesPattern(route.path, entry.pattern)) { if (this.matchesMiddlewareFilter(route, entry)) { route.handler.use(...entry.middleware); } } } const path = `/${route.method}/${route.path}`.replace(/\/+/g, "/"); const responseKind = this.getResponseType(route.schema); this.log.trace(`Create route ${path}`); this.push({ path, handler: (rawRequest) => { const request = this.serverRequestParser.createServerRequest(rawRequest); return this.alepha.context.run( () => this.processRequest(request, route, responseKind), { context: this.getContextId(rawRequest.headers) }, ); }, }); } /** * Get or generate a context ID from request headers. * * It first checks for common headers like 'x-request-id' or 'x-correlation-id' that may be set by proxies or clients. * If none of these headers are present, it generates a new UUID to ensure uniqueness. * * Note: In production environments, it's recommended to have a proxy (e.g. Nginx, Cloudflare) that sets a consistent request ID header for better traceability across services. */ protected getContextId(headers: Record<string, string>): string { // note: we trust these headers as all our environments are behind a proxy return ( headers["x-request-id"] || headers["x-correlation-id"] || this.crypto.randomUUID() ); } /** * Process an incoming request through the full lifecycle: * onRequest → handler → onSend → response → onResponse */ protected async processRequest( request: ServerRequest, route: ServerRoute, responseKind: ResponseKind, ) { try { await this.runRouteHandler(route, request, responseKind); } catch (error) { await this.errorHandler(route, request, error as Error); } const payload = { request, route, response: undefined as any }; await this.alepha.events.emit("server:onSend", payload, { catch: true }); const reply = request.reply; const response = { status: reply.status ?? (reply.body ? 200 : 204), // default status: 200 if body is set, otherwise 204 headers: reply.headers, body: reply.body as any, }; payload.response = response; await this.alepha.events.emit("server:onResponse", payload, { catch: true, }); return response; } /** * Run the route handler with request validation and response serialization. */ protected async runRouteHandler( route: ServerRoute, request: ServerRequest, responseKind: ResponseKind, ) { // Built-in hooks: body parsing, security, logging await this.alepha.events.emit("server:onRequest", { request, route }); // A hook (e.g. CORS preflight) may have already written the response const reply = request.reply; if (reply.body || (reply.status && reply.status >= 200)) { return; } // Store route path info for middleware (e.g. $secure auto-permission lookup) request.metadata.routePath = route.path; request.metadata.routeMethod = route.method; // Make the request available to handlers via alepha.context this.alepha.set("alepha.http.request", request); const timing = this.serverTimingProvider; timing.beginTiming("validateRequest"); try { this.validateRequest(route, request); } finally { timing.endTiming("validateRequest"); } timing.beginTiming("runHandler"); try { const result = await route.handler.run(request); if (result) { reply.body = result; } } finally { timing.endTiming("runHandler"); } timing.beginTiming("serializeResponse"); try { this.serializeResponse(route, reply, responseKind); } finally { timing.endTiming("serializeResponse"); } } /** * Transform reply body based on response kind and route schema. */ public serializeResponse( route: ServerRoute, reply: ServerReply, responseKind: ResponseKind, ): void { const headers = reply.headers; if (responseKind === "json" && route.schema?.response) { headers["content-type"] = "application/json"; reply.body = this.alepha.codec.encode(route.schema.response, reply.body, { as: "string" as const, }); return; } if (responseKind === "file") { if (!isFileLike(reply.body)) { throw new HttpError({ status: 500, message: "Invalid response body - not a file", }); } headers["content-type"] = reply.body.type; const sanitizedName = reply.body.name .replaceAll("\\", "\\\\") .replaceAll('"', '\\"') .replaceAll("\r", "") .replaceAll("\n", ""); headers["content-disposition"] = `attachment; filename="${sanitizedName}"`; reply.body = reply.body.stream(); return; } if (responseKind === "text") { reply.body = String(reply.body); // Detect HTML responses (starts with <!DOCTYPE html>) if (reply.body.startsWith("<!DOCTYPE html>")) { headers["content-type"] ??= "text/html; charset=UTF-8"; } else { headers["content-type"] ??= "text/plain"; } return; } if (reply.body == null || responseKind === "void") { delete (headers as Record<string, unknown>)["content-type"]; reply.body = undefined; return; } if (Buffer.isBuffer(reply.body)) { headers["content-type"] ??= "application/octet-stream"; return; } if ( reply.body instanceof NodeWebStream || reply.body instanceof NodeStream ) { headers["content-type"] ??= "application/octet-stream"; return; } // Plain objects/arrays → auto-serialize as JSON if (typeof reply.body === "object") { headers["content-type"] ??= "application/json"; reply.body = JSON.stringify(reply.body); return; } headers["content-type"] ??= "text/plain"; reply.body = String(reply.body); } /** * Determine response type based on route schema. */ protected getResponseType(schema?: RequestConfigSchema): ResponseKind { if (schema?.response) { if ( z.schema.isObject(schema.response) || z.schema.isRecord(schema.response) || z.schema.isArray(schema.response) ) { return "json"; } if ( z.schema.isString(schema.response) || z.schema.isInteger(schema.response) || z.schema.isNumber(schema.response) || z.schema.isBoolean(schema.response) ) { return "text"; } if (isTypeFile(schema.response)) { return "file"; } if (z.schema.isVoid(schema.response)) { return "void"; } } return "any"; } /** * Handle errors during request processing. */ protected async errorHandler( route: ServerRoute, request: ServerRequest, error: Error, ) { // Reset body — it's likely invalid after an error const reply = request.reply; reply.body = null; // Let error hooks handle it first (e.g. custom error pages, Sentry) await this.alepha.events.emit("server:onError", { request, route, error }); // | // | // --> If a hook already set the response, we're done! if (reply.status) return; const requestId = request.requestId; if (error instanceof HttpError) { reply.status = error.status; reply.headers["content-type"] = "application/json"; const errorJson = HttpError.toJSON(error); errorJson.requestId = requestId; reply.body = JSON.stringify(errorJson); return; } // Errors with a known HTTP status (e.g. from upstream libraries) if ( "status" in error && typeof error.status === "number" && errorNameByStatus[error.status] ) { const status = error.status; reply.status = status; reply.headers["content-type"] = "application/json"; reply.body = JSON.stringify({ status, error: errorNameByStatus[status], // 4xx messages are intentional and client-facing; 5xx messages can // leak internals (DB errors, stack-y hints) — sanitize them in // production, exactly like the unknown-error fallback below. message: status >= 500 && this.alepha.isProduction() ? "Internal Server Error" : error.message, requestId, }); return; } // Fallback: unknown error → 500 reply.status = 500; reply.headers["content-type"] = "application/json"; reply.body = JSON.stringify({ status: 500, error: "InternalServerError", message: this.alepha.isProduction() ? "Internal Server Error" : error.message, requestId, }); } /** * Validate incoming request against route schema. */ public validateRequest( route: { schema?: RequestConfigSchema }, request: ServerRequestConfig, ) { if (route.schema?.params) { try { // Path params are always strings on the wire — coerce each declared // param to its schema type (int/bool/date) before strict validation, // mirroring the `query` / `headers` handling below. Without this a // numeric route param like `/users/:id` (id: z.integer()) fails strict // zod validation with "expected number, received string". const schemaParams = route.schema.params; const coerced: Record<string, unknown> = { ...request.params }; for (const key of Object.keys(schemaParams.properties)) { if (coerced[key] != null) { coerced[key] = this.coerceParam( schemaParams.properties[key], coerced[key], ); } } request.params = this.alepha.codec.validate( schemaParams, coerced, ) as any; } catch (error) { throw new ValidationError("Invalid request params", error); } } if (route.schema?.query) { try { const schemaQuery = route.schema.query; const keys = this.getQuerySchemaKeys(schemaQuery); const query: Record<string, any> = {}; for (const key of keys) { if (request.query[key] != null) { const propSchema = schemaQuery.properties[key]; query[key] = this.alepha.codec.decode( propSchema, this.coerceParam(propSchema, request.query[key]), ); } } request.query = query; } catch (error) { throw new ValidationError("Invalid request query", error); } } if (route.schema?.headers) { try { const schemaHeaders = route.schema.headers; // Per-key decode (mirrors `query` handling): coerces declared header // values from strings to their schema types (int/bool/date). Then // validate the decoded subset against the full schema so TypeBox // produces consistent error messages (missing-required, type // mismatch). Finally merge the decoded values back into // `request.headers` so undeclared headers (auth, cookie, user-agent, // ...) survive the validation step intact. const decoded: Record<string, unknown> = {}; for (const key of Object.keys(schemaHeaders.properties)) { const lcKey = key.toLowerCase(); const value = request.headers[lcKey]; if (value == null) continue; decoded[key] = this.alepha.codec.decode( schemaHeaders.properties[key], this.coerceParam(schemaHeaders.properties[key], value), ); } this.alepha.codec.validate(schemaHeaders, decoded); for (const [key, value] of Object.entries(decoded)) { (request.headers as Record<string, unknown>)[key.toLowerCase()] = value; } } catch (error) { throw new ValidationError("Invalid request header", error); } } if (route.schema?.body) { if (z.schema.isString(route.schema.body)) { if (typeof request.body !== "string") { throw new ValidationError("Request body is not a string"); } } else { try { request.body = this.alepha.codec.decode( route.schema.body, request.body, ); } catch (error) { throw new ValidationError("Invalid request body", error); } } } } /** * Coerce a raw query/header value (always a string on the wire) to the JS * type its schema declares, mirroring `z.coerce` at the HTTP boundary. * * Only the boundary coerces — request bodies and the ORM stay strict. Values * that can't be coerced are passed through unchanged so the subsequent * validation produces a proper rejection. Arrays coerce element-wise. */ protected coerceParam(schema: unknown, value: unknown): unknown { return coerceScalar(schema, value); } } // --------------------------------------------------------------------------------------------------------------------- export interface PushMiddlewareOptions { method?: RouteMethod | RouteMethod[]; exclude?: string[]; } interface GlobalMiddlewareEntry extends PushMiddlewareOptions { pattern: string; middleware: Middleware[]; }