UNPKG

alepha

Version:

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

422 lines (367 loc) 12.7 kB
import { Readable } from "node:stream"; import { $hook, $inject, Alepha } from "alepha"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import type { Route } from "alepha/router"; import type { RouteMethod } from "../constants/routeMethods.ts"; import type { NodeRequestEvent, ServerRequestData, WebRequestEvent, } from "../interfaces/ServerRequest.ts"; import { ServerRouterProvider } from "./ServerRouterProvider.ts"; // ============================================================================ // Performance Constants // ============================================================================ // Note: We cannot use frozen/shared empty objects here because downstream code // may mutate params/query (e.g., validation, React page rendering) // Header constants for fast property access const HEADER_X_FORWARDED_PROTO = "x-forwarded-proto"; const HEADER_HOST = "host"; // Protocol prefixes const PROTO_HTTPS = "https://"; const PROTO_HTTP = "http://"; /** * Base server provider to handle incoming requests and route them. * * This is the default implementation for serverless environments. * * ServerProvider supports both Node.js HTTP requests and Web (Fetch API) requests. */ export class ServerProvider { protected readonly log = $logger(); protected readonly alepha = $inject(Alepha); protected readonly dateTimeProvider = $inject(DateTimeProvider); protected readonly router = $inject(ServerRouterProvider); protected readonly internalServerErrorMessage = "Internal Server Error"; // Pre-allocated error response to avoid object creation per failed request protected readonly internalErrorResponse = Object.freeze({ status: 500, headers: Object.freeze({ "content-type": "text/plain" }), body: this.internalServerErrorMessage, }); // Pre-bound error handler to avoid function allocation per request protected readonly handleInternalError = () => this.internalErrorResponse; // ============================================================================ // P1: URL Base Cache - cache protocol+host per unique host header // Avoids string concatenation on every request // ============================================================================ protected readonly urlBaseCache = new Map<string, string>(); /** * Get cached URL base (protocol + host) for a given host header. * Caches the result to avoid repeated string concatenation. */ protected getUrlBase(headers: Record<string, string>): string { const host = headers[HEADER_HOST]; const proto = headers[HEADER_X_FORWARDED_PROTO] === "https" ? PROTO_HTTPS : PROTO_HTTP; const cacheKey = proto + host; let base = this.urlBaseCache.get(cacheKey); if (!base) { base = cacheKey; // Limit cache size to prevent memory leaks from many unique hosts if (this.urlBaseCache.size < 100) { this.urlBaseCache.set(cacheKey, base); } } return base; } // ============================================================================ // P0: Manual Query String Parser - faster than URLSearchParams // Parses query string without creating URL object // ============================================================================ /** * Parse query string manually - faster than new URL() + URLSearchParams. * Returns empty object if no query string. */ protected parseQueryString(rawUrl: string): Record<string, string> { const qIndex = rawUrl.indexOf("?"); if (qIndex === -1) { return {}; } const qs = rawUrl.slice(qIndex + 1); if (!qs) { return {}; } const query: Record<string, string> = {}; let start = 0; let eqIdx = -1; for (let i = 0; i <= qs.length; i++) { const char = i < qs.length ? qs.charCodeAt(i) : 38; // '&' at end if (char === 61) { // '=' eqIdx = i; } else if (char === 38) { // '&' if (eqIdx !== -1 && eqIdx > start) { const key = qs.slice(start, eqIdx); const value = qs.slice(eqIdx + 1, i); // Only decode if necessary (contains % or +) query[this.fastDecode(key)] = this.fastDecode(value); } start = i + 1; eqIdx = -1; } } return query; } /** * Fast decode - only calls decodeURIComponent if needed. */ protected fastDecode(str: string): string { // Fast path: no encoding needed if (str.indexOf("%") === -1 && str.indexOf("+") === -1) { return str; } // Replace + with space before decoding try { return decodeURIComponent(str.replace(/\+/g, " ")); } catch { return str; } } public get hostname(): string { if (this.alepha.isViteDev()) { return `http://localhost:${this.alepha.env.SERVER_PORT}`; } return ""; // no hostname in serverless mode } /** * When a Node.js HTTP request is received from outside. (Vercel, AWS Lambda, etc.) */ protected readonly onNodeRequest = $hook({ on: "node:request", handler: (ev) => this.handleNodeRequest(ev), }); /** * When a Web (Fetch API) request is received from outside. (Netlify, Cloudflare Workers, etc.) */ protected readonly onWebRequest = $hook({ on: "web:request", handler: (ev) => { return this.handleWebRequest(ev); }, }); /** * Handle Node.js HTTP request event. * * Optimized to avoid expensive URL parsing when possible. */ public async handleNodeRequest( nodeRequestEvent: NodeRequestEvent, ): Promise<void> { const { req, res } = nodeRequestEvent; const rawUrl = req.url!; const { route, params } = this.router.match(`/${req.method}${rawUrl}`); if (!route) { // Skip if response was already sent (e.g., by Vite middleware) if (res.headersSent) { return; } // if no route is found, return basic 404 // note: you should not use this in production, use a custom 404 page instead by adding a route /* res.writeHead(404, { "content-type": "text/plain" }); res.end("Not Found"); return; } const headers = (req.headers ?? {}) as Record<string, string>; const method = (req.method?.toUpperCase() ?? "GET") as RouteMethod; // P0: Use manual query parsing - much faster than new URL() + URLSearchParams const query = this.parseQueryString(rawUrl); // P1: Use cached URL base - avoids string concat on every request // Create URL object (still needed for downstream code that uses url.pathname, etc.) const urlBase = this.getUrlBase(headers); const url = new URL(rawUrl, urlBase); const request: ServerRequestData = { method, url, headers, params: params ?? {}, query, raw: { node: nodeRequestEvent }, }; const response = await route .handler(request) .catch(this.handleInternalError); // Skip if response was already sent (e.g., by Vite middleware) if (res.headersSent) { return; } // empty body - just send status & headers if (!response.body) { res.writeHead(response.status, response.headers); res.end(); return; } // if response.body is string or buffer if (typeof response.body === "string" || Buffer.isBuffer(response.body)) { res.writeHead(response.status, response.headers); res.end(response.body); return; } // if response.body is node stream if (response.body instanceof Readable) { res.writeHead(response.status, response.headers); response.body.pipe(res); return; } // if response.body is web stream if (response.body instanceof ReadableStream) { res.writeHead(response.status, response.headers); // Flush headers immediately and disable Nagle's algorithm for streaming res.flushHeaders(); res.socket?.setNoDelay(true); try { for await (const chunk of response.body) { const canContinue = res.write(chunk); // If the internal buffer is full, wait for it to drain if (!canContinue) { await new Promise<void>((resolve) => res.once("drain", resolve)); } } } catch (error) { this.log.error("Error piping proxy response stream", error); } finally { res.end(); } return; } // not supported response body type this.log.error("Unknown response body type:", typeof response.body); res.writeHead(500, { "content-type": "text/plain" }); res.end(this.internalServerErrorMessage); } /** * Handle Web (Fetch API) request event. */ public async handleWebRequest(ev: WebRequestEvent): Promise<void> { const req = ev.req; const url = new URL(req.url); const { route, params } = this.router.match( `/${req.method}${url.pathname}`, ); if (this.isViteNotFound(req.url, route, params)) { return; } if (!route) { // if no route is found, return basic 404 // note: you should not use this in production, use a custom 404 page instead by adding a route /* ev.res = new Response("Not Found", { status: 404, headers: { "content-type": "text/plain" }, }); return; } const headers: Record<string, string> = {}; req.headers.forEach((value, key) => { headers[key] = value; }); // Optimize: only parse query params if there are any const query = url.search ? Object.fromEntries(url.searchParams.entries()) : {}; const method = (req.method.toUpperCase() ?? "GET") as RouteMethod; const request: ServerRequestData = { method, url, headers, params: params || {}, query, raw: { web: ev }, }; const response = await route .handler(request) .catch(this.handleInternalError); const webHeaders = this.toWebHeaders(response.headers); // empty body - just send status & headers if (!response.body) { ev.res = new Response(null, { status: response.status, headers: webHeaders, }); return; } // if response.body is string or buffer if (typeof response.body === "string") { ev.res = new Response(response.body, { status: response.status, headers: webHeaders, }); return; } if (Buffer.isBuffer(response.body)) { // Use Uint8Array to avoid Buffer pooling issues where .buffer returns // the entire underlying ArrayBuffer which may be larger than the actual data ev.res = new Response(new Uint8Array(response.body), { status: response.status, headers: webHeaders, }); return; } // if response.body is node stream if (response.body instanceof Readable) { ev.res = new Response( Readable.toWeb(response.body) as unknown as ReadableStream, { status: response.status, headers: webHeaders, }, ); return; } // if response.body is web stream if (response.body instanceof ReadableStream) { ev.res = new Response(response.body, { status: response.status, headers: webHeaders, }); return; } // not supported response body type this.log.error(`Unknown response body type: ${typeof response.body}`); ev.res = new Response(this.internalServerErrorMessage, { status: 500, headers: { "content-type": "text/plain" }, }); } /** * Convert response headers to Web API Headers. * * The `set-cookie` header requires special handling because it's stored * as `string[]` (one entry per cookie) but the `Response` constructor * would comma-join arrays, which breaks cookie parsing. We use * `Headers.append()` to emit each cookie as a separate header. */ protected toWebHeaders(headers: Record<string, string | string[]>): Headers { const webHeaders = new Headers(); for (const [key, value] of Object.entries(headers)) { if (Array.isArray(value)) { for (const v of value) { webHeaders.append(key, v); } } else { webHeaders.set(key, value); } } return webHeaders; } /** * Helper for Vite development mode to let Vite handle (or not) 404. */ protected isViteNotFound( url?: string, route?: Route, params?: Record<string, string>, ): boolean { if (this.alepha.isViteDev()) { if (!route) { return true; } url = url?.split("?")[0]; if (params?.["*"] && `/${params?.["*"]}` === url) { return true; } } return false; } }