UNPKG

alepha

Version:

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

1,575 lines 95.3 kB
import { $atom, $env, $hook, $inject, $module, $state, Alepha, AlephaError, KIND, PipelineHandler, PipelinePrimitive, Primitive, TypeBoxError, coerceScalar, createMiddleware, createPrimitive, isFileLike, isTypeFile, z } from "alepha"; import { $logger } from "alepha/logger"; import { Readable } from "node:stream"; import { DateTimeProvider } from "alepha/datetime"; import { ReadableStream as ReadableStream$1 } from "node:stream/web"; import { CryptoProvider } from "alepha/crypto"; import { RouterProvider } from "alepha/router"; import { $cache } from "alepha/cache"; import { createServer } from "node:http"; import * as zlib from "node:zlib"; import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib"; import { promisify } from "node:util"; import { HttpError as HttpError$1, isMultipart as isMultipart$1 } from "alepha/server"; //#region ../../src/server/core/helpers/isMultipart.ts /** * Checks if the route has multipart/form-data request body. */ const isMultipart = (options) => { if (options.contentType === "multipart/form-data" || options.requestBodyType === "multipart/form-data") return true; if (options.schema?.body && "properties" in options.schema.body) { const properties = options.schema.body.properties; for (const key in properties) if (z.schema.format(properties[key]) === "binary") return true; } return false; }; //#endregion //#region ../../src/server/core/helpers/ServerReply.ts /** * Helper for building server replies. */ var ServerReply = class { headers = {}; status; body; /** * Redirect to a given URL with optional status code (default 302). */ redirect(url, status = 302) { this.status = status; this.headers.location = url; } /** * Set the response status code. */ setStatus(status) { this.status = status; return this; } /** * Set a response header. */ setHeader(name, value) { this.headers[name.toLowerCase()] = value; return this; } /** * Set the response body. */ setBody(body) { this.body = body; return this; } }; //#endregion //#region ../../src/server/core/errors/HttpError.ts const isHttpError = (error, status) => { if (!(!!error && typeof error === "object" && "message" in error && typeof error.message === "string" && "status" in error && typeof error.status === "number")) return false; if (status) return error.status === status; return true; }; var HttpError = class extends AlephaError { name = "HttpError"; static is = isHttpError; static toJSON(error) { const json = { error: error.error, status: error.status, message: error.message }; if (error.details) json.details = error.details; if (error.requestId) json.requestId = error.requestId; if (error.reason) json.cause = error.reason; return json; } error; status; requestId; details; reason; constructor(options, cause) { super(options.message, { cause }); this.status = options.status ?? 500; this.details = options.details; this.requestId = options.requestId; if (typeof options.cause === "object") this.reason = { name: options.cause.name, message: options.cause.message }; else if (cause instanceof Error) this.reason = { name: cause.name, message: cause.message }; if (this.constructor.name === "HttpError") this.error = options.error ?? errorNameByStatus[this.status] ?? "HttpError"; else this.error = this.constructor.name; } }; const errorNameByStatus = { 400: "BadRequestError", 401: "UnauthorizedError", 403: "ForbiddenError", 404: "NotFoundError", 405: "MethodNotAllowedError", 409: "ConflictError", 410: "GoneError", 413: "PayloadTooLargeError", 415: "UnsupportedMediaTypeError", 429: "TooManyRequestsError", 500: "InternalServerError", 501: "NotImplementedError", 502: "BadGatewayError", 503: "ServiceUnavailableError", 504: "GatewayTimeoutError" }; //#endregion //#region ../../src/server/core/errors/ValidationError.ts var ValidationError = class extends HttpError { constructor(message = "Validation has failed", cause) { let fullMessage = message; let details; if (cause instanceof TypeBoxError) { const path = cause.cause.instancePath; fullMessage = `${message}: ${cause.cause.message}${path ? ` (${path})` : ""}`; if (path) details = path; } super({ message: fullMessage, status: 400, details }, cause); } }; //#endregion //#region ../../src/server/core/services/UserAgentParser.ts /** * Simple User-Agent parser to detect OS, browser, and device type. * This parser is not exhaustive and may not cover all edge cases. * * Use result for non */ var UserAgentParser = class { parse(userAgent = "") { const ua = userAgent.toLowerCase(); let os = "Windows"; let browser = "Chrome"; let device = "DESKTOP"; if (ua.includes("windows phone")) os = "Windows Phone"; else if (ua.includes("windows")) os = "Windows"; else if (ua.includes("android")) os = "Android"; else if (ua.includes("iphone") || ua.includes("ipad") || ua.includes("ipod") || ua.includes("ios") && !ua.includes("android")) os = "iOS"; else if (ua.includes("mac os") || ua.includes("macos") || ua.includes("macintosh")) os = "MacOS"; else if (ua.includes("cros") || ua.includes("chromeos")) os = "ChromeOS"; else if (ua.includes("ubuntu")) os = "Ubuntu"; else if (ua.includes("freebsd")) os = "FreeBSD"; else if (ua.includes("openbsd")) os = "OpenBSD"; else if (ua.includes("blackberry") || ua.includes("bb10")) os = "BlackBerry"; else if (ua.includes("symbian") || ua.includes("symbos")) os = "Symbian"; else if (ua.includes("linux") || ua.includes("x11")) os = "Linux"; if (ua.includes("yabrowser") || ua.includes("yandex")) browser = "Yandex"; else if (ua.includes("brave")) browser = "Brave"; else if (ua.includes("vivaldi")) browser = "Vivaldi"; else if (ua.includes("samsungbrowser") || ua.includes("samsung")) browser = "Samsung Browser"; else if (ua.includes("ucbrowser") || ua.includes("uc browser")) browser = "UC Browser"; else if (ua.includes("opera") || ua.includes("opr/") || ua.includes("opios")) browser = "Opera"; else if (ua.includes("edg/") || ua.includes("edge") || ua.includes("edgios")) browser = "Edge"; else if (ua.includes("firefox") && !ua.includes("seamonkey")) browser = "Firefox"; else if (ua.includes("trident") || ua.includes("msie")) browser = "Internet Explorer"; else if (ua.includes("safari") && !ua.includes("chrome") && !ua.includes("chromium")) browser = "Safari"; else if (ua.includes("chrome") || ua.includes("chromium") || ua.includes("crios")) browser = "Chrome"; const mobileKeywords = [ "mobile", "android", "iphone", "ipod", "blackberry", "windows phone", "opera mini", "iemobile", "mobile safari", "nokia", "symbian" ]; const isTablet = [ "ipad", "tablet", "kindle", "silk", "gt-p", "sm-t", "nexus 7", "nexus 10" ].some((keyword) => ua.includes(keyword)); const isMobile = mobileKeywords.some((keyword) => ua.includes(keyword)); if (isTablet) device = "TABLET"; else if (isMobile) device = "MOBILE"; else device = "DESKTOP"; return { os, browser, device }; } }; //#endregion //#region ../../src/server/core/services/ServerRequestParser.ts const envSchema$2 = z.object({ /** * Trust proxy headers (X-Forwarded-For, X-Real-IP) for client IP resolution. * * Default: true (modern deployments are typically behind a reverse proxy) * * Set to false only if your server accepts direct connections without a proxy * and you want to use the raw connection IP. */ TRUST_PROXY: z.boolean().describe("Trust proxy headers for client IP").default(true) }); var ServerRequestParser = class ServerRequestParser { alepha = $inject(Alepha); userAgentParser = $inject(UserAgentParser); cryptoProvider = $inject(CryptoProvider); env = $env(envSchema$2); rootURL = new URL("http://localhost/"); createServerRequest(partialRawRequest) { const rawRequest = { method: "GET", url: this.rootURL, headers: {}, query: {}, params: {}, ...partialRawRequest }; const self = this; return { method: rawRequest.method, url: rawRequest.url, raw: rawRequest.raw, headers: rawRequest.headers, query: rawRequest.query, params: rawRequest.params, body: null, metadata: {}, reply: this.alepha.inject(ServerReply, { lifetime: "transient" }), get requestId() { return self.getRequestId(rawRequest); }, get ip() { return self.getRequestIp(rawRequest); }, get userAgent() { return self.getRequestUserAgent(rawRequest); }, get geo() { return self.getRequestGeo(rawRequest); }, get isBot() { return self.getIsBot(rawRequest); }, get isMobile() { return self.getIsMobile(rawRequest); }, get protocol() { return self.getProtocol(rawRequest); }, get language() { return self.getLanguage(rawRequest); }, get referer() { return self.getReferer(rawRequest); } }; } getRequestId(request) { return request.headers["x-request-id"] || this.cryptoProvider.randomUUID(); } getRequestUserAgent(request) { return this.userAgentParser.parse(request.headers["user-agent"]); } getRequestIp(request) { if (this.env.TRUST_PROXY) { const headers = request.headers; const forwardedFor = headers["x-forwarded-for"]; if (forwardedFor) return Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor.split(",")[0].trim(); const xRealIP = headers["x-real-ip"]; if (xRealIP) return Array.isArray(xRealIP) ? xRealIP[0] : xRealIP; } return this.getConnectionIp(request); } getConnectionIp(request) { const nodeReq = request.raw.node?.req; if (nodeReq) return nodeReq.socket?.remoteAddress; } getRequestGeo(request) { const headers = request.headers; return { country: headers["cf-ipcountry"] || headers["x-vercel-ip-country"] || headers["cloudfront-viewer-country"], city: headers["cf-ipcity"] || headers["x-vercel-ip-city"], region: headers["cf-region"] || headers["cf-region-code"] || headers["x-vercel-ip-country-region"], latitude: headers["cf-iplatitude"] || headers["x-vercel-ip-latitude"], longitude: headers["cf-iplongitude"] || headers["x-vercel-ip-longitude"] }; } static BOT_PATTERNS = [ /bot/i, /crawl/i, /spider/i, /slurp/i, /googlebot/i, /bingbot/i, /yandex/i, /baiduspider/i, /facebookexternalhit/i, /twitterbot/i, /linkedinbot/i, /whatsapp/i, /telegrambot/i, /discordbot/i, /slackbot/i, /applebot/i, /duckduckbot/i, /semrush/i, /ahrefsbot/i, /mj12bot/i, /dotbot/i, /petalbot/i, /bytespider/i, /gptbot/i, /claudebot/i, /anthropic/i, /curl/i, /wget/i, /python-requests/i, /axios/i, /node-fetch/i, /go-http-client/i, /java\//i, /libwww/i, /httpunit/i, /nutch/i, /phpcrawl/i, /biglotron/i, /teoma/i, /convera/i, /gigablast/i, /ia_archiver/i, /webmon/i, /httrack/i, /grub\.org/i, /netresearchserver/i, /speedy/i, /fluffy/i, /findlink/i, /panscient/i, /ips-agent/i, /yanga/i, /cyberpatrol/i, /postrank/i, /page2rss/i, /linkdex/i, /ezooms/i, /heritrix/i, /findthatfile/i, /europarchive\.org/i, /mappydata/i, /eright/i, /apercite/i, /aboundex/i, /domaincrawler/i, /wbsearchbot/i, /summify/i, /ccbot/i, /edisterbot/i, /seznambot/i, /ec2linkfinder/i, /gslfbot/i, /aihitbot/i, /intelium_bot/i, /yeti/i, /retrevopageanalyzer/i, /lb-spider/i, /sogou/i, /lssbot/i, /careerbot/i, /wotbox/i, /wocbot/i, /ichiro/i, /duckduckgo/i, /lssrocketcrawler/i, /drupact/i, /webcompanycrawler/i, /acoonbot/i, /openindexspider/i, /screaming frog/i, /pingdom/i, /uptimerobot/i, /headlesschrome/i, /phantomjs/i, /prerender/i, /lighthouse/i, /pagespeed/i ]; getIsBot(request) { const ua = request.headers["user-agent"]; if (!ua) return false; return ServerRequestParser.BOT_PATTERNS.some((pattern) => pattern.test(ua)); } static MOBILE_PATTERNS = [ /android/i, /webos/i, /iphone/i, /ipad/i, /ipod/i, /blackberry/i, /iemobile/i, /opera mini/i, /mobile/i, /tablet/i, /kindle/i, /silk/i, /fennec/i, /windows phone/i, /windows ce/i, /symbian/i, /palm/i, /webmate/i ]; getIsMobile(request) { const ua = request.headers["user-agent"]; if (!ua) return false; return ServerRequestParser.MOBILE_PATTERNS.some((pattern) => pattern.test(ua)); } getProtocol(request) { const forwardedProto = request.headers["x-forwarded-proto"]; if (forwardedProto) return forwardedProto.toLowerCase() === "https" ? "https" : "http"; const cfVisitorHeader = request.headers["cf-visitor"]; if (cfVisitorHeader) try { if (JSON.parse(cfVisitorHeader).scheme === "https") return "https"; } catch {} if (request.url.protocol === "https:") return "https"; return "http"; } getLanguage(request) { const acceptLanguage = request.headers["accept-language"]; if (!acceptLanguage) return void 0; const firstLang = acceptLanguage.split(",")[0]; if (!firstLang) return void 0; return firstLang.split(";")[0].trim() || void 0; } getReferer(request) { const referer = request.headers.referer || request.headers.referrer; if (!referer) return void 0; try { const url = new URL(referer); return { url: referer, hostname: url.hostname, pathname: url.pathname }; } catch { return; } } }; //#endregion //#region ../../src/server/core/providers/ServerTimingProvider.ts var ServerTimingProvider = class { log = $logger(); dateTime = $inject(DateTimeProvider); alepha = $inject(Alepha); options = { prefix: this.alepha.env.APP_NAME ? `${this.alepha.env.APP_NAME.toLowerCase()}-` : "", disabled: this.alepha.isProduction() }; onRequest = $hook({ priority: "first", on: "server:onRequest", handler: ({ request }) => { if (this.options.disabled) return; request.metadata.timing = {}; request.metadata.timing[this.handlerName] = [this.dateTime.nowMillis()]; } }); onResponse = $hook({ priority: "last", on: "server:onResponse", handler: ({ request }) => { if (this.options.disabled) return; if (request.metadata.timing) { this.setDuration(this.handlerName, request.metadata.timing); let timingHeader = ""; for (const [name, [start, duration]] of Object.entries(request.metadata.timing)) { if (typeof start !== "number" || typeof duration !== "number") { this.log.warn(`Invalid timing for '${name}': [${start}, ${duration}]`); continue; } const formattedName = this.options.prefix + name.replace(/[^a-zA-Z0-9-]/g, "-"); timingHeader += `${formattedName};dur=${duration}, `; } if (request.reply.headers["Server-Timing"]) request.reply.headers["Server-Timing"] += `, ${timingHeader}`; else request.reply.headers["Server-Timing"] = timingHeader; } } }); get handlerName() { return `request`; } beginTiming(name) { if (this.options.disabled) return; const request = this.alepha.store.get("alepha.http.request"); if (!request) return; request.metadata ??= {}; request.metadata.timing ??= {}; request.metadata.timing[name] = [this.dateTime.nowMillis()]; } endTiming(name) { if (this.options.disabled) return; const request = this.alepha.store.get("alepha.http.request"); if (!request) return; if (!request.metadata?.timing || !(name in request.metadata.timing)) { this.log.warn(`No timing found for '${name}'.`); return; } const start = request.metadata.timing[name]?.[0]; if (typeof start !== "number") { this.log.warn(`Invalid timing start for '${name}': ${start}`); return; } this.setDuration(name, request.metadata.timing); } setDuration(name, timing) { timing[name] = [timing[name][0], this.dateTime.nowMillis() - timing[name][0]]; } }; //#endregion //#region ../../src/server/core/providers/ServerRouterProvider.ts /** * Main router for all routes server side. * * Reminder: * - $route => generic route * - $action => action route (for API calls) * - $page => React route (for React SSR) */ var ServerRouterProvider = class extends RouterProvider { log = $logger(); alepha = $inject(Alepha); crypto = $inject(CryptoProvider); routes = []; serverTimingProvider = $inject(ServerTimingProvider); serverRequestParser = $inject(ServerRequestParser); queryKeysCache = /* @__PURE__ */ new WeakMap(); globalMiddlewareRegistry = []; /** * Get cached keys for a query schema, computing them lazily on first access. */ getQuerySchemaKeys(schema) { let keys = this.queryKeysCache.get(schema.properties); if (!keys) { keys = Object.keys(schema.properties); this.queryKeysCache.set(schema.properties, keys); } return keys; } pushMiddleware(pattern, middleware, options) { 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). */ matchesMiddlewareFilter(route, options) { if (options?.method) { const methods = Array.isArray(options.method) ? options.method : [options.method]; if (route.method && !methods.includes(route.method.toUpperCase())) 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. */ matchesPattern(routePath, pattern) { 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. */ getRoutes(pattern) { 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`. */ createRoute(input) { if (!(input.handler instanceof PipelineHandler)) input.handler = new PipelineHandler(input.handler); const route = input; route.method ??= "GET"; route.method = route.method.toUpperCase(); 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. */ getContextId(headers) { 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 */ async processRequest(request, route, responseKind) { try { await this.runRouteHandler(route, request, responseKind); } catch (error) { await this.errorHandler(route, request, error); } const payload = { request, route, response: void 0 }; await this.alepha.events.emit("server:onSend", payload, { catch: true }); const reply = request.reply; const response = { status: reply.status ?? (reply.body ? 200 : 204), headers: reply.headers, body: reply.body }; 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. */ async runRouteHandler(route, request, responseKind) { await this.alepha.events.emit("server:onRequest", { request, route }); const reply = request.reply; if (reply.body || reply.status && reply.status >= 200) return; request.metadata.routePath = route.path; request.metadata.routeMethod = route.method; 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. */ serializeResponse(route, reply, responseKind) { 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" }); 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; headers["content-disposition"] = `attachment; filename="${reply.body.name.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\r", "").replaceAll("\n", "")}"`; reply.body = reply.body.stream(); return; } if (responseKind === "text") { reply.body = String(reply.body); 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["content-type"]; reply.body = void 0; return; } if (Buffer.isBuffer(reply.body)) { headers["content-type"] ??= "application/octet-stream"; return; } if (reply.body instanceof ReadableStream$1 || reply.body instanceof Readable) { headers["content-type"] ??= "application/octet-stream"; return; } 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. */ getResponseType(schema) { 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. */ async errorHandler(route, request, error) { const reply = request.reply; reply.body = null; await this.alepha.events.emit("server:onError", { request, route, error }); 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; } 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], message: status >= 500 && this.alepha.isProduction() ? "Internal Server Error" : error.message, requestId }); return; } 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. */ validateRequest(route, request) { if (route.schema?.params) try { const schemaParams = route.schema.params; const coerced = { ...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); } 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 = {}; 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; const decoded = {}; 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[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. */ coerceParam(schema, value) { return coerceScalar(schema, value); } }; //#endregion //#region ../../src/server/core/providers/ServerProvider.ts const HEADER_X_FORWARDED_PROTO = "x-forwarded-proto"; const HEADER_HOST = "host"; 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. */ var ServerProvider = class { log = $logger(); alepha = $inject(Alepha); dateTimeProvider = $inject(DateTimeProvider); router = $inject(ServerRouterProvider); internalServerErrorMessage = "Internal Server Error"; internalErrorResponse = Object.freeze({ status: 500, headers: Object.freeze({ "content-type": "text/plain" }), body: this.internalServerErrorMessage }); handleInternalError = () => this.internalErrorResponse; urlBaseCache = /* @__PURE__ */ new Map(); /** * Get cached URL base (protocol + host) for a given host header. * Caches the result to avoid repeated string concatenation. */ getUrlBase(headers) { const host = headers[HEADER_HOST]; const cacheKey = (headers[HEADER_X_FORWARDED_PROTO] === "https" ? PROTO_HTTPS : PROTO_HTTP) + host; let base = this.urlBaseCache.get(cacheKey); if (!base) { base = cacheKey; if (this.urlBaseCache.size < 100) this.urlBaseCache.set(cacheKey, base); } return base; } /** * Parse query string manually - faster than new URL() + URLSearchParams. * Returns empty object if no query string. */ parseQueryString(rawUrl) { const qIndex = rawUrl.indexOf("?"); if (qIndex === -1) return {}; const qs = rawUrl.slice(qIndex + 1); if (!qs) return {}; const query = {}; let start = 0; let eqIdx = -1; for (let i = 0; i <= qs.length; i++) { const char = i < qs.length ? qs.charCodeAt(i) : 38; 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); query[this.fastDecode(key)] = this.fastDecode(value); } start = i + 1; eqIdx = -1; } } return query; } /** * Fast decode - only calls decodeURIComponent if needed. */ fastDecode(str) { if (str.indexOf("%") === -1 && str.indexOf("+") === -1) return str; try { return decodeURIComponent(str.replace(/\+/g, " ")); } catch { return str; } } get hostname() { if (this.alepha.isViteDev()) return `http://localhost:${this.alepha.env.SERVER_PORT}`; return ""; } /** * When a Node.js HTTP request is received from outside. (Vercel, AWS Lambda, etc.) */ onNodeRequest = $hook({ on: "node:request", handler: (ev) => this.handleNodeRequest(ev) }); /** * When a Web (Fetch API) request is received from outside. (Netlify, Cloudflare Workers, etc.) */ 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. */ async handleNodeRequest(nodeRequestEvent) { const { req, res } = nodeRequestEvent; const rawUrl = req.url; const { route, params } = this.router.match(`/${req.method}${rawUrl}`); if (!route) { if (res.headersSent) return; res.writeHead(404, { "content-type": "text/plain" }); res.end("Not Found"); return; } const headers = req.headers ?? {}; const method = req.method?.toUpperCase() ?? "GET"; const query = this.parseQueryString(rawUrl); const urlBase = this.getUrlBase(headers); const request = { method, url: new URL(rawUrl, urlBase), headers, params: params ?? {}, query, raw: { node: nodeRequestEvent } }; const response = await route.handler(request).catch(this.handleInternalError); if (res.headersSent) return; if (!response.body) { res.writeHead(response.status, response.headers); res.end(); return; } if (typeof response.body === "string" || Buffer.isBuffer(response.body)) { res.writeHead(response.status, response.headers); res.end(response.body); return; } if (response.body instanceof Readable) { res.writeHead(response.status, response.headers); response.body.pipe(res); return; } if (response.body instanceof ReadableStream) { res.writeHead(response.status, response.headers); res.flushHeaders(); res.socket?.setNoDelay(true); try { for await (const chunk of response.body) if (!res.write(chunk)) await new Promise((resolve) => res.once("drain", resolve)); } catch (error) { this.log.error("Error piping proxy response stream", error); } finally { res.end(); } return; } 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. */ async handleWebRequest(ev) { 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) { ev.res = new Response("Not Found", { status: 404, headers: { "content-type": "text/plain" } }); return; } const headers = {}; req.headers.forEach((value, key) => { headers[key] = value; }); const query = url.search ? Object.fromEntries(url.searchParams.entries()) : {}; const request = { method: req.method.toUpperCase() ?? "GET", url, headers, params: params || {}, query, raw: { web: ev } }; const response = await route.handler(request).catch(this.handleInternalError); const webHeaders = this.toWebHeaders(response.headers); if (!response.body) { ev.res = new Response(null, { status: response.status, headers: webHeaders }); return; } if (typeof response.body === "string") { ev.res = new Response(response.body, { status: response.status, headers: webHeaders }); return; } if (Buffer.isBuffer(response.body)) { ev.res = new Response(new Uint8Array(response.body), { status: response.status, headers: webHeaders }); return; } if (response.body instanceof Readable) { ev.res = new Response(Readable.toWeb(response.body), { status: response.status, headers: webHeaders }); return; } if (response.body instanceof ReadableStream) { ev.res = new Response(response.body, { status: response.status, headers: webHeaders }); return; } 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. */ toWebHeaders(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. */ isViteNotFound(url, route, params) { if (this.alepha.isViteDev()) { if (!route) return true; url = url?.split("?")[0]; if (params?.["*"] && `/${params?.["*"]}` === url) return true; } return false; } }; //#endregion //#region ../../src/server/core/schemas/errorSchema.ts const errorSchema = z.object({ error: z.text({ description: "HTTP error name" }), status: z.integer().describe("HTTP status code"), message: z.text({ description: "Short text which describe the error", size: "rich" }), details: z.text({ description: "Detailed description of the error", size: "rich" }).optional(), requestId: z.text().optional(), cause: z.object({ name: z.text(), message: z.text({ description: "Cause Error message", size: "rich" }) }).optional() }).meta({ title: "HttpError" }).describe("Generic response after a failed operation"); //#endregion //#region ../../src/server/core/services/HttpClient.ts var HttpClient = class { log = $logger(); alepha = $inject(Alepha); cache = $cache({ provider: "memory", name: "http:client" }); pendingRequests = {}; async fetchAction(args) { const route = args.action; const options = args.options ?? {}; const config = args.config ?? {}; const host = args.host ?? ""; const request = { ...options.request }; const method = route.method; const headers = {}; const url = this.url(host, route, config); await this.alepha.events.emit("client:onRequest", { route, config, options, headers, request }); request.method ??= method; await this.body(request, headers, route, config); request.headers = { ...config.headers, ...Object.fromEntries(new Headers(request.headers).entries()), ...headers }; return await this.fetch(url, { ...request, schema: route.schema, ...options }); } async fetch(url, request = {}) { const options = { cache: request.localCache, schema: request.schema?.response, key: request.key }; request.method ??= "GET"; this.log.trace("Request", { url, method: request.method, body: request.body, headers: request.headers, options }); const cached = await this.cache.get(url); if (cached && request.method === "GET") if (cached.etag) { request.headers = new Headers(request.headers); if (!request.headers.has("if-none-match")) request.headers.set("if-none-match", cached.etag); } else return { data: cached.data, status: 200, statusText: "OK", headers: new Headers() }; await this.alepha.events.emit("client:beforeFetch", { url, options, request }); const isIdempotent = request.method === "GET" || request.method === "HEAD" || request.method === "OPTIONS"; const key = options.key ?? (isIdempotent ? JSON.stringify({ url, method: request.method }) : void 0); if (key) { const existing = this.pendingRequests[key]; if (existing) { this.log.info("Request already pending", key); return existing; } } const promise = fetch(url, request).then(async (response) => { this.log.debug("Response", { url, status: response.status }); const fetchResponse = { data: await this.responseData(response, options), status: response.status, statusText: response.statusText, headers: response.headers, raw: response }; if (request.method === "GET") { if (options.cache) await this.cache.set(url, { data: fetchResponse.data }, typeof options.cache === "boolean" ? void 0 : options.cache); else if (!this.alepha.isBrowser()) { const etag = response.headers.get("etag") ?? void 0; if (etag) await this.cache.set(url, { data: fetchResponse.data, etag }); } } return fetchResponse; }).finally(() => { if (key) delete this.pendingRequests[key]; }); if (key) this.pendingRequests[key] = promise; return promise; } url(host, action, args) { let url = host; if (action.prefix) url += action.prefix; url += action.path; url = this.pathVariables(url, action, args); url = this.queryParams(url, action, args); return url; } async body(init, headers, action, args = {}) { if (typeof init.headers === "object" && "content-type" in init.headers && init.headers["content-type"] === "multipart/form-data" || isMultipart(action)) { if (typeof init.headers === "object" && "content-type" in init.headers) delete init.headers["content-type"]; const formData = new FormData(); for (const [key, value] of Object.entries(args.body ?? {})) { if (typeof value === "string") { formData.append(key, value); continue; } if (value instanceof Blob) { formData.append(key, value); continue; } if (isFileLike(value)) formData.append(key, new File([await value.arrayBuffer()], value.name, { type: value.type })); } init.body = formData; return; } if (!init.body && action.schema?.body) { headers["content-type"] = "application/json"; init.body = this.alepha.codec.encode(action.schema?.body, args.body, { as: "string" }); } } async responseData(response, options) { if (response.status === 304) { let cacheKey = response.url; if (typeof window !== "undefined") cacheKey = cacheKey.replace(window.location.origin, ""); const cached = await this.cache.get(cacheKey); if (cached) return cached.data; return ""; } if (response.status === 204) return; if (this.isMaybeFile(response)) return this.createFileLike(response); if (response.headers.get("Content-Type")?.startsWith("text/")) return await response.text(); if (response.headers.get("Content-Type") === "application/json") { const json = await response.json(); if (response.status >= 400) { const error = new HttpError(this.alepha.codec.decode(errorSchema, json)); await this.alepha.events.emit("client:onError", { error }); throw error; } if (options.schema) return this.alepha.codec.decode(options.schema, json); return json; } if (response.status >= 400) { const error = new HttpError({ status: response.status, message: `An error occurred while fetching the resource. (${response.statusText})` }); await this.alepha.events.emit("client:onError", { error }); throw error; } return response; } isMaybeFile(response) { const contentType = response.headers.get("Content-Type"); if (!contentType) return false; if (response.headers.get("Content-Disposition")?.includes("attachment")) return true; return contentType.startsWith("application/octet-stream") || contentType.startsWith("application/pdf") || contentType.startsWith("application/zip") || contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/"); } createFileLike(response, defaultFileName = "") { const match = (response.headers.get("Content-Disposition") ?? "").match(/filename="(.+)"/); return { name: match?.[1] ? match[1] : defaultFileName, type: response.headers.get("Content-Type") ?? "application/octet-stream", size: Number(response.headers.get("Content-Length") ?? 0), lastModified: Date.now(), stream: () => { throw new AlephaError("Not implemented"); }, arrayBuffer: async () => { return await response.arrayBuffer(); }, text: async () => { return await response.text(); } }; } pathVariables(url, action, args = {}) { if (typeof args.params === "object") { const params = action.schema?.params ? this.alepha.codec.decode(action.schema.params, args.params) : args.params; for (const key of Object.keys(params)) { url = url.replace(`:${key}`, params[key]); url = url.replace(`{${key}}`, params[key]); } } return url; } queryParams(url, action, args = {}) { if (typeof args.query === "object") { const query = action.schema?.query ? this.alepha.codec.decode(action.schema.query, args.query ?? {}) : args.query; for (const key of Object.keys(query)) { if (query[key] === void 0) delete query[key]; if (typeof query[key] === "object") query[key] = JSON.stringify(query[key]); } const params = new URLSearchParams(query).toString(); return params ? `${url}?${params}` : url; } return url; } }; //#endregion //#region ../../src/server/core/primitives/$action.ts /** * Creates a server action primitive for defining type-safe HTTP endpoints. * * Server actions are the core building blocks for REST APIs in Alepha, providing declarative * HTTP endpoints with full type safety, automatic validation, and OpenAPI documentation. * * **Key Features** * - Full TypeScript inference for request/response types * - Automatic schema validation using TypeBox * - Convention-based URL generation with customizable paths * - Direct invocation (`run()`) or HTTP requests (`fetch()`) * - Built-in authentication and authorization support * - Automatic content-type handling (JSON, form-data, plain text) * * **URL Generation** * * **Important:** All `$action` paths are automatically prefixed with `/api`. * * ```ts * $action({ path: "/users" }) // → GET /api/users * $action({ path: "/users/:id" }) // → GET /api/users/:id * $action({ path: "/hello" }) // → GET /api/hello * ``` * * This prefix is configurable via the `SERVER_API_PREFIX` environment variable. * HTTP method defaults to GET, or POST if body schema is provided. * * **Common Use Cases** * - CRUD operations with type safety * - File upload and download endpoints * - Microservice communication * * @example * ```ts * class UserController { * getUsers = $action({ * path: "/users", * schema: { * query: z.object({ * page: z.number({ default: 1 }).optional(), * limit: z.number({ default: 10 }).optional() * }), * response: z.object({ * users: z.array(z.object({ * id: z.text(), * name: z.text(), * email: z.text() * })), * total: z.number() * }) * }, * handler: async ({ query }) => { * const users = await this.userService.findUsers(query); * return { users: users.items, total: users.total }; * } * }); * * createUser = $action({ * method: "POST", * path: "/users", * schema: { * body: z.object({ * name: z.text(), * email: z.text({ format: "email" }) * }), * response: z.object({ id: z.text(), name: z.text() }) * }, * handler: async ({ body }) => { * return await this.userService.create(body); * } * }); * } * ``` */ const $action = (options) => { const instance = createPrimitive(ActionPrimitive, options); const fn = (config, options) => { return instance.run(config, options); }; Object.defineProperty(fn, "name", { get() { return instance.options.name || instance.config.propertyKey; } }); return Object.setPrototypeOf(fn, instance); }; /** * Server API configuration atom. */ const serverApiOptions = $atom({ name: "alepha.server.api.options", schema: z.object({ prefix: z.text({ default: "/api", description: "Prefix for all API routes (e.g. $action)." }) }), default: { prefix: "/api" } }); var ActionPrimitive = class extends PipelinePrimitive { log = $logger(); settings = $state(serverApiOptions); httpClient = $inject(HttpClient); serverProvider = $inject(ServerProvider); serverRouterProvider = $inject(ServerRouterProvider); onInit() { if (this.options.disabled) { this.log.debug(`Action '${this.name}' is disabled. It won't be available in the API.`); return; } this.serverRouterProvider.createRoute(this.route); } get prefix() { return this.settings.prefix; } get route() { return { ...this.options, method: this.method, path: `${this.prefix}${this.path}`, handler: this.handler }; } /** * Returns the name of the action. */ get name() { return this.options.name || this.config.propertyKey; } /** * Returns the group of the action. (e.g. "orders", "admin", etc.) */ get group() { return this.options.group || this.config.service.name; } /** * Returns the HTTP method of the action. */ get method() { return this.options.method || (this.options.schema?.body ? "POST" : "GET"); } /** * Returns the path of the action. * * Path is prefixed by `/api` by default. */ get path() { if (this.options.path) return this.options.path; let path = `/${this.name}`; if (this.options.schema?.params) for (const [key] of Object.entries(this.options.schema.params.properties)) path += `/:${key}`; return path; } get schema() { return this.options.schema; } getBodyContentType() { if (this.options.schema?.body) { if (isMultipart(this.options)) return "multipart/form-data"; if (z.schema.isString(this.options.schema.body)) return "text/plain"; if (z.schema.isObject(this.options.schema.body) || z.schema.isArray(this.options.schema.body) || z.schema.isRecord(this.options.schema.body)) return "application/json"; } } /** * Call the action handler directly. * There is no HTTP layer involved. */ async run(config, options = {}) { if (this.options.disabled) throw new AlephaError(`Action '${this.name}' is disabled.`); const handler = this.handler.run.bind(this.handler); const { body, params = {}, query = {}, headers = {} } = config ?? {}; const reply = new ServerReply(); const serverActionRequest = { method: this.method, url: new URL(`http://localhost${this.path ?? ""}`), body, params, query, headers, reply, metadata: { routePath: this.route.path, routeMethod: this.route.method } }; const eventData = { action: this, request: serverActionRequest, options }; await this.alepha.events.emit("action:onRequest", eventData); if (serverActionRequest.reply?.body) return serverActionRequest.reply.body; const executeHandler = async () => { if (serverActionRequest.query && this.options.schema?.query) serverActionRequest.query = this.alepha.codec.encode(this.options.schema.query, serverActionRequest.query); if (serverActionRequest.headers && this.options.schema?.headers) { const schemaHeaders = this.options.schema.headers; const headers = serverActionRequest.headers; for (const key of Object.keys(schemaHeaders.properties)) { const lcKey = key.toLowerCase(); if (headers[lcKey] !== void 0) headers[lcKey] = this.alepha.codec.encode(schemaHeaders.properties[key], headers[lcKey]); } } if (serverActionRequest.body && this.options.schema?.body) serverActionRequest.body = this.alepha.codec.encode(this.options.schema.body, serverActionRequest.body); if (serverActionRequest.params && this.options.schema?.params) serverActionRequest.params = this.alepha.codec.encode(this.options.schema.params, serverActionRequest.params); this.serverRouterProvider.validateRequest(this.options, serverActionRequest); let response = await handler(serverActionRequest); if (this.options.schema?.response && !isTypeFile(this.options.schema.response)) response = this.alepha.codec.validate(this.options.schema.response, response); await this.alepha.events.emit("action:onResponse", { action: this, request: serverActionRequest, options, response }); return response; }; const forkData = { "alepha.action.request":