alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
1,575 lines • 95.3 kB
JavaScript
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":