├── www └── static │ └── input.css ├── src ├── BodyType.ts ├── CookieOptions.ts ├── HTTPHandlerFunc.ts ├── WSHandlerFuncs.ts ├── ContextState.ts ├── Method.ts ├── SystemErr.ts ├── XerusValidator.ts ├── SystemErrCode.ts ├── LoggerService.ts ├── std │ ├── Template.ts │ ├── Request.ts │ └── Response.ts ├── XerusPlugin.ts ├── TrieNode.ts ├── ObjectPool.ts ├── Validator.ts ├── PathParams.ts ├── RouteGroup.ts ├── URLQuery.ts ├── MutResponse.ts ├── macros.ts ├── XerusRoute.ts ├── DataBag.ts ├── ValidationSource.ts ├── WSContext.ts ├── CSRFService.ts ├── SystemErrRecord.ts ├── RouteFields.ts ├── TemplateStore.ts ├── Href.ts ├── Headers.ts ├── RateLimitService.ts └── Cookies.ts ├── tests ├── TestStore.ts ├── abf_ws_services_validatorsParity.test.ts ├── aao_safeguard.test.ts ├── aaq_injection.test.ts ├── aau_validator_pattern.test.ts ├── abi_validator_caching_across_serviceAndRoute.test.ts ├── abc_ws_validator.test.ts ├── aan_middleware_errors.test.ts ├── aab_architechture.test.ts ├── aba_ws_validation.test.ts ├── aah_parse_body.test.ts ├── aaw_data_integrity.test.ts ├── abe_ws_lifecycle_cleanup.test.ts ├── aax_injector_validators.test.ts ├── aal_routing_complexity.test.ts ├── abr_templates.test.ts ├── aap_object_pool.test.ts ├── aaz_ws_methods.test.ts ├── abk_body_reparse_and_header_injection_guards.test.ts ├── abd_context_safety.test.ts ├── aad.websockets.test.ts ├── aac_validation_security.test.ts ├── aak_error_handling.test.ts ├── aaa_http_core.test.ts ├── aaj_middlewares.test.ts ├── abl_cookies_parsing_and_set_cookie_defaults.test.ts ├── aag_static_files.test.ts ├── aar_flexible_validation.test.ts ├── abq_class_system_handlers.test.ts ├── aai_cookies.test.ts ├── abm_ws_per_message_scope_isolation.test.ts ├── aaq_precedence.test.ts ├── aaf_route_grouping.test.ts ├── abh_validator_return_and_freeze.test.ts ├── aae_basic_methods.test.ts ├── aay_ws_advanced.test.ts ├── aar_hardening.test.ts ├── abj_service_graph_lifecycle_and_on_error_order.test.ts └── aat_http_context_edge_cases.test.ts ├── package.json ├── tsconfig.json ├── Makefile ├── prompt.md ├── LICENSE ├── index.ts ├── bun.lock └── .gitignore /www/static/input.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; -------------------------------------------------------------------------------- /src/BodyType.ts: -------------------------------------------------------------------------------- 1 | // PATH: /home/jacex/src/xerus/src/BodyType.ts 2 | 3 | export enum BodyType { 4 | JSON = "json", 5 | TEXT = "text", 6 | FORM = "form", 7 | MULTIPART_FORM = "multipart_form", 8 | } 9 | -------------------------------------------------------------------------------- /src/CookieOptions.ts: -------------------------------------------------------------------------------- 1 | export interface CookieOptions { 2 | path?: string; 3 | domain?: string; 4 | maxAge?: number; 5 | expires?: Date; 6 | httpOnly?: boolean; 7 | secure?: boolean; 8 | sameSite?: "Strict" | "Lax" | "None"; 9 | } 10 | -------------------------------------------------------------------------------- /src/HTTPHandlerFunc.ts: -------------------------------------------------------------------------------- 1 | import { HTTPContext } from "./HTTPContext"; 2 | 3 | export type HTTPHandlerFunc = (c: HTTPContext) => Promise; 4 | export type HTTPErrorHandlerFunc = ( 5 | c: HTTPContext, 6 | err: Error | any, 7 | ) => Promise; 8 | -------------------------------------------------------------------------------- /src/WSHandlerFuncs.ts: -------------------------------------------------------------------------------- 1 | import type { HTTPContext } from "./HTTPContext"; 2 | 3 | export type WSOpenFunc = (c: HTTPContext) => Promise; 4 | export type WSMessageFunc = (c: HTTPContext) => Promise; 5 | export type WSDrainFunc = (c: HTTPContext) => Promise; 6 | export type WSCloseFunc = (c: HTTPContext) => Promise; 7 | -------------------------------------------------------------------------------- /src/ContextState.ts: -------------------------------------------------------------------------------- 1 | export enum ContextState { 2 | OPEN = "OPEN", // Everything is mutable 3 | WRITTEN = "WRITTEN", // Body is set, Handler chain stops, but Headers are STILL mutable (Fixes Onion pattern) 4 | STREAMING = "STREAMING", // Streaming started. Headers are IMMUTABLE. 5 | SENT = "SENT", // Response handed off. Immutable. 6 | } 7 | -------------------------------------------------------------------------------- /src/Method.ts: -------------------------------------------------------------------------------- 1 | export enum Method { 2 | // HTTP 3 | GET = "GET", 4 | POST = "POST", 5 | PUT = "PUT", 6 | PATCH = "PATCH", 7 | DELETE = "DELETE", 8 | OPTIONS = "OPTIONS", 9 | HEAD = "HEAD", 10 | 11 | // WebSocket 12 | WS_OPEN = "OPEN", 13 | WS_MESSAGE = "MESSAGE", 14 | WS_CLOSE = "CLOSE", 15 | WS_DRAIN = "DRAIN", 16 | } 17 | -------------------------------------------------------------------------------- /tests/TestStore.ts: -------------------------------------------------------------------------------- 1 | import type { InjectableStore } from "../src/RouteFields"; 2 | 3 | export class TestStore implements InjectableStore { 4 | storeKey = "TestStore"; 5 | 6 | requestId?: string; 7 | csrfToken?: string; 8 | test_val?: string; 9 | secretKey?: string; 10 | 11 | __timeoutSent?: boolean; 12 | __holdRelease?: Promise; 13 | 14 | [key: string]: any; 15 | } 16 | -------------------------------------------------------------------------------- /src/SystemErr.ts: -------------------------------------------------------------------------------- 1 | import type { SystemErrCode } from "./SystemErrCode"; 2 | 3 | export class SystemErr extends Error { 4 | typeOf: SystemErrCode; 5 | constructor(typeOf: SystemErrCode, message: string) { 6 | super(`${typeOf}: ${message}`); 7 | this.typeOf = typeOf; 8 | if (Error.captureStackTrace) { 9 | Error.captureStackTrace(this, SystemErr); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xerus", 3 | "version": "0.0.39", 4 | "module": "index.ts", 5 | "type": "module", 6 | "devDependencies": { 7 | "@types/bun": "latest" 8 | }, 9 | "peerDependencies": {}, 10 | "bin": { 11 | "xerus": "./xerus.ts" 12 | }, 13 | "dependencies": { 14 | "@types/node": "^25.0.3", 15 | "typescript": "^5.0.0", 16 | "zod": "^4.2.1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/XerusValidator.ts: -------------------------------------------------------------------------------- 1 | // --- START FILE: src/TypeValidator.ts --- 2 | import type { HTTPContext } from "./HTTPContext"; 3 | 4 | /** 5 | * Validators must return a value. 6 | * That returned value is what gets stored and later accessed via `c.validated(Type)`. 7 | */ 8 | export interface XerusValidator { 9 | validate(c: HTTPContext): Promise | TOut; 10 | } 11 | // --- END FILE --- 12 | -------------------------------------------------------------------------------- /src/SystemErrCode.ts: -------------------------------------------------------------------------------- 1 | export enum SystemErrCode { 2 | FILE_NOT_FOUND = "FILE_NOT_FOUND", 3 | BODY_PARSING_FAILED = "BODY_PARSING_FAILED", 4 | VALIDATION_FAILED = "VALIDATION_FAILED", 5 | ROUTE_ALREADY_REGISTERED = "ROUTE_ALREADY_REGISTERED", 6 | ROUTE_NOT_FOUND = "ROUTE_NOT_FOUND", 7 | INTERNAL_SERVER_ERR = "INTERNAL_SERVER_ERROR", 8 | WEBSOCKET_UPGRADE_FAILURE = "WEBSOCKET_UPGRADE_FAILURE", 9 | HEADERS_ALREADY_SENT = "HEADERS_ALREADY_SENT", 10 | } 11 | -------------------------------------------------------------------------------- /src/LoggerService.ts: -------------------------------------------------------------------------------- 1 | // src/services/LoggerService.ts 2 | 3 | import type { HTTPContext } from "./HTTPContext"; 4 | 5 | 6 | export class LoggerService { 7 | private startTime: number = 0; 8 | 9 | async before(c: HTTPContext) { 10 | this.startTime = performance.now(); 11 | } 12 | 13 | async after(c: HTTPContext) { 14 | const duration = performance.now() - this.startTime; 15 | console.log(`[${c.req.method}][${c.path}][${duration.toFixed(2)}ms]`); 16 | } 17 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/std/Template.ts: -------------------------------------------------------------------------------- 1 | // --- START FILE: src/std/Template.ts --- 2 | import type { HTTPContext } from "../HTTPContext"; 3 | import { TemplateStore } from "../TemplateStore"; 4 | 5 | /** 6 | * Read a template from the app-registered TemplateStore. 7 | * 8 | * Usage: 9 | * const html = template(c, "index.html"); 10 | * const html = template(c, "./index.html"); 11 | * const html = template(c, "/sub/dir/page.html"); 12 | */ 13 | export function template(c: HTTPContext, relPath: string): string { 14 | const store = c.global(TemplateStore); 15 | return store.text(relPath); 16 | } 17 | 18 | /** 19 | * If you ever want bytes (e.g. for binary templates / precompressed / etc) 20 | */ 21 | export function templateBytes(c: HTTPContext, relPath: string): Uint8Array { 22 | const store = c.global(TemplateStore); 23 | return store.bytes(relPath); 24 | } 25 | // --- END FILE: src/std/Template.ts --- 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | kill: 2 | sudo lsof -t -i:8080 | xargs kill -9 3 | 4 | test: 5 | clear; bun test ./tests 6 | 7 | bench-raw: 8 | wrk -t12 -c400 -d30s http://localhost:8080/ 9 | 10 | bench-routing: 11 | wrk -t12 -c400 -d30s http://localhost:8080/users/12345 12 | 13 | bench-embedded: 14 | wrk -t12 -c400 -d10s http://localhost:8080/static-site/index.html 15 | 16 | bench-static: 17 | wrk -t12 -c400 -d10s http://localhost:8080/disk-src/Xerus.ts 18 | 19 | bench-json: 20 | @echo 'wrk.method = "POST"; wrk.body = "{\"name\": \"Benchmark Item\"}"; wrk.headers["Content-Type"] = "application/json"' > temp_post.lua 21 | wrk -t12 -c400 -d30s -s temp_post.lua http://localhost:8080/items 22 | @rm temp_post.lua 23 | 24 | docs: 25 | bun ./www/html/**/*.html 26 | 27 | build-docs: 28 | bun build ./www/html/**/*.html --outdir=dist --env=PUBLIC_* 29 | 30 | tw: 31 | tailwindcss -i './www/static/input.css' -o './www/static/output.css' --watch -------------------------------------------------------------------------------- /prompt.md: -------------------------------------------------------------------------------- 1 | This framework already has a way to embed and make static assets available, but what about embedding other content like templates or other information we might need to actually access in our routes? 2 | 3 | What if we could do something like this: 4 | 5 | ```ts 6 | let embedableDir = embedDir('/some/abs/path') 7 | let app = new Xerus() 8 | app.templates(embedableDir) 9 | ``` 10 | 11 | then in my route handlers I could access files like so: 12 | 13 | ```ts 14 | import {template} from 'xerus' 15 | 16 | async handle(c: HTTPContext) { 17 | let t = template(c, "/some/relative/path/from/the/templates/abs/path") // for example /some/abs/path/index.html about be accessible at template(c, './index.html') or template(c, 'index.html') 18 | // ^^ the above function should THROW if no template with the passes in name is available 19 | } 20 | ``` 21 | 22 | This would allow me to pipe in any file into the system and use it later -------------------------------------------------------------------------------- /src/XerusPlugin.ts: -------------------------------------------------------------------------------- 1 | import type { Xerus } from "./Xerus"; 2 | import type { XerusRoute } from "./XerusRoute"; 3 | 4 | export interface XerusPlugin { 5 | /** 6 | * Called immediately when app.plugin(SomePlugin) is called. 7 | */ 8 | onConnect?(app: Xerus): Promise | void; 9 | 10 | /** 11 | * Called whenever a route is mounted. 12 | * Useful for inspecting or modifying routes before they are locked into the router. 13 | */ 14 | onRegister?(app: Xerus, route: XerusRoute): Promise | void; 15 | 16 | /** 17 | * Called inside app.listen() before the server actually starts. 18 | * Useful for async setup (database connections, etc). 19 | */ 20 | onPreListen?(app: Xerus): Promise | void; 21 | 22 | /** 23 | * Called when the app is shutting down (SIGINT/SIGTERM or app.shutdown()). 24 | * Useful for cleaning up resources (closing DB connections, etc). 25 | */ 26 | onShutdown?(app: Xerus): Promise | void; 27 | } -------------------------------------------------------------------------------- /src/TrieNode.ts: -------------------------------------------------------------------------------- 1 | // --- START FILE: src/TrieNode.ts --- 2 | import type { HTTPErrorHandlerFunc } from "./HTTPHandlerFunc"; 3 | import type { XerusRoute, AnyServiceCtor, AnyValidatorCtor } from "./XerusRoute"; 4 | 5 | export interface RouteBlueprint { 6 | Ctor: new () => XerusRoute; 7 | errHandler?: HTTPErrorHandlerFunc; 8 | mounted?: { 9 | props: Record; 10 | }; 11 | 12 | // ✅ Unified typing: service/validator ctor lists are the same for HTTP + WS. 13 | services?: AnyServiceCtor[]; 14 | validators?: AnyValidatorCtor[]; 15 | 16 | wsChain?: { 17 | open?: RouteBlueprint; 18 | message?: RouteBlueprint; 19 | close?: RouteBlueprint; 20 | drain?: RouteBlueprint; 21 | }; 22 | } 23 | 24 | export class TrieNode { 25 | handlers: Record = {}; 26 | wsHandler?: { 27 | open?: RouteBlueprint; 28 | message?: RouteBlueprint; 29 | close?: RouteBlueprint; 30 | drain?: RouteBlueprint; 31 | }; 32 | children: Record = {}; 33 | paramKey?: string; 34 | wildcard?: TrieNode; 35 | } 36 | // --- END FILE --- 37 | -------------------------------------------------------------------------------- /src/ObjectPool.ts: -------------------------------------------------------------------------------- 1 | export class ObjectPool { 2 | private items: T[] = []; 3 | private factory: () => T; 4 | private limit: number; 5 | 6 | constructor(factory: () => T, size: number) { 7 | this.factory = factory; 8 | this.limit = size; 9 | 10 | // Pre-fill the pool 11 | for (let i = 0; i < size; i++) { 12 | this.items.push(this.factory()); 13 | } 14 | } 15 | 16 | acquire(): T { 17 | const item = this.items.pop(); 18 | // If pool is empty, create a new one on the fly (burst handling) 19 | return item ?? this.factory(); 20 | } 21 | 22 | release(item: T): void { 23 | // Only push back if we haven't exceeded the limit 24 | if (this.items.length < this.limit) { 25 | this.items.push(item); 26 | } 27 | } 28 | 29 | resize(newSize: number) { 30 | this.limit = newSize; 31 | // If current size is smaller than new size, fill it up 32 | while (this.items.length < newSize) { 33 | this.items.push(this.factory()); 34 | } 35 | // If current size is larger, we don't force drain, 36 | // we just let acquire/release naturally balance it out. 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Phillip England 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Validator.ts: -------------------------------------------------------------------------------- 1 | // --- START FILE: src/Validator.ts --- 2 | import type { TypeValidator } from "./XerusValidator"; 3 | 4 | /** 5 | * @deprecated 6 | * This API is intentionally disabled to enforce the new UX: 7 | * 8 | * class MyRoute extends XerusRoute { 9 | * validators = [MyValidator] 10 | * } 11 | * 12 | * Validators are now ctor-based and run by Xerus automatically. 13 | */ 14 | export class Validator { 15 | static Ctx>( 16 | _Type: new () => T, 17 | _storeKey?: string, 18 | ): never { 19 | throw new Error( 20 | `[XERUS] Validator.Ctx() has been removed.\n` + 21 | `Use: validators = [MyValidatorCtor]\n` + 22 | `Then read via: c.validated(MyValidatorCtor)`, 23 | ); 24 | } 25 | } 26 | 27 | /** 28 | * @deprecated 29 | * Same as Validator.Ctx(). Disabled to enforce the new UX. 30 | */ 31 | export function Validate>( 32 | _Type: new () => T, 33 | _storeKey?: string, 34 | ): never { 35 | throw new Error( 36 | `[XERUS] Validate() has been removed.\n` + 37 | `Use: validators = [MyValidatorCtor]\n` + 38 | `Then read via: c.validated(MyValidatorCtor)`, 39 | ); 40 | } 41 | // --- END FILE --- 42 | -------------------------------------------------------------------------------- /src/PathParams.ts: -------------------------------------------------------------------------------- 1 | // src/PathParams.ts 2 | export interface PathParamsView { 3 | get(key: string): string | null; 4 | has(key: string): boolean; 5 | toObject(): Record; 6 | } 7 | 8 | export class PathParams implements PathParamsView { 9 | private params: Record; 10 | 11 | constructor(params: Record) { 12 | this.params = params; 13 | } 14 | 15 | get(key: string): string | null { 16 | return this.params[key] ?? null; 17 | } 18 | 19 | has(key: string): boolean { 20 | return Object.prototype.hasOwnProperty.call(this.params, key); 21 | } 22 | 23 | toObject(): Record { 24 | return { ...this.params }; 25 | } 26 | 27 | ref(key: string): PathParamRef { 28 | return new PathParamRef(this, key); 29 | } 30 | } 31 | 32 | export class PathParamRef { 33 | private view: PathParamsView; 34 | private _key: string; 35 | 36 | constructor(view: PathParamsView, key: string) { 37 | this.view = view; 38 | this._key = key; 39 | } 40 | 41 | get key(): string { 42 | return this._key; 43 | } 44 | 45 | get(): string | null { 46 | return this.view.get(this._key); 47 | } 48 | 49 | has(): boolean { 50 | return this.view.has(this._key); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/RouteGroup.ts: -------------------------------------------------------------------------------- 1 | import { Xerus } from "./Xerus"; 2 | import { XerusRoute } from "./XerusRoute"; 3 | 4 | /** 5 | * Groups routes under a common path prefix. 6 | * 7 | * Middleware no longer exists in Xerus — services are injected via Inject(). 8 | * So RouteGroup is now ONLY about path prefixing. 9 | */ 10 | export class RouteGroup { 11 | app: Xerus; 12 | prefixPath: string; 13 | 14 | constructor(app: Xerus, prefixPath: string) { 15 | this.app = app; 16 | this.prefixPath = prefixPath === "/" ? "/" : prefixPath.replace(/\/+$/, ""); 17 | } 18 | 19 | mount(...routeCtors: Array XerusRoute>) { 20 | for (const BaseCtor of routeCtors) { 21 | const groupPrefix = this.prefixPath; 22 | const Base: any = BaseCtor; 23 | 24 | class GroupedRoute extends Base { 25 | onMount(): void { 26 | super.onMount?.(); 27 | 28 | const basePath = (this as any).path as string; 29 | const prefixed = 30 | groupPrefix === "/" 31 | ? basePath 32 | : (groupPrefix + (basePath === "/" ? "" : basePath)).replace( 33 | /\/{2,}/g, 34 | "/", 35 | ); 36 | 37 | (this as any).path = prefixed; 38 | } 39 | } 40 | 41 | this.app.mount(GroupedRoute as any); 42 | } 43 | 44 | return this; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/URLQuery.ts: -------------------------------------------------------------------------------- 1 | // src/URLQuery.ts 2 | export interface URLQueryView { 3 | get(key: string): string | null; 4 | getAll(key: string): string[]; 5 | has(key: string): boolean; 6 | entries(): IterableIterator<[string, string]>; 7 | toObject(): Record; 8 | } 9 | 10 | export class URLQuery implements URLQueryView { 11 | private params: URLSearchParams; 12 | 13 | constructor(params: URLSearchParams) { 14 | this.params = params; 15 | } 16 | 17 | get(key: string): string | null { 18 | return this.params.get(key); 19 | } 20 | 21 | getAll(key: string): string[] { 22 | return this.params.getAll(key); 23 | } 24 | 25 | has(key: string): boolean { 26 | return this.params.has(key); 27 | } 28 | 29 | entries(): IterableIterator<[string, string]> { 30 | return this.params.entries(); 31 | } 32 | 33 | toObject(): Record { 34 | return Object.fromEntries(this.params.entries()); 35 | } 36 | 37 | ref(key: string): URLQueryRef { 38 | return new URLQueryRef(this, key); 39 | } 40 | } 41 | 42 | export class URLQueryRef { 43 | private view: URLQueryView; 44 | private _key: string; 45 | 46 | constructor(view: URLQueryView, key: string) { 47 | this.view = view; 48 | this._key = key; 49 | } 50 | 51 | get key(): string { 52 | return this._key; 53 | } 54 | 55 | get(): string | null { 56 | return this.view.get(this._key); 57 | } 58 | 59 | all(): string[] { 60 | return this.view.getAll(this._key); 61 | } 62 | 63 | has(): boolean { 64 | return this.view.has(this._key); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | // --- START FILE: index.ts --- 2 | export * from "./src/BodyType"; 3 | export * from "./src/ContextState"; 4 | export * from "./src/Method"; 5 | 6 | export * from "./src/SystemErr"; 7 | export * from "./src/SystemErrCode"; 8 | export * from "./src/SystemErrRecord"; 9 | 10 | export * from "./src/CookieOptions"; 11 | export * from "./src/Cookies"; 12 | export * from "./src/Headers"; 13 | 14 | export * from "./src/PathParams"; 15 | export * from "./src/URLQuery"; 16 | export * from "./src/Href"; 17 | 18 | export * from "./src/DataBag"; 19 | 20 | export * from "./src/HTTPContext"; 21 | export * from "./src/HTTPHandlerFunc"; 22 | 23 | export * from "./src/WSContext"; 24 | export * from "./src/WSHandlerFuncs"; 25 | 26 | export * from "./src/XerusValidator"; 27 | export * from "./src/ValidationSource"; 28 | export * from "./src/Validator"; 29 | 30 | export * from "./src/RouteFields"; 31 | export * from "./src/RouteGroup"; 32 | export * from "./src/XerusRoute"; 33 | 34 | export * from "./src/TrieNode"; 35 | export * from "./src/ObjectPool"; 36 | export * from "./src/MutResponse"; 37 | 38 | export * from "./src/TemplateStore"; 39 | export * from "./src/LoggerService"; 40 | export * from "./src/XerusPlugin"; 41 | 42 | export * from "./src/Xerus"; 43 | export * from "./src/CORSService"; 44 | export * from "./src/CSRFService"; 45 | export * from "./src/RateLimitService"; 46 | 47 | export * from "./src/std/Body"; 48 | export * from "./src/std/Request"; 49 | export * from "./src/std/Response"; 50 | export * from "./src/std/Template"; 51 | 52 | export * from "./src/macros"; 53 | // --- END FILE: index.ts --- 54 | -------------------------------------------------------------------------------- /src/MutResponse.ts: -------------------------------------------------------------------------------- 1 | import { CookieJar } from "./Cookies"; 2 | import { HeadersBag } from "./Headers"; 3 | 4 | export class MutResponse { 5 | statusCode: number; 6 | headers: HeadersBag; 7 | cookies: CookieJar; 8 | bodyContent: BodyInit | null; 9 | 10 | constructor() { 11 | this.statusCode = 200; 12 | this.headers = new HeadersBag(); 13 | this.cookies = new CookieJar(); 14 | this.bodyContent = ""; 15 | } 16 | 17 | reset(): void { 18 | this.statusCode = 200; 19 | this.headers.reset(); 20 | this.cookies.reset(); 21 | this.bodyContent = ""; 22 | } 23 | 24 | setStatus(code: number): this { 25 | this.statusCode = code; 26 | return this; 27 | } 28 | 29 | getHeader(name: string): string | null { 30 | return this.headers.get(name); 31 | } 32 | 33 | setHeader(name: string, value: string): this { 34 | this.headers.set(name, value); 35 | return this; 36 | } 37 | 38 | appendHeader(name: string, value: string): this { 39 | this.headers.append(name, value); 40 | return this; 41 | } 42 | 43 | getBody(): BodyInit | null { 44 | return this.bodyContent; 45 | } 46 | 47 | body(content: any): this { 48 | this.bodyContent = content; 49 | return this; 50 | } 51 | 52 | send(): Response { 53 | const h = this.headers.toHeaders(); 54 | const cookieLines = this.cookies.getSetCookieLines(); 55 | for (const line of cookieLines) { 56 | h.append("Set-Cookie", line); 57 | } 58 | return new Response(this.bodyContent, { 59 | status: this.statusCode, 60 | headers: h, 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/macros.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync, readFileSync, statSync } from "node:fs"; 2 | import { join, relative } from "node:path"; 3 | 4 | /** 5 | * Xerus Macro: embedDir 6 | * * Reads a directory at compile-time and returns a dictionary of files. 7 | * The content is returned as number[] (for binaries) or strings so that 8 | * Bun's macro system can serialize it into the AST. 9 | */ 10 | export function embedDir( 11 | absPath: string, 12 | ): Record { 13 | const files: Record = 14 | {}; 15 | 16 | function walk(currentDir: string) { 17 | const entries = readdirSync(currentDir); 18 | for (const entry of entries) { 19 | const fullPath = join(currentDir, entry); 20 | const stats = statSync(fullPath); 21 | 22 | if (stats.isDirectory()) { 23 | walk(fullPath); 24 | } else { 25 | // Create the relative key (e.g., "/css/style.css") 26 | const relPath = "/" + relative(absPath, fullPath); 27 | 28 | // 1. Read file as Buffer 29 | const buffer = readFileSync(fullPath); 30 | 31 | // 2. Determine mime type using Bun's API 32 | const type = Bun.file(fullPath).type || "application/octet-stream"; 33 | 34 | // 3. Convert Buffer to number[] 35 | // Bun Macros cannot return raw Buffers/Uint8Arrays. 36 | // We must convert to a plain array to allow AST serialization. 37 | const content = Array.from(buffer); 38 | 39 | files[relPath] = { content, type }; 40 | } 41 | } 42 | } 43 | 44 | walk(absPath); 45 | return files; 46 | } 47 | -------------------------------------------------------------------------------- /src/XerusRoute.ts: -------------------------------------------------------------------------------- 1 | // --- START FILE: src/XerusRoute.ts --- 2 | import { HTTPContext } from "./HTTPContext"; 3 | import type { HTTPErrorHandlerFunc } from "./HTTPHandlerFunc"; 4 | import { Method } from "./Method"; 5 | import type { TypeValidator } from "./XerusValidator"; 6 | 7 | /** 8 | * Services are now "any constructor" globally. 9 | * 10 | * Reason: 11 | * - ServiceLifecycle is a "weak type" (all optional keys). 12 | * - TypeScript requires some shared keys when assigning class instances to weak types. 13 | * - WS routes often use lightweight per-message services without lifecycle keys, 14 | * so typing them as ServiceLifecycle creates false-negative TS errors. 15 | * 16 | * Runtime already treats hooks as optional: 17 | * if (svc.before) await svc.before(c) 18 | */ 19 | export type AnyServiceCtor = new () => any; 20 | export type AnyValidatorCtor = new () => TypeValidator; 21 | 22 | export abstract class XerusRoute { 23 | abstract method: Method; 24 | abstract path: string; 25 | 26 | public _errHandler?: HTTPErrorHandlerFunc; 27 | 28 | // ✅ Unified: HTTP + WS both use ctor lists 29 | public services: AnyServiceCtor[] = []; 30 | public validators: AnyValidatorCtor[] = []; 31 | 32 | onMount(): void {} 33 | 34 | async validate(_c: HTTPContext): Promise {} 35 | async preHandle(_c: HTTPContext): Promise {} 36 | async postHandle(_c: HTTPContext): Promise {} 37 | async onFinally(_c: HTTPContext): Promise {} 38 | 39 | abstract handle(c: HTTPContext): Promise; 40 | 41 | onErr(handler: HTTPErrorHandlerFunc): this { 42 | this._errHandler = handler; 43 | return this; 44 | } 45 | } 46 | // --- END FILE --- 47 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "configVersion": 1, 4 | "workspaces": { 5 | "": { 6 | "name": "xerus", 7 | "dependencies": { 8 | "@types/node": "^25.0.3", 9 | "typescript": "^5.0.0", 10 | "zod": "^4.2.1", 11 | }, 12 | "devDependencies": { 13 | "@types/bun": "latest", 14 | }, 15 | }, 16 | }, 17 | "packages": { 18 | "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], 19 | 20 | "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], 21 | 22 | "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], 23 | 24 | "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 25 | 26 | "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 27 | 28 | "zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], 29 | 30 | "bun-types/@types/node": ["@types/node@25.0.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA=="], 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/DataBag.ts: -------------------------------------------------------------------------------- 1 | // --- START FILE: src/DataBag.ts --- 2 | export type Ctor = new (...args: any[]) => T; 3 | 4 | /** 5 | * DataBag: ctor-keyed storage that supports storing `undefined` values. 6 | * 7 | * Important: Map.get() returns `undefined` both for "missing" and "stored undefined". 8 | * So we must always consult `hasCtor()` for presence checks. 9 | */ 10 | export interface DataBag { 11 | (Type: Ctor): T | undefined; 12 | 13 | setCtor(Type: Ctor, value: T): void; 14 | getCtor(Type: Ctor): T | undefined; 15 | 16 | requireCtor(Type: Ctor, errMsg?: string): T; 17 | 18 | hasCtor(Type: Ctor): boolean; 19 | deleteCtor(Type: Ctor): void; 20 | clear(): void; 21 | } 22 | 23 | export function createDataBag(): DataBag { 24 | const byCtor = new Map(); 25 | 26 | const bag = (Type: Ctor): T | undefined => { 27 | return byCtor.get(Type); 28 | }; 29 | 30 | bag.setCtor = (Type: Ctor, value: T) => { 31 | byCtor.set(Type, value); 32 | }; 33 | 34 | bag.getCtor = (Type: Ctor): T | undefined => { 35 | return byCtor.get(Type); 36 | }; 37 | 38 | bag.requireCtor = (Type: Ctor, errMsg?: string): T => { 39 | if (byCtor.has(Type)) return byCtor.get(Type) as T; 40 | throw new Error( 41 | errMsg ?? 42 | `DataBag missing instance for ctor: ${(Type as any)?.name ?? "UnknownType"}`, 43 | ); 44 | }; 45 | 46 | bag.hasCtor = (Type: Ctor): boolean => { 47 | return byCtor.has(Type); 48 | }; 49 | 50 | bag.deleteCtor = (Type: Ctor) => { 51 | byCtor.delete(Type); 52 | }; 53 | 54 | bag.clear = () => { 55 | byCtor.clear(); 56 | }; 57 | 58 | return bag as DataBag; 59 | } 60 | // --- END FILE --- 61 | -------------------------------------------------------------------------------- /src/ValidationSource.ts: -------------------------------------------------------------------------------- 1 | import type { HTTPContext } from "./HTTPContext"; 2 | import type { ParsedFormBodyLast, ParsedFormBodyMulti } from "./std/Body"; 3 | // CHANGE: Import from std/Body instead of HTTPContext 4 | 5 | export type ValidationSource = 6 | | { kind: "JSON" } & { __raw?: TRaw } 7 | | { kind: "FORM"; formMode?: "last" } & { __raw?: ParsedFormBodyLast } 8 | | { kind: "FORM"; formMode: "multi" } & { __raw?: ParsedFormBodyMulti } 9 | | { kind: "FORM"; formMode: "params" } & { __raw?: URLSearchParams } 10 | | { kind: "QUERY"; key?: string } & { __raw?: TRaw } 11 | | { kind: "PARAM"; key?: string } & { __raw?: TRaw } 12 | | { kind: "WSMESSAGE" } & { __raw?: string | Buffer | null }; 13 | 14 | export class Source { 15 | static JSON(): ValidationSource { 16 | return { kind: "JSON" } as ValidationSource; 17 | } 18 | 19 | static FORM(formMode?: "last"): ValidationSource; 20 | static FORM(formMode: "multi"): ValidationSource; 21 | static FORM(formMode: "params"): ValidationSource; 22 | static FORM( 23 | formMode: "last" | "multi" | "params" = "last", 24 | ): ValidationSource { 25 | return { kind: "FORM", formMode } as any; 26 | } 27 | 28 | static QUERY(key: string): ValidationSource; 29 | static QUERY(): ValidationSource>; 30 | static QUERY(key?: string): ValidationSource { 31 | return { kind: "QUERY", key } as any; 32 | } 33 | 34 | static PARAM(key: string): ValidationSource; 35 | static PARAM(): ValidationSource>; 36 | static PARAM(key?: string): ValidationSource { 37 | return { kind: "PARAM", key } as any; 38 | } 39 | 40 | static WSMESSAGE(): ValidationSource { 41 | return { kind: "WSMESSAGE" }; 42 | } 43 | } -------------------------------------------------------------------------------- /tests/abf_ws_services_validatorsParity.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { Xerus } from "../src/Xerus"; 3 | import { XerusRoute } from "../src/XerusRoute"; 4 | import { Method } from "../src/Method"; 5 | import type { HTTPContext } from "../src/HTTPContext"; 6 | import { ws } from "../src/std/Request"; 7 | 8 | class MsgValidator { 9 | async validate(c: HTTPContext) { 10 | // For WS MESSAGE routes, raw msg is in c._wsMessage 11 | return String(c._wsMessage ?? ""); 12 | } 13 | } 14 | 15 | class PerMessageService { 16 | msg = ""; 17 | async init(c: HTTPContext) { 18 | this.msg = c.validated(MsgValidator); 19 | } 20 | } 21 | 22 | class WsMessageRoute extends XerusRoute { 23 | method = Method.WS_MESSAGE; 24 | path = "/ws"; 25 | validators = [MsgValidator]; 26 | services = [PerMessageService]; 27 | 28 | async handle(c: HTTPContext) { 29 | const w = ws(c); 30 | const svc = c.service(PerMessageService); 31 | w.send(JSON.stringify({ ok: true, msg: svc.msg })); 32 | } 33 | } 34 | 35 | test("WS: validators[] + services[] work (same pipeline as HTTP)", async () => { 36 | const app = new Xerus(); 37 | app.mount(WsMessageRoute); 38 | 39 | const server = await app.listen(0); 40 | const url = `ws://localhost:${server.port}/ws`; 41 | 42 | const sock = new WebSocket(url); 43 | 44 | const nextMessage = () => 45 | new Promise((resolve, reject) => { 46 | const onMsg = (ev: MessageEvent) => { 47 | sock.removeEventListener("message", onMsg); 48 | resolve(JSON.parse(String(ev.data))); 49 | }; 50 | sock.addEventListener("message", onMsg); 51 | sock.addEventListener("error", (e) => reject(e), { once: true }); 52 | }); 53 | 54 | await new Promise((resolve, reject) => { 55 | sock.addEventListener("open", () => resolve(), { once: true }); 56 | sock.addEventListener("error", (e) => reject(e), { once: true }); 57 | }); 58 | 59 | sock.send("hello-ws"); 60 | const m1 = await nextMessage(); 61 | 62 | expect(m1.ok).toBe(true); 63 | expect(m1.msg).toBe("hello-ws"); 64 | 65 | sock.close(1000, "bye"); 66 | await new Promise((resolve) => sock.addEventListener("close", () => resolve(), { once: true })); 67 | 68 | server.stop(true); 69 | }); 70 | -------------------------------------------------------------------------------- /tests/aao_safeguard.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import { HTTPContext } from "../src/HTTPContext"; 7 | import type { ServiceLifecycle } from "../src/RouteFields"; 8 | import { json, setStatus } from "../src/std/Response"; 9 | 10 | function makeURL(port: number, path: string) { 11 | return `http://127.0.0.1:${port}${path}`; 12 | } 13 | 14 | describe("Safeguard", () => { 15 | let server: any; 16 | let port: number; 17 | 18 | beforeAll(async () => { 19 | const app = new Xerus(); 20 | 21 | class ErrorCatcherService implements XerusService { 22 | async onError(c: HTTPContext, err: any) { 23 | setStatus(c, 500); 24 | json(c, { 25 | error: { 26 | code: "SERVICE_CAUGHT", 27 | message: "Service caught the error", 28 | detail: err?.message ?? String(err), 29 | }, 30 | }); 31 | } 32 | } 33 | 34 | class FailRoute extends XerusRoute { 35 | method = Method.GET; 36 | path = "/safeguard/fail"; 37 | services = [ErrorCatcherService]; 38 | 39 | async handle(_c: HTTPContext) { 40 | throw new Error("Handler Failed"); 41 | } 42 | } 43 | 44 | class OkRoute extends XerusRoute { 45 | method = Method.GET; 46 | path = "/safeguard/ok"; 47 | 48 | async handle(c: HTTPContext) { 49 | json(c, { status: "ok" }); 50 | } 51 | } 52 | 53 | app.mount(FailRoute, OkRoute); 54 | 55 | server = await app.listen(0); 56 | port = server.port; 57 | }); 58 | 59 | afterAll(() => { 60 | server?.stop?.(true); 61 | }); 62 | 63 | test("Service onError should capture handler exception", async () => { 64 | const res = await fetch(makeURL(port, "/safeguard/fail")); 65 | const data = await res.json(); 66 | 67 | expect(res.status).toBe(500); 68 | expect(data.error.code).toBe("SERVICE_CAUGHT"); 69 | expect(data.error.detail).toBe("Handler Failed"); 70 | }); 71 | 72 | test("Normal route should pass", async () => { 73 | const res = await fetch(makeURL(port, "/safeguard/ok")); 74 | const data = await res.json(); 75 | 76 | expect(res.status).toBe(200); 77 | expect(data.status).toBe("ok"); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/WSContext.ts: -------------------------------------------------------------------------------- 1 | import type { ServerWebSocket } from "bun"; 2 | import type { HTTPContext } from "./HTTPContext"; 3 | 4 | export class WSContext { 5 | ws: ServerWebSocket; 6 | http: HTTPContext; 7 | 8 | message: string | Buffer | ""; 9 | code: number; 10 | reason: string; 11 | 12 | constructor( 13 | ws: ServerWebSocket, 14 | http: HTTPContext, 15 | opts?: { 16 | message?: string | Buffer | null; 17 | code?: number | null; 18 | reason?: string | null; 19 | }, 20 | ) { 21 | this.ws = ws; 22 | this.http = http; 23 | this.message = (opts?.message ?? "") as any; 24 | this.code = opts?.code ?? 0; 25 | this.reason = opts?.reason ?? ""; 26 | } 27 | 28 | get data(): HTTPContext { 29 | return this.ws.data; 30 | } 31 | 32 | get readyState(): number { 33 | return (this.ws as any).readyState ?? 0; 34 | } 35 | 36 | get remoteAddress(): any { 37 | return (this.ws as any).remoteAddress; 38 | } 39 | 40 | isOpen(): boolean { 41 | return this.readyState === 1; 42 | } 43 | 44 | send(data: string | Buffer | Uint8Array | ArrayBuffer): void { 45 | (this.ws as any).send(data); 46 | } 47 | 48 | close(code?: number, reason?: string): void { 49 | if (typeof (this.ws as any).close !== "function") return; 50 | if (code === undefined) (this.ws as any).close(); 51 | else (this.ws as any).close(code, reason); 52 | } 53 | 54 | ping(data?: string | Buffer | Uint8Array | ArrayBuffer): void { 55 | if (typeof (this.ws as any).ping !== "function") return; 56 | if (data === undefined) (this.ws as any).ping(); 57 | else (this.ws as any).ping(data); 58 | } 59 | 60 | pong(data?: string | Buffer | Uint8Array | ArrayBuffer): void { 61 | if (typeof (this.ws as any).pong !== "function") return; 62 | if (data === undefined) (this.ws as any).pong(); 63 | else (this.ws as any).pong(data); 64 | } 65 | 66 | subscribe(topic: string): void { 67 | if (typeof (this.ws as any).subscribe !== "function") return; 68 | (this.ws as any).subscribe(topic); 69 | } 70 | 71 | unsubscribe(topic: string): void { 72 | if (typeof (this.ws as any).unsubscribe !== "function") return; 73 | (this.ws as any).unsubscribe(topic); 74 | } 75 | 76 | publish(topic: string, data: string | Buffer | Uint8Array | ArrayBuffer): void { 77 | if (typeof (this.ws as any).publish !== "function") return; 78 | (this.ws as any).publish(topic, data); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/std/Request.ts: -------------------------------------------------------------------------------- 1 | // --- START FILE: src/std/Request.ts --- 2 | import { HTTPContext } from "../HTTPContext"; 3 | import { RequestHeaders } from "../Headers"; 4 | import { SystemErr } from "../SystemErr"; 5 | import { SystemErrCode } from "../SystemErrCode"; 6 | import { WSContext } from "../WSContext"; 7 | 8 | export function url(c: HTTPContext): URL { 9 | if (!c._url) c._url = new URL(c.req.url); 10 | return c._url; 11 | } 12 | 13 | export function segments(c: HTTPContext): string[] { 14 | if (!c._segments) c._segments = c.path.split("/").filter(Boolean); 15 | return c._segments; 16 | } 17 | 18 | export function queries(c: HTTPContext): Record { 19 | if (!c._url) c._url = new URL(c.req.url); 20 | return Object.fromEntries(c._url.searchParams.entries()); 21 | } 22 | 23 | export function query(c: HTTPContext, key: string, defaultValue: string = ""): string { 24 | if (!c._url) c._url = new URL(c.req.url); 25 | return c._url.searchParams.get(key) ?? defaultValue; 26 | } 27 | 28 | export function param(c: HTTPContext, key: string, defaultValue: string = ""): string { 29 | return c.params[key] ?? defaultValue; 30 | } 31 | 32 | export function params(c: HTTPContext): Record { 33 | return { ...c.params }; 34 | } 35 | 36 | export function header(c: HTTPContext, name: string): string | null { 37 | return c.req.headers.get(name); 38 | } 39 | 40 | export function headers(c: HTTPContext): RequestHeaders { 41 | if (!c._reqHeaders) c._reqHeaders = new RequestHeaders(c.req.headers); 42 | return c._reqHeaders; 43 | } 44 | 45 | export function clientIP(c: HTTPContext): string { 46 | const xff = c.req.headers.get("x-forwarded-for") || c.req.headers.get("X-Forwarded-For"); 47 | if (xff) return xff.split(",")[0].trim(); 48 | const xrip = c.req.headers.get("x-real-ip") || c.req.headers.get("X-Real-IP"); 49 | if (xrip) return xrip.trim(); 50 | return "unknown"; 51 | } 52 | 53 | export function ws(c: HTTPContext): WSContext { 54 | if (!c._wsContext) { 55 | throw new SystemErr( 56 | SystemErrCode.INTERNAL_SERVER_ERR, 57 | "WebSocket context is not available. Are you calling ws(c) from a non-WS route?", 58 | ); 59 | } 60 | return c._wsContext; 61 | } 62 | 63 | export function isWs(c: HTTPContext): boolean { 64 | return c._wsContext != null; 65 | } 66 | 67 | /** 68 | * Request cookie read helper (canonical: c.cookies.request.get()). 69 | */ 70 | export function reqCookie(c: HTTPContext, name: string): string | undefined { 71 | return c.cookies.request.get(name); 72 | } 73 | // --- END FILE: src/std/Request.ts --- 74 | -------------------------------------------------------------------------------- /tests/aaq_injection.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import { HTTPContext } from "../src/HTTPContext"; 7 | import type { InjectableStore } from "../src/RouteFields"; 8 | import { json } from "../src/std/Response"; 9 | 10 | function makeURL(port: number, path: string) { 11 | return `http://127.0.0.1:${port}${path}`; 12 | } 13 | 14 | /* ====================== 15 | Services 16 | ====================== */ 17 | 18 | class UserService implements InjectableStore { 19 | storeKey = "UserService"; 20 | private users = ["Alice", "Bob"]; 21 | 22 | getUsers() { 23 | return this.users; 24 | } 25 | } 26 | 27 | class MetricsService implements InjectableStore { 28 | storeKey = "MetricsService"; 29 | initialized = false; 30 | startTime = 0; 31 | 32 | async init(_c: HTTPContext) { 33 | this.initialized = true; 34 | this.startTime = Date.now(); 35 | } 36 | 37 | getUptime() { 38 | return Date.now() - this.startTime; 39 | } 40 | } 41 | 42 | /* ====================== 43 | Route 44 | ====================== */ 45 | 46 | class InjectionRoute extends XerusRoute { 47 | method = Method.GET; 48 | path = "/injection/test"; 49 | services = [UserService, MetricsService]; 50 | 51 | async handle(c: HTTPContext) { 52 | const userService = c.service(UserService); 53 | const metrics = c.service(MetricsService); 54 | 55 | json(c, { 56 | users: userService.getUsers(), 57 | serviceName: userService.storeKey, 58 | initialized: metrics.initialized, 59 | processingTime: metrics.getUptime(), 60 | }); 61 | } 62 | } 63 | 64 | /* ====================== 65 | Tests 66 | ====================== */ 67 | 68 | describe("Service Injection", () => { 69 | let server: any; 70 | let port: number; 71 | 72 | beforeAll(async () => { 73 | const app = new Xerus(); 74 | app.mount(InjectionRoute); 75 | 76 | server = await app.listen(0); 77 | port = server.port; 78 | }); 79 | 80 | afterAll(() => { 81 | server?.stop?.(true); 82 | }); 83 | 84 | test("Injection: Should inject services and run init lifecycle", async () => { 85 | const res = await fetch(makeURL(port, "/injection/test")); 86 | const data = await res.json(); 87 | 88 | expect(res.status).toBe(200); 89 | expect(data.users).toEqual(["Alice", "Bob"]); 90 | expect(data.serviceName).toBe("UserService"); 91 | expect(data.initialized).toBe(true); 92 | expect(data.processingTime).toBeGreaterThanOrEqual(0); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/CSRFService.ts: -------------------------------------------------------------------------------- 1 | import type { HTTPContext } from "./HTTPContext"; 2 | import { Method } from "./Method"; 3 | import type { XerusService } from "./RouteFields"; 4 | import { errorJSON } from "./std/Response"; 5 | 6 | export interface CSRFConfig { 7 | /** 8 | * Name of the cookie to set. 9 | * Default: "XSRF-TOKEN" (standard for Angular/React) 10 | */ 11 | cookieName?: string; 12 | 13 | /** 14 | * Name of the header to check on incoming requests. 15 | * Default: "X-XSRF-TOKEN" 16 | */ 17 | headerName?: string; 18 | 19 | /** 20 | * HTTP methods to ignore validation for. 21 | * Default: ["GET", "HEAD", "OPTIONS", "TRACE"] 22 | */ 23 | ignoreMethods?: string[]; 24 | } 25 | 26 | export class CSRFService implements XerusService { 27 | // Configuration defaults 28 | private cookieName = "XSRF-TOKEN"; 29 | private headerName = "X-XSRF-TOKEN"; 30 | private ignoreMethods = [ 31 | Method.GET, 32 | Method.HEAD, 33 | Method.OPTIONS, 34 | "TRACE" 35 | ]; 36 | 37 | constructor(config?: CSRFConfig) { 38 | if (config?.cookieName) this.cookieName = config.cookieName; 39 | if (config?.headerName) this.headerName = config.headerName; 40 | if (config?.ignoreMethods) this.ignoreMethods = config.ignoreMethods; 41 | } 42 | 43 | /** 44 | * Generates a secure random token. 45 | */ 46 | private generateToken(): string { 47 | return crypto.randomUUID(); 48 | } 49 | 50 | async before(c: HTTPContext): Promise { 51 | // 1. Retrieve the token from the request cookies (if it exists) 52 | let token = c.cookies.request.get(this.cookieName); 53 | 54 | // 2. If the token doesn't exist, generate a new one and set the cookie. 55 | // We set httpOnly to false so the client-side JS can read it and inject it into headers. 56 | if (!token) { 57 | token = this.generateToken(); 58 | c.cookies.response.set(this.cookieName, token, { 59 | path: "/", 60 | httpOnly: false, 61 | sameSite: "Lax", 62 | secure: false, // Set to true in production/HTTPS 63 | }); 64 | } 65 | 66 | // 3. If this is a "Safe" method, we are done. 67 | if (this.ignoreMethods.includes(c.method as any)) { 68 | return; 69 | } 70 | 71 | // 4. For "Unsafe" methods, verify the header matches the cookie. 72 | const headerToken = c.req.headers.get(this.headerName); 73 | 74 | if (!headerToken || headerToken !== token) { 75 | // 5. Short-circuit the request with a 403 Forbidden 76 | errorJSON(c, 403, "CSRF_DETECTED", "Invalid or missing CSRF token"); 77 | // Mark context as handled to stop further processing in Xerus (implied by finalizing response in errorJSON) 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /src/SystemErrRecord.ts: -------------------------------------------------------------------------------- 1 | import { HTTPContext } from "./HTTPContext"; 2 | import type { HTTPErrorHandlerFunc } from "./HTTPHandlerFunc"; 3 | import { errorJSON } from "./std/Response"; 4 | import { SystemErrCode } from "./SystemErrCode"; 5 | import { SystemErr } from "./SystemErr"; 6 | 7 | export const SystemErrRecord: Record = { 8 | [SystemErrCode.FILE_NOT_FOUND]: async (c: HTTPContext, err: any) => { 9 | const e = (err instanceof SystemErr ? err : c.err) as SystemErr; 10 | errorJSON(c, 404, SystemErrCode.FILE_NOT_FOUND, e?.message || "File not found"); 11 | }, 12 | 13 | [SystemErrCode.BODY_PARSING_FAILED]: async (c: HTTPContext, err: any) => { 14 | const e = (err instanceof SystemErr ? err : c.err) as SystemErr; 15 | errorJSON(c, 400, SystemErrCode.BODY_PARSING_FAILED, e?.message || "Body parsing failed"); 16 | }, 17 | 18 | [SystemErrCode.ROUTE_ALREADY_REGISTERED]: async (c: HTTPContext, err: any) => { 19 | const e = (err instanceof SystemErr ? err : c.err) as SystemErr; 20 | errorJSON(c, 409, SystemErrCode.ROUTE_ALREADY_REGISTERED, e?.message || "Route already registered"); 21 | }, 22 | 23 | [SystemErrCode.ROUTE_NOT_FOUND]: async (c: HTTPContext, err: any) => { 24 | const e = (err instanceof SystemErr ? err : c.err) as SystemErr; 25 | errorJSON(c, 404, SystemErrCode.ROUTE_NOT_FOUND, e?.message || "Route not found"); 26 | }, 27 | 28 | [SystemErrCode.VALIDATION_FAILED]: async (c: HTTPContext, err: any) => { 29 | const issues = err?.issues ?? err?.errors ?? err?.detail ?? err?.data ?? undefined; 30 | const detail = 31 | typeof err?.message === "string" && err.message.length > 0 32 | ? err.message 33 | : "Validation failed"; 34 | 35 | errorJSON(c, 400, SystemErrCode.VALIDATION_FAILED, "Validation Failed", { 36 | detail, 37 | ...(issues !== undefined ? { issues } : {}), 38 | }); 39 | }, 40 | 41 | [SystemErrCode.INTERNAL_SERVER_ERR]: async (c: HTTPContext, err: any) => { 42 | const e = (err instanceof SystemErr ? err : c.err) as SystemErr; 43 | errorJSON(c, 500, SystemErrCode.INTERNAL_SERVER_ERR, "Internal Server Error", { 44 | detail: e?.message || "Unknown error", 45 | }); 46 | }, 47 | 48 | [SystemErrCode.WEBSOCKET_UPGRADE_FAILURE]: async (c: HTTPContext, err: any) => { 49 | const e = (err instanceof SystemErr ? err : c.err) as SystemErr; 50 | errorJSON(c, 500, SystemErrCode.WEBSOCKET_UPGRADE_FAILURE, e?.message || "WebSocket upgrade failed"); 51 | }, 52 | 53 | [SystemErrCode.HEADERS_ALREADY_SENT]: async (c: HTTPContext, err: any) => { 54 | const e = (err instanceof SystemErr ? err : c.err) as SystemErr; 55 | console.error(`[CRITICAL] ${e?.message}`); 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /tests/aau_validator_pattern.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import { HTTPContext } from "../src/HTTPContext"; 7 | import type { XerusValidator } from "../src/XerusValidator"; 8 | import { SystemErr } from "../src/SystemErr"; 9 | import { SystemErrCode } from "../src/SystemErrCode"; 10 | import { query } from "../src/std/Request"; 11 | import { json } from "../src/std/Response"; 12 | 13 | function makeURL(port: number, path: string) { 14 | return `http://127.0.0.1:${port}${path}`; 15 | } 16 | 17 | // -------------------- 18 | // Validator 19 | // -------------------- 20 | 21 | class QueryPageValidator implements XerusValidator { 22 | async validate(c: HTTPContext) { 23 | const page = Number(query(c, "page") || "1"); 24 | if (isNaN(page) || page < 1) { 25 | throw new SystemErr(SystemErrCode.VALIDATION_FAILED, "Page must be >= 1"); 26 | } 27 | return { page }; 28 | } 29 | } 30 | 31 | // -------------------- 32 | // Route 33 | // -------------------- 34 | 35 | class ValidatorRoute extends XerusRoute { 36 | method = Method.GET; 37 | path = "/validator/pattern"; 38 | validators = [QueryPageValidator]; 39 | 40 | async handle(c: HTTPContext) { 41 | const { page } = c.validated(QueryPageValidator); 42 | json(c, { page }); 43 | } 44 | } 45 | 46 | describe("Validator pattern", () => { 47 | let server: any; 48 | let port: number; 49 | 50 | beforeAll(async () => { 51 | const app = new Xerus(); 52 | app.mount(ValidatorRoute); 53 | 54 | server = await app.listen(0); 55 | port = server.port; 56 | }); 57 | 58 | afterAll(() => { 59 | server?.stop?.(true); 60 | }); 61 | 62 | test("Validator Pattern: Should resolve valid data using c.resolve()", async () => { 63 | const res = await fetch(makeURL(port, "/validator/pattern?page=5")); 64 | const data = await res.json(); 65 | 66 | expect(res.status).toBe(200); 67 | expect(data.page).toBe(5); 68 | }); 69 | 70 | test("Validator Pattern: Should fail validation logic", async () => { 71 | const res = await fetch(makeURL(port, "/validator/pattern?page=0")); 72 | const data = await res.json(); 73 | 74 | expect(res.status).toBe(400); 75 | expect(data.error.code).toBe("VALIDATION_FAILED"); 76 | expect(data.error.detail).toBe("VALIDATION_FAILED: Page must be >= 1"); 77 | }); 78 | 79 | test("Validator Pattern: Should use default value if missing", async () => { 80 | const res = await fetch(makeURL(port, "/validator/pattern")); 81 | const data = await res.json(); 82 | 83 | expect(res.status).toBe(200); 84 | expect(data.page).toBe(1); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/RouteFields.ts: -------------------------------------------------------------------------------- 1 | // --- START FILE: src/RouteFields.ts --- 2 | import type { HTTPContext } from "./HTTPContext"; 3 | 4 | type Ctor = new (...args: any[]) => T; 5 | 6 | const XERUS_FIELD = Symbol.for("xerus:routefield"); 7 | 8 | /** 9 | * Lifecycle interface for services. 10 | * (Kept for compatibility + typing.) 11 | */ 12 | export interface XerusService { 13 | storeKey?: string; 14 | init?(c: HTTPContext): Promise; 15 | initApp?(app: any): Promise; // `any` avoids circular import; Xerus calls it. 16 | before?(c: HTTPContext): Promise; 17 | after?(c: HTTPContext): Promise; 18 | onError?(c: HTTPContext, err: unknown): Promise; 19 | } 20 | 21 | export type InjectableStore = XerusService; // Alias for backward compat 22 | 23 | /** 24 | * @deprecated Legacy RouteField classes are kept only so old code errors clearly. 25 | * Xerus no longer supports RouteField-based injection/validation. 26 | */ 27 | export type RouteFieldKind = "validator" | "inject"; 28 | export type AnyRouteField = RouteFieldValidator | RouteFieldInject; 29 | 30 | export class RouteFieldValidator | any } = any> { 31 | readonly kind = "validator" as const; 32 | readonly [XERUS_FIELD] = true as const; 33 | readonly Type: new () => T; 34 | readonly storeKey?: string; 35 | constructor(Type: new () => T, storeKey?: string) { 36 | this.Type = Type; 37 | this.storeKey = storeKey; 38 | } 39 | } 40 | 41 | export class RouteFieldInject { 42 | readonly kind = "inject" as const; 43 | readonly [XERUS_FIELD] = true as const; 44 | readonly Type: Ctor; 45 | readonly storeKey?: string; 46 | constructor(Type: Ctor, storeKey?: string) { 47 | this.Type = Type; 48 | this.storeKey = storeKey; 49 | } 50 | } 51 | 52 | export function isRouteField(x: any): x is AnyRouteField { 53 | return !!x && typeof x === "object" && x[XERUS_FIELD] === true; 54 | } 55 | export function isRouteFieldValidator(x: any): x is RouteFieldValidator { 56 | return isRouteField(x) && x.kind === "validator"; 57 | } 58 | export function isRouteFieldInject(x: any): x is RouteFieldInject { 59 | return isRouteField(x) && x.kind === "inject"; 60 | } 61 | 62 | /** 63 | * @deprecated Disabled to enforce the new UX: 64 | * 65 | * services = [UserService, MetricsService] 66 | * 67 | * If you need a service, declare it in `services` and then call `c.service(Type)`. 68 | */ 69 | export function Inject( 70 | _Type: Ctor, 71 | _storeKey?: string, 72 | ): never { 73 | throw new Error( 74 | `[XERUS] Inject() has been removed.\n` + 75 | `Use: services = [MyServiceCtor]\n` + 76 | `Then read via: c.service(MyServiceCtor)`, 77 | ); 78 | } 79 | // --- END FILE --- 80 | -------------------------------------------------------------------------------- /src/TemplateStore.ts: -------------------------------------------------------------------------------- 1 | // --- START FILE: src/TemplateStore.ts --- 2 | import { SystemErr } from "./SystemErr"; 3 | import { SystemErrCode } from "./SystemErrCode"; 4 | import * as path from "node:path"; 5 | 6 | export type EmbeddedFile = { 7 | content: string | Buffer | Uint8Array | number[]; 8 | type: string; 9 | }; 10 | 11 | export class TemplateStore { 12 | private files: Record = {}; 13 | 14 | constructor(initial?: Record) { 15 | if (initial) this.add(initial); 16 | } 17 | 18 | add(more: Record) { 19 | // Merge (later wins) 20 | for (const [k, v] of Object.entries(more)) { 21 | this.files[k] = v; 22 | } 23 | } 24 | 25 | private normalizeRel(relPath: string): string { 26 | // Accept: "index.html", "./index.html", "/index.html" 27 | // Deny traversal: "../x", "a/../../b" 28 | let p = (relPath ?? "").trim(); 29 | if (!p) p = "index.html"; 30 | 31 | p = p.replace(/\\/g, "/"); 32 | 33 | // strip leading "./" 34 | while (p.startsWith("./")) p = p.slice(2); 35 | 36 | // make it relative (no leading slash) then normalize as posix 37 | p = p.replace(/^\/+/, ""); 38 | const norm = path.posix.normalize(p); 39 | 40 | // normalize can produce "." for empty 41 | const finalRel = norm === "." ? "index.html" : norm; 42 | 43 | // traversal check 44 | if (finalRel === ".." || finalRel.startsWith("../") || finalRel.includes("/../")) { 45 | throw new SystemErr(SystemErrCode.FILE_NOT_FOUND, "Access Denied"); 46 | } 47 | 48 | return "/" + finalRel; 49 | } 50 | 51 | has(relPath: string): boolean { 52 | const key = this.normalizeRel(relPath); 53 | return !!this.files[key]; 54 | } 55 | 56 | bytes(relPath: string): Uint8Array { 57 | const key = this.normalizeRel(relPath); 58 | const file = this.files[key]; 59 | if (!file) { 60 | throw new SystemErr(SystemErrCode.FILE_NOT_FOUND, `Template ${key} not found`); 61 | } 62 | 63 | const body = file.content; 64 | if (typeof body === "string") return new TextEncoder().encode(body); 65 | if (body instanceof Uint8Array) return body; 66 | if (typeof Buffer !== "undefined" && body instanceof Buffer) return new Uint8Array(body); 67 | if (Array.isArray(body)) return new Uint8Array(body); 68 | return new Uint8Array(body as any); 69 | } 70 | 71 | text(relPath: string): string { 72 | const u8 = this.bytes(relPath); 73 | return new TextDecoder().decode(u8); 74 | } 75 | 76 | // Handy if you want to branch by mime later 77 | type(relPath: string): string { 78 | const key = this.normalizeRel(relPath); 79 | const file = this.files[key]; 80 | if (!file) { 81 | throw new SystemErr(SystemErrCode.FILE_NOT_FOUND, `Template ${key} not found`); 82 | } 83 | return file.type; 84 | } 85 | } 86 | // --- END FILE: src/TemplateStore.ts --- 87 | -------------------------------------------------------------------------------- /tests/abi_validator_caching_across_serviceAndRoute.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { Xerus } from "../src/Xerus"; 3 | import { XerusRoute } from "../src/XerusRoute"; 4 | import { Method } from "../src/Method"; 5 | import type { HTTPContext } from "../src/HTTPContext"; 6 | import type { XerusValidator } from "../src/XerusValidator"; 7 | import { json } from "../src/std/Response"; 8 | 9 | function makeURL(port: number, path: string) { 10 | return `http://127.0.0.1:${port}${path}`; 11 | } 12 | async function readJSON(res: Response) { 13 | return await res.json(); 14 | } 15 | 16 | describe("validator caching: service + route share one evaluation per request", () => { 17 | let server: any; 18 | let port: number; 19 | 20 | beforeAll(async () => { 21 | const app = new Xerus(); 22 | 23 | let validateCalls = 0; 24 | 25 | class CountedValidator implements XerusValidator { 26 | async validate(_c: HTTPContext) { 27 | validateCalls++; 28 | return { ok: true, n: validateCalls }; 29 | } 30 | } 31 | 32 | class UsesValidatorService { 33 | validators = [CountedValidator]; 34 | seenN: number = -1; 35 | async init(c: HTTPContext) { 36 | const v = c.validated(CountedValidator); 37 | this.seenN = v.n; 38 | } 39 | } 40 | 41 | class CacheRoute extends XerusRoute { 42 | method = Method.GET; 43 | path = "/v/cache"; 44 | validators = [CountedValidator]; 45 | services = [UsesValidatorService]; 46 | async handle(c: HTTPContext) { 47 | const v = c.validated(CountedValidator); 48 | const svc = c.service(UsesValidatorService); 49 | 50 | json(c, { 51 | validateCalls, 52 | routeValueN: v.n, 53 | serviceValueN: svc.seenN, 54 | sameN: v.n === svc.seenN, 55 | }); 56 | } 57 | } 58 | 59 | app.mount(CacheRoute); 60 | server = await app.listen(0); 61 | port = server.port; 62 | }); 63 | 64 | afterAll(() => { 65 | server?.stop?.(true); 66 | }); 67 | 68 | test("single request: CountedValidator runs once even if used by both route + service", async () => { 69 | const res = await fetch(makeURL(port, "/v/cache")); 70 | expect(res.status).toBe(200); 71 | const body = await readJSON(res); 72 | 73 | expect(body.sameN).toBe(true); 74 | expect(body.validateCalls).toBe(1); 75 | expect(body.routeValueN).toBe(1); 76 | expect(body.serviceValueN).toBe(1); 77 | }); 78 | 79 | test("second request: validator runs again (per request), still once total for that request", async () => { 80 | const res = await fetch(makeURL(port, "/v/cache")); 81 | expect(res.status).toBe(200); 82 | const body = await readJSON(res); 83 | 84 | expect(body.sameN).toBe(true); 85 | expect(body.validateCalls).toBe(2); 86 | expect(body.routeValueN).toBe(2); 87 | expect(body.serviceValueN).toBe(2); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /tests/abc_ws_validator.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import { TestStore } from "./TestStore"; 7 | import type { XerusValidator } from "../src/XerusValidator"; 8 | import { SystemErr } from "../src/SystemErr"; 9 | import { SystemErrCode } from "../src/SystemErrCode"; 10 | import type { HTTPContext } from "../src/HTTPContext"; 11 | import { ws } from "../src/std/Request"; 12 | 13 | function makeWSURL(port: number, path: string) { 14 | return `ws://127.0.0.1:${port}${path}`; 15 | } 16 | 17 | /* ====================== 18 | Validator + Route 19 | ====================== */ 20 | 21 | export class ChatMessageValidator implements XerusValidator { 22 | async validate(c: HTTPContext): Promise { 23 | const content = String(ws(c).message); 24 | 25 | if (content.includes("badword")) { 26 | throw new SystemErr( 27 | SystemErrCode.VALIDATION_FAILED, 28 | "Profanity detected", 29 | ); 30 | } 31 | 32 | return content; 33 | } 34 | } 35 | 36 | class ValidatorWsRoute extends XerusRoute { 37 | method = Method.WS_MESSAGE; 38 | path = "/ws/validator"; 39 | services = [TestStore]; 40 | validators = [ChatMessageValidator]; 41 | 42 | async handle(c: HTTPContext) { 43 | const socket = ws(c); 44 | const content = c.validated(ChatMessageValidator); 45 | socket.send(`clean: ${content}`); 46 | } 47 | } 48 | 49 | /* ====================== 50 | Tests 51 | ====================== */ 52 | 53 | describe("WS validator", () => { 54 | let server: any; 55 | let port: number; 56 | 57 | beforeAll(async () => { 58 | const app = new Xerus(); 59 | app.mount(ValidatorWsRoute); 60 | 61 | server = await app.listen(0); 62 | port = server.port; 63 | }); 64 | 65 | afterAll(() => { 66 | server?.stop?.(true); 67 | }); 68 | 69 | test("WS Validator: Should allow clean messages", async () => { 70 | const socket = new WebSocket(makeWSURL(port, "/ws/validator")); 71 | 72 | const response = await new Promise((resolve) => { 73 | socket.onopen = () => socket.send("hello world"); 74 | socket.onmessage = (event) => { 75 | socket.close(); 76 | resolve(String(event.data)); 77 | }; 78 | }); 79 | 80 | expect(response).toBe("clean: hello world"); 81 | }); 82 | 83 | test("WS Validator: Should close connection on validation failure", async () => { 84 | const socket = new WebSocket(makeWSURL(port, "/ws/validator")); 85 | 86 | const result = await new Promise<{ code: number; reason: string }>((resolve) => { 87 | socket.onopen = () => socket.send("this is a badword"); 88 | 89 | socket.onclose = (event) => { 90 | resolve({ code: event.code, reason: event.reason }); 91 | }; 92 | }); 93 | 94 | // 1008 = Policy Violation (commonly used for validation/policy failures) 95 | expect(result.code).toBe(1008); 96 | expect(result.reason).toContain("Profanity detected"); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /tests/aan_middleware_errors.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { Xerus } from "../src/Xerus"; 3 | import { XerusRoute } from "../src/XerusRoute"; 4 | import { Method } from "../src/Method"; 5 | import { HTTPContext } from "../src/HTTPContext"; 6 | import type { ServiceLifecycle } from "../src/RouteFields"; 7 | import { json, setStatus } from "../src/std/Response"; 8 | 9 | function makeURL(port: number, path: string) { 10 | return `http://127.0.0.1:${port}${path}`; 11 | } 12 | 13 | describe("Middleware error handling", () => { 14 | let server: any; 15 | let port: number; 16 | 17 | beforeAll(async () => { 18 | const app = new Xerus(); 19 | 20 | class ServiceSafeGuard implements XerusService { 21 | async onError(c: HTTPContext, err: any) { 22 | setStatus(c, 422); 23 | json(c, { 24 | safeGuard: true, 25 | originalError: err?.message ?? String(err), 26 | }); 27 | } 28 | } 29 | 30 | class CatchMeRoute extends XerusRoute { 31 | method = Method.GET; 32 | path = "/mw-err/catch-me"; 33 | services = [ServiceSafeGuard]; 34 | async handle(_c: HTTPContext) { 35 | throw new Error("I am an error thrown in the handler"); 36 | } 37 | } 38 | 39 | class BubbleUpRoute extends XerusRoute { 40 | method = Method.GET; 41 | path = "/mw-err/bubble-up"; 42 | async handle(_c: HTTPContext) { 43 | throw new Error("I should bubble to global handler"); 44 | } 45 | } 46 | 47 | // CHANGED: Use Class-based error handler 48 | class GlobalErrorHandler extends XerusRoute { 49 | method = Method.GET; 50 | path = ""; 51 | async handle(c: HTTPContext) { 52 | const err = c.err; 53 | setStatus(c, 500); 54 | json(c, { 55 | error: { 56 | code: "GLOBAL_ERROR", 57 | message: "Custom Global Handler", 58 | detail: err instanceof Error ? err.message : String(err ?? "Unknown Error"), 59 | }, 60 | }); 61 | } 62 | } 63 | 64 | app.onErr(GlobalErrorHandler); 65 | app.mount(CatchMeRoute, BubbleUpRoute); 66 | 67 | server = await app.listen(0); 68 | port = server.port; 69 | }); 70 | 71 | afterAll(() => { 72 | server?.stop?.(true); 73 | }); 74 | 75 | test("MW Error: Middleware try/catch should intercept downstream error", async () => { 76 | const res = await fetch(makeURL(port, "/mw-err/catch-me")); 77 | const data = await res.json(); 78 | expect(res.status).toBe(422); 79 | expect(data.safeGuard).toBe(true); 80 | expect(data.originalError).toBe("I am an error thrown in the handler"); 81 | }); 82 | 83 | test("MW Error: Uncaught error should bubble to global handler", async () => { 84 | const res = await fetch(makeURL(port, "/mw-err/bubble-up")); 85 | const data = await res.json(); 86 | expect(res.status).toBe(500); 87 | expect(data.error.message).toBe("Custom Global Handler"); 88 | expect(data.error.detail).toBe("I should bubble to global handler"); 89 | expect(data.error.code).toBeTruthy(); 90 | }); 91 | }); -------------------------------------------------------------------------------- /tests/aab_architechture.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, beforeAll, afterAll } from "bun:test"; 2 | import { Xerus } from "../src/Xerus"; 3 | import { XerusRoute } from "../src/XerusRoute"; 4 | import { Method } from "../src/Method"; 5 | import { HTTPContext } from "../src/HTTPContext"; 6 | import type { ServiceLifecycle, InjectableStore } from "../src/RouteFields"; 7 | import { json, setHeader, setStatus } from "../src/std/Response"; 8 | 9 | describe("Architecture: Services & Middleware", () => { 10 | let server: any; 11 | let baseUrl: string; 12 | 13 | beforeAll(async () => { 14 | const app = new Xerus(); 15 | 16 | // --- Services --- 17 | class AuthMiddleware implements XerusService { 18 | async before(c: HTTPContext) { 19 | setHeader(c, "X-Auth-Check", "Passed"); 20 | } 21 | } 22 | 23 | class UserService implements InjectableStore { 24 | storeKey = "UserService"; 25 | users = ["Alice", "Bob"]; 26 | getUsers() { return this.users; } 27 | } 28 | 29 | class ErrorService implements XerusService { 30 | async onError(c: HTTPContext, err: unknown) { 31 | setStatus(c, 500); 32 | json(c, { handled: true, error: (err as Error).message }); 33 | } 34 | } 35 | 36 | // --- Routes --- 37 | class ProtectedRoute extends XerusRoute { 38 | method = Method.GET; 39 | path = "/protected"; 40 | services = [AuthMiddleware]; 41 | async handle(c: HTTPContext) { 42 | json(c, { ok: true }); 43 | } 44 | } 45 | 46 | class InjectionRoute extends XerusRoute { 47 | method = Method.GET; 48 | path = "/users"; 49 | services = [UserService]; 50 | async handle(c: HTTPContext) { 51 | const svc = c.service(UserService); 52 | json(c, { users: svc.getUsers() }); 53 | } 54 | } 55 | 56 | class BoomRoute extends XerusRoute { 57 | method = Method.GET; 58 | path = "/boom"; 59 | services = [ErrorService]; 60 | async handle(c: HTTPContext) { 61 | throw new Error("Explosion"); 62 | } 63 | } 64 | 65 | app.mount(ProtectedRoute, InjectionRoute, BoomRoute); 66 | server = await app.listen(0); 67 | baseUrl = `http://localhost:${server.port}`; 68 | }); 69 | 70 | afterAll(() => { 71 | server.stop(true); 72 | }); 73 | 74 | test("Middleware: 'before' hook sets header", async () => { 75 | const res = await fetch(`${baseUrl}/protected`); 76 | expect(res.status).toBe(200); 77 | expect(res.headers.get("X-Auth-Check")).toBe("Passed"); 78 | }); 79 | 80 | test("Dependency Injection: Service available in handle", async () => { 81 | const res = await fetch(`${baseUrl}/users`); 82 | const data = await res.json(); 83 | expect(data.users).toEqual(["Alice", "Bob"]); 84 | }); 85 | 86 | test("Error Handling: Service intercepts error", async () => { 87 | const res = await fetch(`${baseUrl}/boom`); 88 | const data = await res.json(); 89 | expect(res.status).toBe(500); 90 | expect(data.handled).toBe(true); 91 | expect(data.error).toBe("Explosion"); 92 | }); 93 | }); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # User-Defined 4 | 5 | 6 | # Logs 7 | 8 | logs 9 | _.log 10 | npm-debug.log_ 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | .pnpm-debug.log* 15 | 16 | # Caches 17 | 18 | .cache 19 | 20 | # Diagnostic reports (https://nodejs.org/api/report.html) 21 | 22 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 23 | 24 | # Runtime data 25 | 26 | pids 27 | _.pid 28 | _.seed 29 | *.pid.lock 30 | 31 | # Directory for instrumented libs generated by jscoverage/JSCover 32 | 33 | lib-cov 34 | 35 | # Coverage directory used by tools like istanbul 36 | 37 | coverage 38 | *.lcov 39 | 40 | # nyc test coverage 41 | 42 | .nyc_output 43 | 44 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 45 | 46 | .grunt 47 | 48 | # Bower dependency directory (https://bower.io/) 49 | 50 | bower_components 51 | 52 | # node-waf configuration 53 | 54 | .lock-wscript 55 | 56 | # Compiled binary addons (https://nodejs.org/api/addons.html) 57 | 58 | build/Release 59 | 60 | # Dependency directories 61 | 62 | node_modules/ 63 | jspm_packages/ 64 | 65 | # Snowpack dependency directory (https://snowpack.dev/) 66 | 67 | web_modules/ 68 | 69 | # TypeScript cache 70 | 71 | *.tsbuildinfo 72 | 73 | # Optional npm cache directory 74 | 75 | .npm 76 | 77 | # Optional eslint cache 78 | 79 | .eslintcache 80 | 81 | # Optional stylelint cache 82 | 83 | .stylelintcache 84 | 85 | # Microbundle cache 86 | 87 | .rpt2_cache/ 88 | .rts2_cache_cjs/ 89 | .rts2_cache_es/ 90 | .rts2_cache_umd/ 91 | 92 | # Optional REPL history 93 | 94 | .node_repl_history 95 | 96 | # Output of 'npm pack' 97 | 98 | *.tgz 99 | 100 | # Yarn Integrity file 101 | 102 | .yarn-integrity 103 | 104 | # dotenv environment variable files 105 | 106 | .env 107 | .env.development.local 108 | .env.test.local 109 | .env.production.local 110 | .env.local 111 | 112 | # parcel-bundler cache (https://parceljs.org/) 113 | 114 | .parcel-cache 115 | 116 | # Next.js build output 117 | 118 | .next 119 | out 120 | 121 | # Nuxt.js build / generate output 122 | 123 | .nuxt 124 | dist 125 | 126 | # Gatsby files 127 | 128 | # Comment in the public line in if your project uses Gatsby and not Next.js 129 | 130 | # https://nextjs.org/blog/next-9-1#public-directory-support 131 | 132 | # public 133 | 134 | # vuepress build output 135 | 136 | .vuepress/dist 137 | 138 | # vuepress v2.x temp and cache directory 139 | 140 | .temp 141 | 142 | # Docusaurus cache and generated files 143 | 144 | .docusaurus 145 | 146 | # Serverless directories 147 | 148 | .serverless/ 149 | 150 | # FuseBox cache 151 | 152 | .fusebox/ 153 | 154 | # DynamoDB Local files 155 | 156 | .dynamodb/ 157 | 158 | # TernJS port file 159 | 160 | .tern-port 161 | 162 | # Stores VSCode versions used for testing VSCode extensions 163 | 164 | .vscode-test 165 | 166 | # yarn v2 167 | 168 | .yarn/cache 169 | .yarn/unplugged 170 | .yarn/build-state.yml 171 | .yarn/install-state.gz 172 | .pnp.* 173 | 174 | # IntelliJ based IDEs 175 | .idea 176 | 177 | # Finder (MacOS) folder config 178 | .DS_Store 179 | -------------------------------------------------------------------------------- /tests/aba_ws_validation.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Xerus } from "../src/Xerus"; 3 | import { XerusRoute } from "../src/XerusRoute"; 4 | import { Method } from "../src/Method"; 5 | import type { HTTPContext } from "../src/HTTPContext"; 6 | import type { XerusValidator } from "../src/XerusValidator"; 7 | import { SystemErr } from "../src/SystemErr"; 8 | import { SystemErrCode } from "../src/SystemErrCode"; 9 | import { header, ws } from "../src/std/Request"; 10 | import { json } from "../src/std/Response"; 11 | 12 | let lastClose: { code: number; reason: string } = { code: 0, reason: "" }; 13 | let closeCount = 0; 14 | 15 | const closeSchema = z.object({ 16 | code: z.number().int().nonnegative(), 17 | reason: z.string(), 18 | }); 19 | 20 | class HeaderClientValidator implements XerusValidator { 21 | async validate(c: HTTPContext): Promise { 22 | const client = header(c, "X-Client") ?? ""; 23 | 24 | if (client.length === 0) { 25 | throw new SystemErr( 26 | SystemErrCode.VALIDATION_FAILED, 27 | "Missing X-Client header", 28 | ); 29 | } 30 | if (client !== "tester") { 31 | throw new SystemErr(SystemErrCode.VALIDATION_FAILED, "Bad client header"); 32 | } 33 | 34 | return client; 35 | } 36 | } 37 | 38 | class CloseEventValidator 39 | implements XerusValidator<{ code: number; reason: string }> 40 | { 41 | async validate(c: HTTPContext): Promise<{ code: number; reason: string }> { 42 | const socket = ws(c); 43 | 44 | // zod will throw; Xerus will treat it as validation-ish via issues/errors 45 | const parsed = await closeSchema.parseAsync({ 46 | code: socket.code, 47 | reason: socket.reason, 48 | }); 49 | 50 | return parsed; 51 | } 52 | } 53 | 54 | class LifecycleOpen extends XerusRoute { 55 | method = Method.WS_OPEN; 56 | path = "/ws/lifecycle-validate"; 57 | 58 | // NEW API 59 | validators = [HeaderClientValidator]; 60 | 61 | async handle(c: HTTPContext) { 62 | // Access via context (if you ever need it) 63 | // const client = c.validated(HeaderClientValidator); 64 | 65 | const socket = ws(c); 66 | socket.send("open-ok"); 67 | } 68 | } 69 | 70 | class LifecycleMessage extends XerusRoute { 71 | method = Method.WS_MESSAGE; 72 | path = "/ws/lifecycle-validate"; 73 | 74 | async handle(c: HTTPContext) { 75 | const socket = ws(c); 76 | socket.send("cleared"); 77 | } 78 | } 79 | 80 | class LifecycleClose extends XerusRoute { 81 | method = Method.WS_CLOSE; 82 | path = "/ws/close-validate"; 83 | 84 | // NEW API 85 | validators = [CloseEventValidator]; 86 | 87 | async handle(c: HTTPContext) { 88 | const socket = ws(c); 89 | 90 | // Ensures validator ran (and makes the validated value available if needed) 91 | // const closeInfo = c.validated(CloseEventValidator); 92 | 93 | lastClose = { code: socket.code, reason: socket.reason }; 94 | closeCount++; 95 | } 96 | } 97 | 98 | class CloseStats extends XerusRoute { 99 | method = Method.GET; 100 | path = "/ws-close-stats"; 101 | 102 | async handle(c: HTTPContext) { 103 | json(c, { closeCount, lastClose }); 104 | } 105 | } 106 | 107 | export function wsLifecycleValidation(app: Xerus) { 108 | app.mount(LifecycleOpen, LifecycleMessage, LifecycleClose, CloseStats); 109 | } 110 | -------------------------------------------------------------------------------- /tests/aah_parse_body.test.ts: -------------------------------------------------------------------------------- 1 | import { Xerus } from "../src/Xerus"; 2 | import { XerusRoute } from "../src/XerusRoute"; 3 | import { Method } from "../src/Method"; 4 | import { HTTPContext } from "../src/HTTPContext"; 5 | import { BodyType } from "../src/BodyType"; 6 | import { SystemErr } from "../src/SystemErr"; 7 | import { SystemErrCode } from "../src/SystemErrCode"; 8 | import type { XerusValidator } from "../src/XerusValidator"; 9 | import { json } from "../src/std/Response"; 10 | import { parseBody as stdParseBody } from "../src/std/Body"; 11 | 12 | class JsonBody implements XerusValidator { 13 | async validate(c: HTTPContext) { 14 | const data: any = await stdParseBody(c, BodyType.JSON); 15 | if (!data || typeof data !== "object" || Array.isArray(data)) { 16 | throw new SystemErr( 17 | SystemErrCode.VALIDATION_FAILED, 18 | "Expected JSON object body", 19 | ); 20 | } 21 | return data; 22 | } 23 | } 24 | 25 | class TextBody implements XerusValidator { 26 | async validate(c: HTTPContext) { 27 | const content = await stdParseBody(c, BodyType.TEXT); 28 | if (typeof content !== "string") { 29 | throw new SystemErr(SystemErrCode.VALIDATION_FAILED, "Expected text body"); 30 | } 31 | return content; 32 | } 33 | } 34 | 35 | class FormBody implements XerusValidator { 36 | async validate(c: HTTPContext) { 37 | const data: any = await stdParseBody(c, BodyType.FORM); 38 | if (!data || typeof data !== "object") { 39 | throw new SystemErr(SystemErrCode.VALIDATION_FAILED, "Expected FORM object"); 40 | } 41 | return data; 42 | } 43 | } 44 | 45 | class MultipartBody implements XerusValidator { 46 | async validate(c: HTTPContext) { 47 | const fd = await stdParseBody(c, BodyType.MULTIPART_FORM); 48 | return fd; 49 | } 50 | } 51 | 52 | class ParseJson extends XerusRoute { 53 | method = Method.POST; 54 | path = "/parse/json"; 55 | validators = [JsonBody]; 56 | 57 | async handle(c: HTTPContext) { 58 | const data = c.validated(JsonBody); 59 | json(c, { status: "success", data }); 60 | } 61 | } 62 | 63 | class ParseText extends XerusRoute { 64 | method = Method.POST; 65 | path = "/parse/text"; 66 | validators = [TextBody]; 67 | 68 | async handle(c: HTTPContext) { 69 | const data = c.validated(TextBody); 70 | json(c, { status: "success", data }); 71 | } 72 | } 73 | 74 | class ParseForm extends XerusRoute { 75 | method = Method.POST; 76 | path = "/parse/form"; 77 | validators = [FormBody]; 78 | 79 | async handle(c: HTTPContext) { 80 | const data = c.validated(FormBody); 81 | json(c, { status: "success", data }); 82 | } 83 | } 84 | 85 | class ParseMultipart extends XerusRoute { 86 | method = Method.POST; 87 | path = "/parse/multipart"; 88 | validators = [MultipartBody]; 89 | 90 | async handle(c: HTTPContext) { 91 | const fd = c.validated(MultipartBody) as FormData; 92 | const result: Record = {}; 93 | fd.forEach((value: FormDataEntryValue, key: string) => { 94 | if (typeof value === "string") result[key] = value; 95 | }); 96 | json(c, { status: "success", data: result }); 97 | } 98 | } 99 | 100 | export function parseBody(c: HTTPContext, JSON: BodyType, app: Xerus) { 101 | app.mount(ParseJson, ParseText, ParseForm, ParseMultipart); 102 | } 103 | -------------------------------------------------------------------------------- /tests/aaw_data_integrity.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import { HTTPContext } from "../src/HTTPContext"; 7 | import { BodyType } from "../src/BodyType"; 8 | import { url } from "../src/std/Request"; 9 | import { json } from "../src/std/Response"; 10 | import { parseBody } from "../src/std/Body"; 11 | 12 | function makeURL(port: number, path: string) { 13 | return `http://127.0.0.1:${port}${path}`; 14 | } 15 | 16 | /* ====================== 17 | Routes 18 | ====================== */ 19 | 20 | class QueryArray extends XerusRoute { 21 | method = Method.GET; 22 | path = "/integrity/query-array"; 23 | 24 | async handle(c: HTTPContext) { 25 | const all = url(c).searchParams.getAll("id"); 26 | json(c, { ids: all }); 27 | } 28 | } 29 | 30 | class FormMulti extends XerusRoute { 31 | method = Method.POST; 32 | path = "/integrity/form-multi"; 33 | 34 | async handle(c: HTTPContext) { 35 | const data = await parseBody(c, BodyType.FORM, { formMode: "multi" }); 36 | json(c, { data }); 37 | } 38 | } 39 | 40 | class EmptyJson extends XerusRoute { 41 | method = Method.POST; 42 | path = "/integrity/empty-json"; 43 | 44 | async handle(c: HTTPContext) { 45 | try { 46 | const data = await parseBody(c, BodyType.JSON); 47 | json(c, { empty: false, data }); 48 | } catch (e: any) { 49 | json(c, { empty: true, error: e.message }); 50 | } 51 | } 52 | } 53 | 54 | /* ====================== 55 | Tests 56 | ====================== */ 57 | 58 | describe("Data Integrity", () => { 59 | let server: any; 60 | let port: number; 61 | 62 | beforeAll(async () => { 63 | const app = new Xerus(); 64 | app.mount(QueryArray, FormMulti, EmptyJson); 65 | 66 | server = await app.listen(0); 67 | port = server.port; 68 | }); 69 | 70 | afterAll(() => { 71 | server?.stop?.(true); 72 | }); 73 | 74 | test("Integrity: Query should capture multiple values for same key", async () => { 75 | const res = await fetch(makeURL(port, "/integrity/query-array?id=1&id=2&id=3")); 76 | const data = await res.json(); 77 | 78 | expect(res.status).toBe(200); 79 | expect(data.ids).toEqual(["1", "2", "3"]); 80 | }); 81 | 82 | test("Integrity: Form should parse multiple values when formMode='multi'", async () => { 83 | const res = await fetch(makeURL(port, "/integrity/form-multi"), { 84 | method: "POST", 85 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 86 | body: "tag=a&tag=b&user=me", 87 | }); 88 | 89 | const json = await res.json(); 90 | 91 | expect(res.status).toBe(200); 92 | expect(json.data.tag).toEqual(["a", "b"]); 93 | expect(json.data.user).toBe("me"); 94 | }); 95 | 96 | test("Integrity: Empty body parsed as JSON should trigger error handling", async () => { 97 | const res = await fetch(makeURL(port, "/integrity/empty-json"), { 98 | method: "POST", 99 | headers: { "Content-Type": "application/json" }, 100 | body: "", 101 | }); 102 | 103 | const json = await res.json(); 104 | 105 | expect(json.empty).toBe(true); 106 | expect(json.error).toContain("JSON"); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /src/Href.ts: -------------------------------------------------------------------------------- 1 | export type HrefQueryValue = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | undefined 7 | | Array; 8 | 9 | export type HrefQuery = Record; 10 | 11 | function assertNoNewlines(s: string) { 12 | if (/[\r\n]/.test(s)) { 13 | throw new Error("Invalid URL: contains newline characters"); 14 | } 15 | } 16 | 17 | function normalizeValues(v: HrefQueryValue): string[] { 18 | if (v === undefined || v === null) return []; 19 | if (Array.isArray(v)) { 20 | const out: string[] = []; 21 | for (const x of v) { 22 | if (x === undefined || x === null) continue; 23 | out.push(String(x)); 24 | } 25 | return out; 26 | } 27 | return [String(v)]; 28 | } 29 | 30 | /** 31 | * Build a URL/HREF with proper encoding + merging behavior. 32 | * 33 | * - Preserves existing query in `path` (e.g. "/?a=1") 34 | * - Appends/overrides via `query` 35 | * - Skips null/undefined 36 | * - Supports arrays => repeated keys: ?tag=a&tag=b 37 | */ 38 | export function href(path: string, query?: HrefQuery): string { 39 | assertNoNewlines(path); 40 | 41 | // Split path from existing query/hash (if present) 42 | const hashIndex = path.indexOf("#"); 43 | const hasHash = hashIndex !== -1; 44 | const beforeHash = hasHash ? path.slice(0, hashIndex) : path; 45 | const hash = hasHash ? path.slice(hashIndex) : ""; 46 | 47 | const qIndex = beforeHash.indexOf("?"); 48 | const basePath = qIndex === -1 ? beforeHash : beforeHash.slice(0, qIndex); 49 | const existingQuery = qIndex === -1 ? "" : beforeHash.slice(qIndex + 1); 50 | 51 | const params = new URLSearchParams(existingQuery); 52 | 53 | if (query && typeof query === "object") { 54 | for (const [k, raw] of Object.entries(query)) { 55 | // If user passes [] we treat as "clear" for that key 56 | if (Array.isArray(raw) && raw.length === 0) { 57 | params.delete(k); 58 | continue; 59 | } 60 | 61 | // For non-arrays, we overwrite (delete+set) to avoid accidental duplicates 62 | if (!Array.isArray(raw)) { 63 | params.delete(k); 64 | for (const v of normalizeValues(raw)) params.append(k, v); 65 | continue; 66 | } 67 | 68 | // Arrays append (repeated keys) 69 | params.delete(k); 70 | for (const v of normalizeValues(raw)) params.append(k, v); 71 | } 72 | } 73 | 74 | const qs = params.toString(); 75 | return qs.length ? `${basePath}?${qs}${hash}` : `${basePath}${hash}`; 76 | } 77 | 78 | 79 | /** 80 | * Creates a reusable href generator function for a specific route. 81 | * 82 | * @param path - The base path (e.g., "/about" or "/search") 83 | * @param keys - A list of query parameter keys that the returned function will accept as arguments. 84 | * @returns A function that accepts values corresponding to `keys` and returns the full URL. 85 | */ 86 | export function defineHref( 87 | path: string, 88 | ...keys: string[] 89 | ): (...values: HrefQueryValue[]) => string { 90 | return (...values: HrefQueryValue[]) => { 91 | const query: HrefQuery = {}; 92 | 93 | // Map the incoming values to the defined keys 94 | for (let i = 0; i < keys.length; i++) { 95 | const key = keys[i]; 96 | const val = values[i]; 97 | // We pass the value through even if null/undefined, 98 | // because the underlying href() function handles normalization/skipping. 99 | query[key] = val; 100 | } 101 | 102 | return href(path, query); 103 | }; 104 | } -------------------------------------------------------------------------------- /tests/abe_ws_lifecycle_cleanup.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { Xerus } from "../src/Xerus"; 3 | import { XerusRoute } from "../src/XerusRoute"; 4 | import { Method } from "../src/Method"; 5 | import type { HTTPContext } from "../src/HTTPContext"; 6 | import { ws } from "../src/std/Request"; 7 | 8 | // Service that should be recreated per WS event (because resetScope() runs) 9 | class PerMessageService { 10 | value: number = Math.random(); 11 | } 12 | 13 | // WS route (MESSAGE) that uses a service and can set __holdRelease 14 | class WsMessageRoute extends XerusRoute { 15 | method = Method.WS_MESSAGE; 16 | path = "/ws"; 17 | services = [PerMessageService]; 18 | 19 | async handle(c: HTTPContext) { 20 | const w = ws(c); 21 | 22 | const svc = c.service(PerMessageService); 23 | 24 | // If client says "hold", set a hold promise to simulate async teardown work 25 | if (String(c._wsMessage ?? "") === "hold") { 26 | c.__holdRelease = new Promise((resolve) => setTimeout(resolve, 50)); 27 | } 28 | 29 | w.send(JSON.stringify({ serviceValue: svc.value })); 30 | } 31 | } 32 | 33 | test("WS contexts are scrubbed + __holdRelease does not double-release; services do not leak per message", async () => { 34 | const app = new Xerus(); 35 | app.mount(WsMessageRoute); 36 | 37 | // Monkeypatch pool release to count releases (contextPool is private, but test can access via `as any`) 38 | const pool = (app as any).contextPool; 39 | expect(pool).toBeTruthy(); 40 | 41 | let releaseCount = 0; 42 | const origRelease = pool.release.bind(pool); 43 | pool.release = (item: any) => { 44 | releaseCount++; 45 | return origRelease(item); 46 | }; 47 | 48 | const server = await app.listen(0); 49 | const url = `ws://localhost:${server.port}/ws`; 50 | 51 | const sock = new WebSocket(url); 52 | 53 | const nextMessage = () => 54 | new Promise((resolve, reject) => { 55 | const onMsg = (ev: MessageEvent) => { 56 | sock.removeEventListener("message", onMsg); 57 | resolve(JSON.parse(String(ev.data))); 58 | }; 59 | const onErr = (ev: Event) => { 60 | sock.removeEventListener("message", onMsg); 61 | reject(ev); 62 | }; 63 | sock.addEventListener("message", onMsg); 64 | sock.addEventListener("error", onErr, { once: true }); 65 | }); 66 | 67 | await new Promise((resolve, reject) => { 68 | sock.addEventListener("open", () => resolve(), { once: true }); 69 | sock.addEventListener("error", (e) => reject(e), { once: true }); 70 | }); 71 | 72 | // Send twice; value should differ if service is recreated per WS event 73 | sock.send("first"); 74 | const m1 = await nextMessage(); 75 | 76 | sock.send("second"); 77 | const m2 = await nextMessage(); 78 | 79 | expect(typeof m1.serviceValue).toBe("number"); 80 | expect(typeof m2.serviceValue).toBe("number"); 81 | expect(m1.serviceValue).not.toBe(m2.serviceValue); 82 | 83 | // Now trigger hold and close immediately; release should happen exactly once, 84 | // after hold resolves, not twice (once immediate, once later). 85 | sock.send("hold"); 86 | await nextMessage(); 87 | 88 | sock.close(1000, "bye"); 89 | 90 | await new Promise((resolve) => { 91 | sock.addEventListener("close", () => resolve(), { once: true }); 92 | }); 93 | 94 | // Wait long enough for hold to resolve + release to run 95 | await new Promise((r) => setTimeout(r, 120)); 96 | 97 | expect(releaseCount).toBe(1); 98 | 99 | server.stop(true); 100 | }); 101 | -------------------------------------------------------------------------------- /tests/aax_injector_validators.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import type { XerusValidator } from "../src/XerusValidator"; 4 | import type { HTTPContext } from "../src/HTTPContext"; 5 | import type { InjectableStore } from "../src/RouteFields"; 6 | import { Method } from "../src/Method"; 7 | import { Xerus } from "../src/Xerus"; 8 | import { XerusRoute } from "../src/XerusRoute"; 9 | import { query } from "../src/std/Request"; 10 | import { json } from "../src/std/Response"; 11 | 12 | function makeURL(port: number, path: string) { 13 | return `http://127.0.0.1:${port}${path}`; 14 | } 15 | 16 | /* ====================== 17 | Validator + Service + Route 18 | ====================== */ 19 | 20 | class SomeQueryParam implements XerusValidator { 21 | async validate(c: HTTPContext) { 22 | const q = query(c, "q", ""); 23 | return { query: q }; 24 | } 25 | } 26 | 27 | class UserService implements InjectableStore { 28 | storeKey = "UserService"; 29 | 30 | qp?: { query: string }; 31 | computed: string = ""; 32 | 33 | async init(c: HTTPContext): Promise { 34 | // Pull from the validator cache (and/or trigger it if not run yet). 35 | const qp = c.validated(SomeQueryParam); 36 | this.qp = qp; 37 | this.computed = `computed:${qp.query}`; 38 | } 39 | } 40 | 41 | class InjectorValidatorRoute extends XerusRoute { 42 | method = Method.GET; 43 | path = "/injector-validator"; 44 | services = [UserService]; 45 | validators = [SomeQueryParam]; 46 | 47 | async handle(c: HTTPContext): Promise { 48 | const user = c.service(UserService); 49 | const qp = c.validated(SomeQueryParam); 50 | 51 | json(c, { 52 | fromSvc: user.qp?.query ?? "", 53 | fromData: qp.query, 54 | sameInstance: user.qp === qp, 55 | computed: user.computed, 56 | }); 57 | } 58 | } 59 | 60 | /* ====================== 61 | Tests 62 | ====================== */ 63 | 64 | describe("Injector + Validators", () => { 65 | let server: any; 66 | let port: number; 67 | 68 | beforeAll(async () => { 69 | const app = new Xerus(); 70 | app.mount(InjectorValidatorRoute); 71 | 72 | server = await app.listen(0); 73 | port = server.port; 74 | }); 75 | 76 | afterAll(() => { 77 | server?.stop?.(true); 78 | }); 79 | 80 | test("injected service should share the same validator instance as c.validated(Type)", async () => { 81 | const res = await fetch(makeURL(port, "/injector-validator?q=hello")); 82 | const j = await res.json(); 83 | 84 | expect(res.status).toBe(200); 85 | expect(j.fromSvc).toBe("hello"); 86 | expect(j.fromData).toBe("hello"); 87 | expect(j.sameInstance).toBe(true); 88 | expect(j.computed).toBe("computed:hello"); 89 | }); 90 | 91 | test("should not leak across requests (computed should track current request)", async () => { 92 | const r1 = await fetch(makeURL(port, "/injector-validator?q=A")); 93 | const j1 = await r1.json(); 94 | 95 | expect(r1.status).toBe(200); 96 | expect(j1.fromSvc).toBe("A"); 97 | expect(j1.computed).toBe("computed:A"); 98 | 99 | const r2 = await fetch(makeURL(port, "/injector-validator?q=B")); 100 | const j2 = await r2.json(); 101 | 102 | expect(r2.status).toBe(200); 103 | expect(j2.fromSvc).toBe("B"); 104 | expect(j2.computed).toBe("computed:B"); 105 | }); 106 | 107 | test("missing query param should default via query fallback", async () => { 108 | const res = await fetch(makeURL(port, "/injector-validator")); 109 | const j = await res.json(); 110 | 111 | expect(res.status).toBe(200); 112 | expect(j.fromSvc).toBe(""); 113 | expect(j.fromData).toBe(""); 114 | expect(j.sameInstance).toBe(true); 115 | expect(j.computed).toBe("computed:"); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /tests/aal_routing_complexity.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import { HTTPContext } from "../src/HTTPContext"; 7 | import { json } from "../src/std/Response"; 8 | import { param } from "../src/std/Request"; 9 | 10 | function makeURL(port: number, path: string) { 11 | return `http://127.0.0.1:${port}${path}`; 12 | } 13 | 14 | describe("Routing complexity", () => { 15 | let server: any; 16 | let port: number; 17 | 18 | beforeAll(async () => { 19 | const app = new Xerus(); 20 | 21 | class UsersMe extends XerusRoute { 22 | method = Method.GET; 23 | path = "/users/me"; 24 | async handle(c: HTTPContext) { 25 | json(c, { type: "exact", identity: "myself" }); 26 | } 27 | } 28 | 29 | class UsersParam extends XerusRoute { 30 | method = Method.GET; 31 | path = "/users/:id"; 32 | async handle(c: HTTPContext) { 33 | json(c, { type: "param", identity: param(c, "id") }); 34 | } 35 | } 36 | 37 | class OrgProject extends XerusRoute { 38 | method = Method.GET; 39 | path = "/org/:orgId/project/:projectId"; 40 | async handle(c: HTTPContext) { 41 | json(c, { 42 | org: param(c, "orgId"), 43 | project: param(c, "projectId"), 44 | }); 45 | } 46 | } 47 | 48 | class PublicWildcard extends XerusRoute { 49 | method = Method.GET; 50 | path = "/public/*"; 51 | async handle(c: HTTPContext) { 52 | json(c, { path: c.path, message: "wildcard matched" }); 53 | } 54 | } 55 | 56 | class DocsWildcard extends XerusRoute { 57 | method = Method.GET; 58 | path = "/api/v1/docs/*"; 59 | async handle(_c: HTTPContext) { 60 | json(_c, { scope: "docs-wildcard" }); 61 | } 62 | } 63 | 64 | app.mount(UsersMe, UsersParam, OrgProject, PublicWildcard, DocsWildcard); 65 | 66 | server = await app.listen(0); 67 | port = server.port; 68 | }); 69 | 70 | afterAll(() => { 71 | server?.stop?.(true); 72 | }); 73 | 74 | test("Should prioritize exact match over parameter", async () => { 75 | const res = await fetch(makeURL(port, "/users/me")); 76 | const data = await res.json(); 77 | expect(res.status).toBe(200); 78 | expect(data.type).toBe("exact"); 79 | expect(data.identity).toBe("myself"); 80 | }); 81 | 82 | test("Should correctly capture dynamic path parameter", async () => { 83 | const res = await fetch(makeURL(port, "/users/12345")); 84 | const data = await res.json(); 85 | expect(res.status).toBe(200); 86 | expect(data.type).toBe("param"); 87 | expect(data.identity).toBe("12345"); 88 | }); 89 | 90 | test("Should capture multiple nested parameters", async () => { 91 | const res = await fetch(makeURL(port, "/org/xerus-inc/project/core-lib")); 92 | const data = await res.json(); 93 | expect(res.status).toBe(200); 94 | expect(data.org).toBe("xerus-inc"); 95 | expect(data.project).toBe("core-lib"); 96 | }); 97 | 98 | test("Should handle simple wildcard greedy match", async () => { 99 | const res = await fetch(makeURL(port, "/public/images/logo.png")); 100 | const data = await res.json(); 101 | expect(res.status).toBe(200); 102 | expect(data.path).toBe("/public/images/logo.png"); 103 | expect(data.message).toBe("wildcard matched"); 104 | }); 105 | 106 | test("Should handle deep wildcard match", async () => { 107 | const res = await fetch(makeURL(port, "/api/v1/docs/intro/getting-started")); 108 | const data = await res.json(); 109 | expect(res.status).toBe(200); 110 | expect(data.scope).toBe("docs-wildcard"); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /tests/abr_templates.test.ts: -------------------------------------------------------------------------------- 1 | // --- START FILE: tests/templates.test.ts --- 2 | import { describe, test, expect, beforeAll, afterAll } from "bun:test"; 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import type { HTTPContext } from "../src/HTTPContext"; 7 | import { template } from "../src/std/Template"; 8 | 9 | describe("Templates", () => { 10 | let server: any; 11 | let baseUrl: string; 12 | 13 | beforeAll(async () => { 14 | const app = new Xerus(); 15 | 16 | // Register embedded templates (keys match your embedDir() output shape: "/rel/path") 17 | app.templates({ 18 | "/index.html": { content: "

Hello

", type: "text/html" }, 19 | "/nested/page.html": { content: "

Nested

", type: "text/html" }, 20 | }); 21 | 22 | class TIndexRoute extends XerusRoute { 23 | method = Method.GET; 24 | path = "/t/index"; 25 | async handle(c: HTTPContext) { 26 | const html = template(c, "index.html"); // also works with "./index.html" or "/index.html" 27 | c.res.headers.set("Content-Type", "text/html"); 28 | c.res.body(html); 29 | c.finalize(); 30 | } 31 | } 32 | 33 | class TNestedRoute extends XerusRoute { 34 | method = Method.GET; 35 | path = "/t/nested"; 36 | async handle(c: HTTPContext) { 37 | const html = template(c, "nested/page.html"); 38 | c.res.headers.set("Content-Type", "text/html"); 39 | c.res.body(html); 40 | c.finalize(); 41 | } 42 | } 43 | 44 | class TTraversalBlockedRoute extends XerusRoute { 45 | method = Method.GET; 46 | path = "/t/traversal"; 47 | async handle(c: HTTPContext) { 48 | // Should throw FILE_NOT_FOUND / Access Denied via TemplateStore normalizeRel() 49 | const html = template(c, "../secrets.txt"); 50 | c.res.headers.set("Content-Type", "text/plain"); 51 | c.res.body(html); 52 | c.finalize(); 53 | } 54 | } 55 | 56 | class TMissingRoute extends XerusRoute { 57 | method = Method.GET; 58 | path = "/t/missing"; 59 | async handle(c: HTTPContext) { 60 | const html = template(c, "does-not-exist.html"); 61 | c.res.headers.set("Content-Type", "text/plain"); 62 | c.res.body(html); 63 | c.finalize(); 64 | } 65 | } 66 | 67 | app.mount(TIndexRoute, TNestedRoute, TTraversalBlockedRoute, TMissingRoute); 68 | 69 | server = await app.listen(0); 70 | baseUrl = `http://localhost:${server.port}`; 71 | }); 72 | 73 | afterAll(() => { 74 | server.stop(true); 75 | }); 76 | 77 | test("template(): reads root template via relative path", async () => { 78 | const res = await fetch(`${baseUrl}/t/index`); 79 | const txt = await res.text(); 80 | expect(res.status).toBe(200); 81 | expect(res.headers.get("content-type")?.toLowerCase()).toContain("text/html"); 82 | expect(txt).toBe("

Hello

"); 83 | }); 84 | 85 | test("template(): reads nested template", async () => { 86 | const res = await fetch(`${baseUrl}/t/nested`); 87 | const txt = await res.text(); 88 | expect(res.status).toBe(200); 89 | expect(txt).toBe("

Nested

"); 90 | }); 91 | 92 | test("template(): blocks path traversal", async () => { 93 | const res = await fetch(`${baseUrl}/t/traversal`); 94 | const body = await res.json(); 95 | expect(res.status).toBe(404); 96 | expect(body?.error?.code).toBe("FILE_NOT_FOUND"); 97 | }); 98 | 99 | test("template(): missing template returns FILE_NOT_FOUND", async () => { 100 | const res = await fetch(`${baseUrl}/t/missing`); 101 | const body = await res.json(); 102 | expect(res.status).toBe(404); 103 | expect(body?.error?.code).toBe("FILE_NOT_FOUND"); 104 | }); 105 | }); 106 | // --- END FILE: tests/templates.test.ts --- 107 | -------------------------------------------------------------------------------- /tests/aap_object_pool.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import { HTTPContext } from "../src/HTTPContext"; 7 | import { query } from "../src/std/Request"; 8 | import { json, setHeader, setStatus, text } from "../src/std/Response"; 9 | import { TestStore } from "./TestStore"; 10 | 11 | function makeURL(port: number, path: string) { 12 | return `http://127.0.0.1:${port}${path}`; 13 | } 14 | 15 | describe("Object Pool behavior", () => { 16 | let server: any; 17 | let port: number; 18 | 19 | beforeAll(async () => { 20 | const app = new Xerus(); 21 | 22 | class PoolSet extends XerusRoute { 23 | method = Method.GET; 24 | path = "/pool/set"; 25 | services = [TestStore]; 26 | 27 | async handle(c: HTTPContext) { 28 | const store = c.service(TestStore); 29 | const val = query(c, "val"); 30 | store.test_val = val; 31 | json(c, { value: val }); 32 | } 33 | } 34 | 35 | class PoolGet extends XerusRoute { 36 | method = Method.GET; 37 | path = "/pool/get"; 38 | services = [TestStore]; 39 | 40 | async handle(c: HTTPContext) { 41 | const store = c.service(TestStore); 42 | const val = store.test_val; 43 | json(c, { value: val }); 44 | } 45 | } 46 | 47 | class PoolSetHeader extends XerusRoute { 48 | method = Method.GET; 49 | path = "/pool/set-header"; 50 | 51 | async handle(c: HTTPContext) { 52 | setHeader(c, "X-Leaked-Header", "I should be gone"); 53 | text(c, "Header set"); 54 | } 55 | } 56 | 57 | class PoolCheckHeader extends XerusRoute { 58 | method = Method.GET; 59 | path = "/pool/check-header"; 60 | 61 | async handle(c: HTTPContext) { 62 | const leaked = c.res.getHeader("X-Leaked-Header"); 63 | if (leaked) { 64 | setStatus(c, 500); 65 | text(c, "Header Leaked!"); 66 | return; 67 | } 68 | text(c, "Headers clean"); 69 | } 70 | } 71 | 72 | class PoolError extends XerusRoute { 73 | method = Method.GET; 74 | path = "/pool/error"; 75 | 76 | async handle(c: HTTPContext) { 77 | setStatus(c, 400); 78 | text(c, "Bad Request"); 79 | } 80 | } 81 | 82 | app.setHTTPContextPool(50); 83 | app.mount( 84 | PoolSet, 85 | PoolGet, 86 | PoolSetHeader, 87 | PoolCheckHeader, 88 | PoolError, 89 | ); 90 | 91 | server = await app.listen(0); 92 | port = server.port; 93 | }); 94 | 95 | afterAll(() => { 96 | server?.stop?.(true); 97 | }); 98 | 99 | test("ObjectPool: Request 1 should set data", async () => { 100 | const res = await fetch(makeURL(port, "/pool/set?val=A")); 101 | const data = await res.json(); 102 | expect(res.status).toBe(200); 103 | expect(data.value).toBe("A"); 104 | }); 105 | 106 | test("ObjectPool: Request 2 should NOT see data from Request 1", async () => { 107 | const res = await fetch(makeURL(port, "/pool/get")); 108 | const data = await res.json(); 109 | expect(res.status).toBe(200); 110 | expect(data.value).toBeFalsy(); 111 | }); 112 | 113 | test("ObjectPool: Headers should be clean on new request", async () => { 114 | await fetch(makeURL(port, "/pool/set-header")); 115 | const res = await fetch(makeURL(port, "/pool/check-header")); 116 | const headerVal = res.headers.get("X-Leaked-Header"); 117 | expect(headerVal).toBeNull(); 118 | }); 119 | 120 | test("ObjectPool: Status code should reset to 200", async () => { 121 | await fetch(makeURL(port, "/pool/error")); 122 | const res = await fetch(makeURL(port, "/pool/get")); 123 | expect(res.status).toBe(200); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /tests/aaz_ws_methods.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import type { HTTPContext } from "../src/HTTPContext"; 7 | import type { ServiceLifecycle } from "../src/RouteFields"; 8 | import { setHeader } from "../src/std/Response"; 9 | import { ws } from "../src/std/Request"; 10 | import { TestStore } from "./TestStore"; 11 | 12 | function makeWSURL(port: number, path: string) { 13 | return `ws://127.0.0.1:${port}${path}`; 14 | } 15 | 16 | /* ====================== 17 | Services 18 | ====================== */ 19 | 20 | class GroupHeaderService implements XerusService { 21 | async before(c: HTTPContext) { 22 | setHeader(c, "X-Group-Auth", "passed"); 23 | } 24 | } 25 | 26 | /* ====================== 27 | WebSocket Routes 28 | ====================== */ 29 | 30 | class WSEcho extends XerusRoute { 31 | method = Method.WS_MESSAGE; 32 | path = "/ws/echo"; 33 | services = [TestStore]; 34 | 35 | async handle(c: HTTPContext) { 36 | const socket = ws(c); 37 | socket.send(`echo: ${socket.message}`); 38 | } 39 | } 40 | 41 | class WSChatOpen extends XerusRoute { 42 | method = Method.WS_OPEN; 43 | path = "/ws/chat"; 44 | services = [TestStore, GroupHeaderService]; 45 | 46 | async handle(c: HTTPContext) { 47 | const socket = ws(c); 48 | const auth = c.res.getHeader("X-Group-Auth"); 49 | socket.send(`auth-${auth}`); 50 | } 51 | } 52 | 53 | class WSChatMessage extends XerusRoute { 54 | method = Method.WS_MESSAGE; 55 | path = "/ws/chat"; 56 | services = [TestStore]; 57 | 58 | async handle(c: HTTPContext) { 59 | const socket = ws(c); 60 | socket.send(`chat: ${socket.message}`); 61 | } 62 | } 63 | 64 | /* ====================== 65 | Tests 66 | ====================== */ 67 | 68 | describe("WebSocket Methods", () => { 69 | let server: any; 70 | let port: number; 71 | 72 | beforeAll(async () => { 73 | const app = new Xerus(); 74 | app.mount(WSEcho, WSChatOpen, WSChatMessage); 75 | 76 | server = await app.listen(0); 77 | port = server.port; 78 | }); 79 | 80 | afterAll(() => { 81 | server?.stop?.(true); 82 | }); 83 | 84 | test("WebSocket: Should echo messages back", async () => { 85 | const socket = new WebSocket(makeWSURL(port, "/ws/echo")); 86 | 87 | const response = await new Promise((resolve, reject) => { 88 | socket.onopen = () => socket.send("hello xerus"); 89 | socket.onmessage = (event) => { 90 | socket.close(); 91 | resolve(String(event.data)); 92 | }; 93 | socket.onerror = (err) => reject(err); 94 | }); 95 | 96 | expect(response).toBe("echo: hello xerus"); 97 | }); 98 | 99 | test("WebSocket: Should respect middleware on protected route", async () => { 100 | const socket = new WebSocket(makeWSURL(port, "/ws/chat")); 101 | 102 | const response = await new Promise((resolve) => { 103 | socket.onmessage = (event) => { 104 | socket.close(); 105 | resolve(String(event.data)); 106 | }; 107 | }); 108 | 109 | // GroupHeaderService sets X-Group-Auth = passed 110 | expect(response).toBe("auth-passed"); 111 | }); 112 | 113 | test("WebSocket: Middleware echo test", async () => { 114 | const socket = new WebSocket(makeWSURL(port, "/ws/chat")); 115 | 116 | const response = await new Promise((resolve) => { 117 | let count = 0; 118 | 119 | socket.onmessage = (event) => { 120 | count++; 121 | 122 | if (count === 1) { 123 | // first message is auth on WS_OPEN 124 | socket.send("ping"); 125 | } else { 126 | socket.close(); 127 | resolve(String(event.data)); 128 | } 129 | }; 130 | }); 131 | 132 | expect(response).toBe("chat: ping"); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /tests/abk_body_reparse_and_header_injection_guards.test.ts: -------------------------------------------------------------------------------- 1 | // PATH: /home/jacex/src/xerus/tests/http/27_bodyReparseAndHeaderInjectionGuards.test.ts 2 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import type { HTTPContext } from "../src/HTTPContext"; 7 | import { json, setHeader } from "../src/std/Response"; 8 | import { jsonBody, formBody, textBody } from "../src/std/Body"; 9 | 10 | function makeURL(port: number, path: string) { 11 | return `http://127.0.0.1:${port}${path}`; 12 | } 13 | 14 | async function readJSON(res: Response) { 15 | const ct = (res.headers.get("content-type") ?? "").toLowerCase(); 16 | if (!ct.includes("application/json")) return { _text: await res.text() }; 17 | return await res.json(); 18 | } 19 | 20 | describe("body re-parse rules + header injection guards", () => { 21 | let server: any; 22 | let port: number; 23 | 24 | beforeAll(async () => { 25 | const app = new Xerus(); 26 | 27 | class ParseJsonThenFormRoute extends XerusRoute { 28 | method = Method.POST; 29 | path = "/body/json-then-form"; 30 | async handle(c: HTTPContext) { 31 | await jsonBody(c); // consumes/parses JSON 32 | await formBody(c); 33 | json(c, { ok: true }); 34 | } 35 | } 36 | 37 | class ParseTextThenJsonRoute extends XerusRoute { 38 | method = Method.POST; 39 | path = "/body/text-then-json"; 40 | async handle(c: HTTPContext) { 41 | const t = await textBody(c); 42 | const j = await jsonBody(c); 43 | json(c, { textLen: t.length, jsonType: typeof j }); 44 | } 45 | } 46 | 47 | class HeaderNewlineGuardRoute extends XerusRoute { 48 | method = Method.GET; 49 | path = "/hdr/newline"; 50 | async handle(c: HTTPContext) { 51 | setHeader(c, "X-Test", "ok\r\ninjected: yes"); 52 | json(c, { ok: true }); 53 | } 54 | } 55 | 56 | app.mount(ParseJsonThenFormRoute, ParseTextThenJsonRoute, HeaderNewlineGuardRoute); 57 | server = await app.listen(0); 58 | port = server.port; 59 | }); 60 | 61 | afterAll(() => { 62 | server?.stop?.(true); 63 | }); 64 | 65 | test("JSON -> FORM reparse is rejected with 400 BODY_PARSING_FAILED", async () => { 66 | const res = await fetch(makeURL(port, "/body/json-then-form"), { 67 | method: "POST", 68 | headers: { "Content-Type": "application/json" }, 69 | body: JSON.stringify({ hello: "world" }), 70 | }); 71 | 72 | expect(res.status).toBe(400); 73 | const body = await readJSON(res); 74 | 75 | expect(body?.error?.code).toBe("BODY_PARSING_FAILED"); 76 | // Updated expectation to match the actual SystemErr format or the specific re-parse message 77 | const msg = String(body?.error?.message ?? ""); 78 | expect(msg).toContain("BODY_PARSING_FAILED"); 79 | expect(msg).toContain("re-parsing as FORM is not allowed"); 80 | }); 81 | 82 | test("TEXT -> JSON is permitted (jsonBody parses from cached raw text)", async () => { 83 | const res = await fetch(makeURL(port, "/body/text-then-json"), { 84 | method: "POST", 85 | headers: { "Content-Type": "application/json" }, 86 | body: JSON.stringify({ a: 1 }), 87 | }); 88 | 89 | expect(res.status).toBe(200); 90 | const body = await readJSON(res); 91 | expect(body.textLen).toBeGreaterThan(0); 92 | expect(body.jsonType).toBe("object"); 93 | }); 94 | 95 | test("header injection guard rejects newline and returns 500 INTERNAL_SERVER_ERROR", async () => { 96 | const res = await fetch(makeURL(port, "/hdr/newline")); 97 | expect(res.status).toBe(500); 98 | 99 | const body = await readJSON(res); 100 | expect(body?.error?.code).toBe("INTERNAL_SERVER_ERROR"); 101 | expect(String(body?.error?.detail ?? "")).toContain("Attempted to set invalid header"); 102 | }); 103 | }); -------------------------------------------------------------------------------- /src/Headers.ts: -------------------------------------------------------------------------------- 1 | // src/Headers.ts 2 | 3 | export interface HeadersView { 4 | get(name: string): string | null; 5 | getAll(name: string): string[]; 6 | has(name: string): boolean; 7 | } 8 | 9 | export interface HeadersMutable extends HeadersView { 10 | set(name: string, value: string): void; 11 | append(name: string, value: string): void; 12 | delete(name: string): void; 13 | } 14 | 15 | function norm(name: string) { 16 | return name.toLowerCase(); 17 | } 18 | 19 | /** 20 | * Response/outgoing headers. 21 | * Stores multi-values properly (Set-Cookie handled elsewhere). 22 | */ 23 | export class HeadersBag implements HeadersMutable { 24 | private map = new Map(); 25 | 26 | reset() { 27 | this.map.clear(); 28 | } 29 | 30 | set(name: string, value: string): void { 31 | this.map.set(norm(name), [value]); 32 | } 33 | 34 | append(name: string, value: string): void { 35 | const key = norm(name); 36 | const cur = this.map.get(key); 37 | if (!cur) this.map.set(key, [value]); 38 | else cur.push(value); 39 | } 40 | 41 | delete(name: string): void { 42 | this.map.delete(norm(name)); 43 | } 44 | 45 | get(name: string): string | null { 46 | const cur = this.map.get(norm(name)); 47 | if (!cur || cur.length === 0) return null; 48 | return cur[cur.length - 1] ?? null; 49 | } 50 | 51 | getAll(name: string): string[] { 52 | return this.map.get(norm(name))?.slice() ?? []; 53 | } 54 | 55 | has(name: string): boolean { 56 | return this.map.has(norm(name)); 57 | } 58 | 59 | /** 60 | * Convert to real Headers, preserving multi-values with append. 61 | */ 62 | toHeaders(): Headers { 63 | const h = new Headers(); 64 | for (const [k, values] of this.map.entries()) { 65 | for (const v of values) h.append(k, v); 66 | } 67 | return h; 68 | } 69 | } 70 | 71 | /** 72 | * Read-only view of request headers. 73 | */ 74 | export class RequestHeaders implements HeadersView { 75 | private h: Headers; 76 | 77 | constructor(h: Headers) { 78 | this.h = h; 79 | } 80 | 81 | get(name: string): string | null { 82 | return this.h.get(name); 83 | } 84 | 85 | has(name: string): boolean { 86 | return this.h.has(name); 87 | } 88 | 89 | getAll(name: string): string[] { 90 | const key = norm(name); 91 | const out: string[] = []; 92 | 93 | // ✅ avoids `.entries()` typing issue 94 | this.h.forEach((value, headerName) => { 95 | if (norm(headerName) === key) out.push(value); 96 | }); 97 | 98 | return out; 99 | } 100 | } 101 | 102 | /** 103 | * Ref handle to one header name on either a request view (read-only) 104 | * or a response bag (mutable). 105 | */ 106 | export class HeaderRef { 107 | private view: HeadersView; 108 | private mutable: HeadersMutable | null; 109 | private _name: string; 110 | 111 | constructor(view: HeadersView, name: string) { 112 | this.view = view; 113 | this.mutable = (view as any)?.set ? (view as HeadersMutable) : null; 114 | this._name = name; 115 | } 116 | 117 | get name(): string { 118 | return this._name; 119 | } 120 | 121 | get(): string | null { 122 | return this.view.get(this._name); 123 | } 124 | 125 | all(): string[] { 126 | return this.view.getAll(this._name); 127 | } 128 | 129 | has(): boolean { 130 | return this.view.has(this._name); 131 | } 132 | 133 | set(value: string): this { 134 | if (!this.mutable) { 135 | throw new Error(`Header "${this._name}" is read-only in this context.`); 136 | } 137 | this.mutable.set(this._name, value); 138 | return this; 139 | } 140 | 141 | append(value: string): this { 142 | if (!this.mutable) { 143 | throw new Error(`Header "${this._name}" is read-only in this context.`); 144 | } 145 | this.mutable.append(this._name, value); 146 | return this; 147 | } 148 | 149 | delete(): this { 150 | if (!this.mutable) { 151 | throw new Error(`Header "${this._name}" is read-only in this context.`); 152 | } 153 | this.mutable.delete(this._name); 154 | return this; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /tests/abd_context_safety.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { Xerus } from "../src/Xerus"; 3 | import { XerusRoute } from "../src/XerusRoute"; 4 | import { Method } from "../src/Method"; 5 | import type { InjectableStore } from "../src/RouteFields"; 6 | import type { HTTPContext } from "../src/HTTPContext"; 7 | import { ws } from "../src/std/Request"; 8 | 9 | /* =============================== 10 | Test Helpers 11 | ================================ */ 12 | 13 | function wsURL(port: number, path: string) { 14 | return `ws://127.0.0.1:${port}${path}`; 15 | } 16 | 17 | /* =============================== 18 | Services 19 | ================================ */ 20 | 21 | class ContextStateService implements InjectableStore { 22 | storeKey = "ContextStateService"; 23 | data: string = ""; 24 | 25 | setData(val: string) { 26 | this.data = val; 27 | } 28 | } 29 | 30 | /* =============================== 31 | Routes 32 | ================================ */ 33 | 34 | class SafetyCheckRoute extends XerusRoute { 35 | method = Method.WS_MESSAGE; 36 | path = "/ws/safety/context"; 37 | services = [ContextStateService]; 38 | 39 | async handle(c: HTTPContext) { 40 | const socket = ws(c); 41 | const msg = String(socket.message); 42 | const svc = c.service(ContextStateService); 43 | 44 | if (msg.startsWith("SET:")) { 45 | const val = msg.split(":")[1] ?? ""; 46 | svc.setData(val); 47 | socket.send(`OK:SET:${val}`); 48 | return; 49 | } 50 | 51 | if (msg === "CHECK") { 52 | socket.send(`VALUE:${svc.data || "EMPTY"}`); 53 | } 54 | } 55 | } 56 | 57 | class MessageIsolationRoute extends XerusRoute { 58 | method = Method.WS_MESSAGE; 59 | path = "/ws/safety/isolation"; 60 | 61 | async handle(c: HTTPContext) { 62 | const socket = ws(c); 63 | socket.send(`ECHO:${socket.message}`); 64 | } 65 | } 66 | 67 | /* =============================== 68 | Test Suite 69 | ================================ */ 70 | 71 | describe("WS Safety", () => { 72 | let server: any; 73 | let port: number; 74 | 75 | beforeAll(async () => { 76 | const app = new Xerus(); 77 | app.mount(SafetyCheckRoute, MessageIsolationRoute); 78 | server = await app.listen(0); 79 | port = server.port; 80 | }); 81 | 82 | afterAll(() => { 83 | server?.stop?.(true); 84 | }); 85 | 86 | test("WS Safety: Service state should NOT leak between messages", async () => { 87 | const socket = new WebSocket(wsURL(port, "/ws/safety/context")); 88 | 89 | const result = await new Promise((resolve, reject) => { 90 | const logs: string[] = []; 91 | 92 | socket.onopen = () => { 93 | socket.send("SET:SECRET_DATA"); 94 | }; 95 | 96 | socket.onmessage = (event) => { 97 | const msg = String(event.data); 98 | logs.push(msg); 99 | 100 | if (msg.startsWith("OK:SET")) { 101 | socket.send("CHECK"); 102 | } else if (msg.startsWith("VALUE:")) { 103 | socket.close(); 104 | resolve(logs); 105 | } 106 | }; 107 | 108 | socket.onerror = reject; 109 | }); 110 | 111 | expect(result[0]).toBe("OK:SET:SECRET_DATA"); 112 | expect(result[1]).toBe("VALUE:EMPTY"); 113 | }); 114 | 115 | test("WS Safety: Message content should be isolated per message", async () => { 116 | const socket = new WebSocket(wsURL(port, "/ws/safety/isolation")); 117 | 118 | const messages = await new Promise((resolve) => { 119 | const logs: string[] = []; 120 | 121 | socket.onopen = () => { 122 | socket.send("A"); 123 | socket.send("BBB"); 124 | socket.send("A"); 125 | }; 126 | 127 | socket.onmessage = (event) => { 128 | logs.push(String(event.data)); 129 | if (logs.length === 3) { 130 | socket.close(); 131 | resolve(logs); 132 | } 133 | }; 134 | }); 135 | 136 | expect(messages[0]).toBe("ECHO:A"); 137 | expect(messages[1]).toBe("ECHO:BBB"); 138 | expect(messages[2]).toBe("ECHO:A"); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /tests/aad.websockets.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, beforeAll, afterAll } from "bun:test"; 2 | import { z } from "zod"; 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import { HTTPContext } from "../src/HTTPContext"; 7 | import type { XerusValidator } from "../src/XerusValidator"; 8 | import { CORSService } from "../src/CORSService"; 9 | import { RateLimitService } from "../src/RateLimitService"; 10 | import { json, setHeader } from "../src/std/Response"; 11 | import { parseBody } from "../src/std/Body"; 12 | import { BodyType } from "../src/BodyType"; 13 | import { SystemErr } from "../src/SystemErr"; 14 | import { SystemErrCode } from "../src/SystemErrCode"; 15 | 16 | describe("Validation & Security", () => { 17 | let server: any; 18 | let baseUrl: string; 19 | 20 | beforeAll(async () => { 21 | const app = new Xerus(); 22 | 23 | // --- Validators --- 24 | const loginSchema = z.object({ 25 | username: z.string(), 26 | password: z.string().min(6) 27 | }); 28 | 29 | class LoginValidator implements XerusValidator { 30 | async validate(c: HTTPContext) { 31 | const raw = await parseBody(c, BodyType.JSON); 32 | try { 33 | return await loginSchema.parseAsync(raw); 34 | } catch (e) { 35 | throw new SystemErr(SystemErrCode.VALIDATION_FAILED, "Invalid Login"); 36 | } 37 | } 38 | } 39 | 40 | // --- Routes --- 41 | class LoginRoute extends XerusRoute { 42 | method = Method.POST; 43 | path = "/login"; 44 | validators = [LoginValidator]; 45 | async handle(c: HTTPContext) { 46 | const data = c.validated(LoginValidator); 47 | json(c, { welcome: data.username }); 48 | } 49 | } 50 | 51 | class CorsRoute extends XerusRoute { 52 | method = Method.GET; 53 | path = "/cors"; 54 | services = [CORSService]; // Default CORS 55 | async handle(c: HTTPContext) { json(c, { ok: true }); } 56 | } 57 | 58 | class LimitRoute extends XerusRoute { 59 | method = Method.GET; 60 | path = "/limit"; 61 | // Limit 2 requests per minute 62 | services = [class extends RateLimitService { constructor() { super({ limit: 2, windowMs: 60000 }); } }]; 63 | async handle(c: HTTPContext) { json(c, { ok: true }); } 64 | } 65 | 66 | app.mount(LoginRoute, CorsRoute, LimitRoute); 67 | server = await app.listen(0); 68 | baseUrl = `http://localhost:${server.port}`; 69 | }); 70 | 71 | afterAll(() => { 72 | server.stop(true); 73 | }); 74 | 75 | test("Validation: Success", async () => { 76 | const res = await fetch(`${baseUrl}/login`, { 77 | method: "POST", 78 | headers: { "Content-Type": "application/json" }, 79 | body: JSON.stringify({ username: "admin", password: "password123" }) 80 | }); 81 | const data = await res.json(); 82 | expect(res.status).toBe(200); 83 | expect(data.welcome).toBe("admin"); 84 | }); 85 | 86 | test("Validation: Failure (Password too short)", async () => { 87 | const res = await fetch(`${baseUrl}/login`, { 88 | method: "POST", 89 | headers: { "Content-Type": "application/json" }, 90 | body: JSON.stringify({ username: "admin", password: "123" }) 91 | }); 92 | expect(res.status).toBe(400); // 400 is default for validation fail 93 | }); 94 | 95 | test("CORS: Sets headers", async () => { 96 | const res = await fetch(`${baseUrl}/cors`, { 97 | headers: { "Origin": "http://example.com" } 98 | }); 99 | expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); 100 | }); 101 | 102 | test("RateLimit: Enforces limit", async () => { 103 | const headers = { "X-Forwarded-For": "1.1.1.1" }; 104 | await fetch(`${baseUrl}/limit`, { headers }); // 1 105 | await fetch(`${baseUrl}/limit`, { headers }); // 2 106 | const res = await fetch(`${baseUrl}/limit`, { headers }); // 3 107 | expect(res.status).toBe(429); 108 | }); 109 | }); -------------------------------------------------------------------------------- /tests/aac_validation_security.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, beforeAll, afterAll } from "bun:test"; 2 | import { z } from "zod"; 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import { HTTPContext } from "../src/HTTPContext"; 7 | import type { XerusValidator } from "../src/XerusValidator"; 8 | import { CORSService } from "../src/CORSService"; 9 | import { RateLimitService } from "../src/RateLimitService"; 10 | import { json, setHeader } from "../src/std/Response"; 11 | import { parseBody } from "../src/std/Body"; 12 | import { BodyType } from "../src/BodyType"; 13 | import { SystemErr } from "../src/SystemErr"; 14 | import { SystemErrCode } from "../src/SystemErrCode"; 15 | 16 | describe("Validation & Security", () => { 17 | let server: any; 18 | let baseUrl: string; 19 | 20 | beforeAll(async () => { 21 | const app = new Xerus(); 22 | 23 | // --- Validators --- 24 | const loginSchema = z.object({ 25 | username: z.string(), 26 | password: z.string().min(6) 27 | }); 28 | 29 | class LoginValidator implements XerusValidator { 30 | async validate(c: HTTPContext) { 31 | const raw = await parseBody(c, BodyType.JSON); 32 | try { 33 | return await loginSchema.parseAsync(raw); 34 | } catch (e) { 35 | throw new SystemErr(SystemErrCode.VALIDATION_FAILED, "Invalid Login"); 36 | } 37 | } 38 | } 39 | 40 | // --- Routes --- 41 | class LoginRoute extends XerusRoute { 42 | method = Method.POST; 43 | path = "/login"; 44 | validators = [LoginValidator]; 45 | async handle(c: HTTPContext) { 46 | const data = c.validated(LoginValidator); 47 | json(c, { welcome: data.username }); 48 | } 49 | } 50 | 51 | class CorsRoute extends XerusRoute { 52 | method = Method.GET; 53 | path = "/cors"; 54 | services = [CORSService]; // Default CORS 55 | async handle(c: HTTPContext) { json(c, { ok: true }); } 56 | } 57 | 58 | class LimitRoute extends XerusRoute { 59 | method = Method.GET; 60 | path = "/limit"; 61 | // Limit 2 requests per minute 62 | services = [class extends RateLimitService { constructor() { super({ limit: 2, windowMs: 60000 }); } }]; 63 | async handle(c: HTTPContext) { json(c, { ok: true }); } 64 | } 65 | 66 | app.mount(LoginRoute, CorsRoute, LimitRoute); 67 | server = await app.listen(0); 68 | baseUrl = `http://localhost:${server.port}`; 69 | }); 70 | 71 | afterAll(() => { 72 | server.stop(true); 73 | }); 74 | 75 | test("Validation: Success", async () => { 76 | const res = await fetch(`${baseUrl}/login`, { 77 | method: "POST", 78 | headers: { "Content-Type": "application/json" }, 79 | body: JSON.stringify({ username: "admin", password: "password123" }) 80 | }); 81 | const data = await res.json(); 82 | expect(res.status).toBe(200); 83 | expect(data.welcome).toBe("admin"); 84 | }); 85 | 86 | test("Validation: Failure (Password too short)", async () => { 87 | const res = await fetch(`${baseUrl}/login`, { 88 | method: "POST", 89 | headers: { "Content-Type": "application/json" }, 90 | body: JSON.stringify({ username: "admin", password: "123" }) 91 | }); 92 | expect(res.status).toBe(400); // 400 is default for validation fail 93 | }); 94 | 95 | test("CORS: Sets headers", async () => { 96 | const res = await fetch(`${baseUrl}/cors`, { 97 | headers: { "Origin": "http://example.com" } 98 | }); 99 | expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); 100 | }); 101 | 102 | test("RateLimit: Enforces limit", async () => { 103 | const headers = { "X-Forwarded-For": "1.1.1.1" }; 104 | await fetch(`${baseUrl}/limit`, { headers }); // 1 105 | await fetch(`${baseUrl}/limit`, { headers }); // 2 106 | const res = await fetch(`${baseUrl}/limit`, { headers }); // 3 107 | expect(res.status).toBe(429); 108 | }); 109 | }); -------------------------------------------------------------------------------- /tests/aak_error_handling.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { Xerus } from "../src/Xerus"; 3 | import { XerusRoute } from "../src/XerusRoute"; 4 | import { Method } from "../src/Method"; 5 | import { HTTPContext } from "../src/HTTPContext"; 6 | import type { ServiceLifecycle } from "../src/RouteFields"; 7 | import { file, json, setStatus, text } from "../src/std/Response"; 8 | 9 | function makeURL(port: number, path: string) { 10 | return `http://127.0.0.1:${port}${path}`; 11 | } 12 | 13 | async function readMaybeError(res: Response) { 14 | const ct = (res.headers.get("content-type") ?? "").toLowerCase(); 15 | if (ct.includes("application/json")) return await res.json(); 16 | return await res.text(); 17 | } 18 | 19 | describe("Global error handling", () => { 20 | let server: any; 21 | let port: number; 22 | 23 | beforeAll(async () => { 24 | const app = new Xerus(); 25 | 26 | class ServiceErrorTrigger implements XerusService { 27 | async before(_c: HTTPContext) { 28 | throw new Error("Failure in Service"); 29 | } 30 | } 31 | 32 | class StandardErr extends XerusRoute { 33 | method = Method.GET; 34 | path = "/err/standard"; 35 | async handle(_c: HTTPContext) { 36 | throw new Error("Standard Route Failure"); 37 | } 38 | } 39 | 40 | class SvcErr extends XerusRoute { 41 | method = Method.GET; 42 | path = "/err/middleware"; 43 | services = [ServiceErrorTrigger]; 44 | async handle(c: HTTPContext) { 45 | text(c, "This won't be reached"); 46 | } 47 | } 48 | 49 | class MissingFile extends XerusRoute { 50 | method = Method.GET; 51 | path = "/err/file-missing"; 52 | async handle(c: HTTPContext) { 53 | return await file(c, "./non/existent/path/file.txt"); 54 | } 55 | } 56 | 57 | // CHANGED: Use Class-based error handler 58 | class GlobalErrorHandler extends XerusRoute { 59 | method = Method.GET; 60 | path = ""; 61 | async handle(c: HTTPContext) { 62 | const err = c.err; 63 | const detail = err instanceof Error ? err.message : String(err ?? "Unknown Error"); 64 | const msg = detail === "Failure in Service" 65 | ? "Failure in Middleware" 66 | : "Custom Global Handler"; 67 | 68 | setStatus(c, 500); 69 | json(c, { 70 | error: { 71 | code: "GLOBAL_ERROR", 72 | message: msg, 73 | detail, 74 | }, 75 | }); 76 | } 77 | } 78 | 79 | app.onErr(GlobalErrorHandler); 80 | app.mount(StandardErr, SvcErr, MissingFile); 81 | 82 | server = await app.listen(0); 83 | port = server.port; 84 | }); 85 | 86 | afterAll(() => { 87 | server?.stop?.(true); 88 | }); 89 | 90 | test("GET /err/standard should be caught by app.onErr", async () => { 91 | const res = await fetch(makeURL(port, "/err/standard")); 92 | const data = await res.json(); 93 | expect(res.status).toBe(500); 94 | expect(data.error.message).toBe("Custom Global Handler"); 95 | expect(data.error.detail).toBe("Standard Route Failure"); 96 | }); 97 | 98 | test("GET /err/middleware should be caught by app.onErr", async () => { 99 | const res = await fetch(makeURL(port, "/err/middleware")); 100 | const data = await res.json(); 101 | expect(res.status).toBe(500); 102 | expect(data.error.detail).toBe("Failure in Service"); 103 | }); 104 | 105 | test("Non-existent route should trigger 404 SystemErr", async () => { 106 | const res = await fetch(makeURL(port, "/err/does-not-exist")); 107 | const body = await readMaybeError(res); 108 | expect(res.status).toBe(404); 109 | if (typeof body === "string") { 110 | expect(body).toContain("is not registered"); 111 | } else { 112 | expect((body.error?.code ?? body.code) as any).toBeTruthy(); 113 | } 114 | }); 115 | 116 | test("Accessing missing file should trigger SystemErr (404)", async () => { 117 | const res = await fetch(makeURL(port, "/err/file-missing")); 118 | expect(res.status).toBe(404); 119 | }); 120 | }); -------------------------------------------------------------------------------- /tests/aaa_http_core.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, beforeAll, afterAll } from "bun:test"; 2 | import { Xerus } from "../src/Xerus"; 3 | import { XerusRoute } from "../src/XerusRoute"; 4 | import { Method } from "../src/Method"; 5 | import { HTTPContext } from "../src/HTTPContext"; 6 | import { BodyType } from "../src/BodyType"; 7 | import { json, text, setCookie, setStatus, setHeader, redirect } from "../src/std/Response"; 8 | import { parseBody, jsonBody, formBody, textBody } from "../src/std/Body"; 9 | import { reqCookie, query, param } from "../src/std/Request"; 10 | 11 | describe("HTTP Core Features", () => { 12 | let server: any; 13 | let baseUrl: string; 14 | 15 | beforeAll(async () => { 16 | const app = new Xerus(); 17 | 18 | // --- Routes Definition --- 19 | class Root extends XerusRoute { 20 | method = Method.GET; 21 | path = "/"; 22 | async handle(c: HTTPContext) { json(c, { message: "Hello, world!" }); } 23 | } 24 | 25 | class EchoQuery extends XerusRoute { 26 | method = Method.GET; 27 | path = "/echo-query"; 28 | async handle(c: HTTPContext) { 29 | json(c, { a: query(c, "a"), b: query(c, "b") }); 30 | } 31 | } 32 | 33 | class ParseJson extends XerusRoute { 34 | method = Method.POST; 35 | path = "/body/json"; 36 | async handle(c: HTTPContext) { 37 | const data = await jsonBody(c); 38 | json(c, { data }); 39 | } 40 | } 41 | 42 | class CookieSet extends XerusRoute { 43 | method = Method.GET; 44 | path = "/cookies/set"; 45 | async handle(c: HTTPContext) { 46 | setCookie(c, "theme", "dark", { path: "/", httpOnly: true }); 47 | json(c, { ok: true }); 48 | } 49 | } 50 | 51 | class CookieGet extends XerusRoute { 52 | method = Method.GET; 53 | path = "/cookies/get"; 54 | async handle(c: HTTPContext) { 55 | json(c, { theme: reqCookie(c, "theme") }); 56 | } 57 | } 58 | 59 | class DynamicRoute extends XerusRoute { 60 | method = Method.GET; 61 | path = "/users/:id"; 62 | async handle(c: HTTPContext) { 63 | json(c, { id: param(c, "id") }); 64 | } 65 | } 66 | 67 | // --- Mount & Listen --- 68 | app.mount(Root, EchoQuery, ParseJson, CookieSet, CookieGet, DynamicRoute); 69 | 70 | // Listen on port 0 (random available port) 71 | server = await app.listen(0); 72 | baseUrl = `http://localhost:${server.port}`; 73 | }); 74 | 75 | afterAll(() => { 76 | server.stop(true); 77 | }); 78 | 79 | // --- Tests --- 80 | 81 | 82 | test("GET / should return Hello, world!", async () => { 83 | const res = await fetch(`${baseUrl}/`); 84 | const data = await res.json(); 85 | expect(res.status).toBe(200); 86 | expect(data.message).toBe("Hello, world!"); 87 | }); 88 | 89 | test("Query Params Echo", async () => { 90 | const res = await fetch(`${baseUrl}/echo-query?a=1&b=2`); 91 | const data = await res.json(); 92 | expect(data.a).toBe("1"); 93 | expect(data.b).toBe("2"); 94 | }); 95 | 96 | test("Body Parsing: JSON", async () => { 97 | const payload = { foo: "bar" }; 98 | const res = await fetch(`${baseUrl}/body/json`, { 99 | method: "POST", 100 | headers: { "Content-Type": "application/json" }, 101 | body: JSON.stringify(payload) 102 | }); 103 | const data = await res.json(); 104 | expect(data.data).toEqual(payload); 105 | }); 106 | 107 | test("Cookies: Set and Get", async () => { 108 | // Test Set 109 | const resSet = await fetch(`${baseUrl}/cookies/set`); 110 | const setCookieHeader = resSet.headers.get("Set-Cookie") || ""; 111 | expect(setCookieHeader).toContain("theme=dark"); 112 | 113 | // Test Get 114 | const resGet = await fetch(`${baseUrl}/cookies/get`, { 115 | headers: { "Cookie": "theme=light" } 116 | }); 117 | const data = await resGet.json(); 118 | expect(data.theme).toBe("light"); 119 | }); 120 | 121 | test("Routing: Dynamic Params", async () => { 122 | const res = await fetch(`${baseUrl}/users/99`); 123 | const data = await res.json(); 124 | expect(data.id).toBe("99"); 125 | }); 126 | }); -------------------------------------------------------------------------------- /src/RateLimitService.ts: -------------------------------------------------------------------------------- 1 | import type { HTTPContext } from "./HTTPContext"; 2 | import type { ServiceLifecycle } from "./RouteFields"; 3 | import { errorJSON, setHeader } from "./std/Response"; 4 | import { clientIP } from "./std/Request"; 5 | 6 | export interface RateLimitConfig { 7 | /** 8 | * Max number of connections during windowMs. 9 | * Default: 100 10 | */ 11 | limit?: number; 12 | 13 | /** 14 | * Time frame for which requests are checked/remembered (in milliseconds). 15 | * Default: 60000 (1 minute) 16 | */ 17 | windowMs?: number; 18 | 19 | /** 20 | * Error message sent to client when limit is reached. 21 | */ 22 | message?: string; 23 | 24 | /** 25 | * HTTP status code. Default: 429 26 | */ 27 | statusCode?: number; 28 | 29 | /** 30 | * Function to generate a unique key for the client. 31 | * Default: clientIP(c) 32 | */ 33 | keyGenerator?: (c: HTTPContext) => string; 34 | 35 | /** 36 | * If true, adds X-RateLimit headers to the response. 37 | * Default: true 38 | */ 39 | standardHeaders?: boolean; 40 | } 41 | 42 | interface RateLimitState { 43 | count: number; 44 | resetTime: number; 45 | } 46 | 47 | export class RateLimitService implements ServiceLifecycle { 48 | // Shared state across all instances of the service 49 | private static store = new Map(); 50 | 51 | protected config: Required> & { 52 | keyGenerator: (c: HTTPContext) => string; 53 | }; 54 | 55 | /** 56 | * To create a custom limiter, extend this class and pass options to super(). 57 | * Example: 58 | * class LoginLimiter extends RateLimitService { 59 | * constructor() { super({ limit: 5, windowMs: 60 * 1000 }); } 60 | * } 61 | */ 62 | constructor(config?: RateLimitConfig) { 63 | this.config = { 64 | limit: config?.limit ?? 100, 65 | windowMs: config?.windowMs ?? 60000, 66 | message: config?.message ?? "Too many requests, please try again later.", 67 | statusCode: config?.statusCode ?? 429, 68 | standardHeaders: config?.standardHeaders ?? true, 69 | keyGenerator: config?.keyGenerator ?? ((c) => clientIP(c)), 70 | }; 71 | } 72 | 73 | /** 74 | * Garbage collection helper to prevent memory leaks in long-running processes. 75 | * (Ideally, run this on a generic interval, but here we run it lazily or could expose it). 76 | */ 77 | static prune() { 78 | const now = Date.now(); 79 | for (const [key, val] of this.store.entries()) { 80 | if (val.resetTime < now) { 81 | this.store.delete(key); 82 | } 83 | } 84 | } 85 | 86 | async before(c: HTTPContext): Promise { 87 | const now = Date.now(); 88 | 89 | // 1. Generate the unique key (e.g. IP address) 90 | // We prefix it with the limit/window to allow different rate limits for the same IP 91 | // on different routes if multiple RateLimitServices are used. 92 | const keyPrefix = `${this.config.limit}:${this.config.windowMs}:`; 93 | const clientKey = this.config.keyGenerator(c); 94 | const storageKey = keyPrefix + clientKey; 95 | 96 | // 2. Get or Initialize State 97 | let record = RateLimitService.store.get(storageKey); 98 | 99 | // 3. Reset if window expired 100 | if (!record || record.resetTime < now) { 101 | record = { 102 | count: 0, 103 | resetTime: now + this.config.windowMs, 104 | }; 105 | RateLimitService.store.set(storageKey, record); 106 | } 107 | 108 | // 4. Increment 109 | record.count++; 110 | 111 | // 5. Set Standard Headers (Draft-7) 112 | if (this.config.standardHeaders) { 113 | const remaining = Math.max(0, this.config.limit - record.count); 114 | const resetDate = new Date(record.resetTime).toUTCString(); 115 | 116 | setHeader(c, "X-RateLimit-Limit", String(this.config.limit)); 117 | setHeader(c, "X-RateLimit-Remaining", String(remaining)); 118 | setHeader(c, "X-RateLimit-Reset", resetDate); 119 | } 120 | 121 | // 6. Check Limit 122 | if (record.count > this.config.limit) { 123 | errorJSON(c, this.config.statusCode, "RATE_LIMITED", this.config.message); 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /tests/aaj_middlewares.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import type { HTTPContext } from "../src/HTTPContext"; 7 | import type { InjectableStore, ServiceLifecycle } from "../src/RouteFields"; 8 | import { json, setHeader, setStatus, text } from "../src/std/Response"; 9 | 10 | function makeURL(port: number, path: string) { 11 | return `http://127.0.0.1:${port}${path}`; 12 | } 13 | 14 | describe("Services + middleware lifecycle", () => { 15 | let server: any; 16 | let port: number; 17 | 18 | // self-contained constant (replaces import from ./5_middlewares) 19 | const treasureValue = "secretValue"; 20 | 21 | beforeAll(async () => { 22 | const app = new Xerus(); 23 | 24 | class ServiceOrderLogger implements XerusService { 25 | name: string = "Unknown"; 26 | 27 | async before(c: HTTPContext) { 28 | const existing = c.res.getHeader("X-Order") ?? ""; 29 | setHeader( 30 | c, 31 | "X-Order", 32 | existing ? `${existing}->${this.name}-In` : `${this.name}-In`, 33 | ); 34 | } 35 | 36 | async after(c: HTTPContext) { 37 | const afterVal = c.res.getHeader("X-Order") ?? ""; 38 | setHeader(c, "X-Order", `${afterVal}->${this.name}-Out`); 39 | } 40 | } 41 | 42 | class ServiceA extends ServiceOrderLogger { 43 | name = "A"; 44 | } 45 | class ServiceB extends ServiceOrderLogger { 46 | name = "B"; 47 | } 48 | 49 | class ServiceShortCircuit implements XerusService { 50 | async before(c: HTTPContext) { 51 | setStatus(c, 200); 52 | text(c, "Intercepted by Service"); 53 | } 54 | } 55 | 56 | class TreasureService implements InjectableStore, ServiceLifecycle { 57 | storeKey = "TreasureService"; 58 | value: string = ""; 59 | 60 | async before(_c: HTTPContext) { 61 | this.value = treasureValue; 62 | } 63 | } 64 | 65 | class OrderRoute extends XerusRoute { 66 | method = Method.GET; 67 | path = "/mw/order"; 68 | services = [ServiceA, ServiceB]; 69 | 70 | async handle(c: HTTPContext) { 71 | json(c, { message: "Handler reached" }); 72 | } 73 | } 74 | 75 | class ShortRoute extends XerusRoute { 76 | method = Method.GET; 77 | path = "/mw/short-circuit"; 78 | services = [ServiceShortCircuit]; 79 | 80 | async handle(c: HTTPContext) { 81 | text(c, "This should never be seen"); 82 | } 83 | } 84 | 85 | class StoreRoute extends XerusRoute { 86 | method = Method.GET; 87 | path = "/mw/store"; 88 | services = [TreasureService]; 89 | 90 | async handle(c: HTTPContext) { 91 | const svc = c.service(TreasureService); 92 | json(c, { storedValue: svc.value }); 93 | } 94 | } 95 | 96 | app.mount(OrderRoute, ShortRoute, StoreRoute); 97 | 98 | server = await app.listen(0); 99 | port = server.port; 100 | }); 101 | 102 | afterAll(() => { 103 | server?.stop?.(true); 104 | }); 105 | 106 | test("Order of execution should follow lifecycle (A-In -> B-In -> Handler -> B-Out -> A-Out)", async () => { 107 | const res = await fetch(makeURL(port, "/mw/order")); 108 | const orderHeader = res.headers.get("X-Order"); 109 | expect(res.status).toBe(200); 110 | 111 | // Note: header only contains service before/after, not handler. 112 | expect(orderHeader).toBe("A-In->B-In->B-Out->A-Out"); 113 | }); 114 | 115 | test("Short-circuiting in 'before' hook should prevent handler execution", async () => { 116 | const res = await fetch(makeURL(port, "/mw/short-circuit")); 117 | const body = await res.text(); 118 | expect(res.status).toBe(200); 119 | expect(body).toBe("Intercepted by Service"); 120 | }); 121 | 122 | test("setStore/getStore should persist data to the handler", async () => { 123 | const res = await fetch(makeURL(port, "/mw/store")); 124 | const data = await res.json(); 125 | expect(res.status).toBe(200); 126 | expect(data.storedValue).toBe(treasureValue); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /tests/abl_cookies_parsing_and_set_cookie_defaults.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { Xerus } from "../src/Xerus"; 3 | import { XerusRoute } from "../src/XerusRoute"; 4 | import { Method } from "../src/Method"; 5 | import type { HTTPContext } from "../src/HTTPContext"; 6 | import { json, setCookie, clearCookie } from "../src/std/Response"; 7 | import { reqCookie } from "../src/std/Request"; 8 | 9 | function makeURL(port: number, path: string) { 10 | return `http://127.0.0.1:${port}${path}`; 11 | } 12 | async function readJSON(res: Response) { 13 | return await res.json(); 14 | } 15 | 16 | describe("cookies: safe decode + default Set-Cookie attributes + clear semantics", () => { 17 | let server: any; 18 | let port: number; 19 | 20 | beforeAll(async () => { 21 | const app = new Xerus(); 22 | 23 | class CookieEchoRoute extends XerusRoute { 24 | method = Method.GET; 25 | path = "/cookie/echo"; 26 | async handle(c: HTTPContext) { 27 | const a = reqCookie(c, "a"); // decoded 28 | const bad = reqCookie(c, "bad"); // invalid percent encoding must not throw 29 | json(c, { a, bad }); 30 | } 31 | } 32 | 33 | class CookieSetDefaultsRoute extends XerusRoute { 34 | method = Method.GET; 35 | path = "/cookie/set-defaults"; 36 | async handle(c: HTTPContext) { 37 | // CookieJar default: Path=/, HttpOnly=true, SameSite=Lax 38 | setCookie(c, "sess", "hello world"); 39 | json(c, { ok: true }); 40 | } 41 | } 42 | 43 | class CookieClearRoute extends XerusRoute { 44 | method = Method.GET; 45 | path = "/cookie/clear"; 46 | async handle(c: HTTPContext) { 47 | clearCookie(c, "sess"); 48 | json(c, { ok: true }); 49 | } 50 | } 51 | 52 | app.mount(CookieEchoRoute, CookieSetDefaultsRoute, CookieClearRoute); 53 | 54 | server = await app.listen(0); 55 | port = server.port; 56 | }); 57 | 58 | afterAll(() => { 59 | server?.stop?.(true); 60 | }); 61 | 62 | test("Request cookie parsing safely decodes and tolerates invalid encoding", async () => { 63 | const res = await fetch(makeURL(port, "/cookie/echo"), { 64 | headers: { 65 | Cookie: "a=hello%20world; bad=%E0%A4%A", // invalid 66 | }, 67 | }); 68 | expect(res.status).toBe(200); 69 | const body = await readJSON(res); 70 | expect(body.a).toBe("hello world"); 71 | // invalid decode should fall back to raw string 72 | expect(typeof body.bad).toBe("string"); 73 | expect(body.bad.length).toBeGreaterThan(0); 74 | }); 75 | 76 | test("Set-Cookie uses default Path=/, HttpOnly, SameSite=Lax and encodes value", async () => { 77 | const res = await fetch(makeURL(port, "/cookie/set-defaults")); 78 | expect(res.status).toBe(200); 79 | 80 | const setCookieLines = res.headers.getSetCookie?.() ?? []; 81 | // Bun’s Headers may not support getSetCookie in older builds; fallback: 82 | const raw = res.headers.get("set-cookie"); 83 | const all = setCookieLines.length ? setCookieLines : raw ? [raw] : []; 84 | 85 | expect(all.length).toBeGreaterThan(0); 86 | const line = all[0]; 87 | 88 | // value should be URL-encoded (space -> %20) 89 | expect(line).toContain("sess=hello%20world"); 90 | expect(line).toContain("Path=/"); 91 | expect(line).toContain("HttpOnly"); 92 | expect(line).toContain("SameSite=Lax"); 93 | }); 94 | 95 | test("clearCookie sets Max-Age=0 and Expires=Date(0)", async () => { 96 | const res = await fetch(makeURL(port, "/cookie/clear")); 97 | expect(res.status).toBe(200); 98 | 99 | const setCookieLines = res.headers.getSetCookie?.() ?? []; 100 | const raw = res.headers.get("set-cookie"); 101 | const all = setCookieLines.length ? setCookieLines : raw ? [raw] : []; 102 | 103 | expect(all.length).toBeGreaterThan(0); 104 | const line = all[0]; 105 | 106 | expect(line).toContain("sess="); 107 | expect(line).toContain("Max-Age=0"); 108 | expect(line).toContain("Expires="); 109 | // the epoch date in GMT; we won't hard-match full string to avoid locale quirks, 110 | // but Date(0) always includes "1970" 111 | expect(line).toContain("1970"); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /tests/aag_static_files.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { resolve } from "path"; 3 | import { Xerus } from "../src/Xerus"; 4 | 5 | function makeURL(port: number, path: string) { 6 | return `http://127.0.0.1:${port}${path}`; 7 | } 8 | 9 | describe("Static files: embed + disk", () => { 10 | let server: any; 11 | let port: number; 12 | 13 | // Keep the test’s expected bytes/content in the same shape as assertions 14 | const expectedEmbedded = { 15 | "/index.html": { 16 | content: "

Home

", 17 | type: "text/html", 18 | }, 19 | "/styles/main.css": { 20 | content: "body { background: #000; }", 21 | type: "text/css", 22 | }, 23 | "/images/logo.png": { 24 | content: new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), 25 | type: "image/png", 26 | }, 27 | }; 28 | 29 | beforeAll(async () => { 30 | const app = new Xerus(); 31 | 32 | // app.embed expects a map; in your helper version logo.png was an array of numbers. 33 | // We'll build an embed map that preserves the same bytes but is compatible. 34 | const embedMap = { 35 | "/index.html": { 36 | content: expectedEmbedded["/index.html"].content, 37 | type: expectedEmbedded["/index.html"].type, 38 | }, 39 | "/styles/main.css": { 40 | content: expectedEmbedded["/styles/main.css"].content, 41 | type: expectedEmbedded["/styles/main.css"].type, 42 | }, 43 | "/images/logo.png": { 44 | // provide either Uint8Array or number[] depending on what embed supports; 45 | // Uint8Array is typically safe for binary. 46 | content: expectedEmbedded["/images/logo.png"].content, 47 | type: expectedEmbedded["/images/logo.png"].type, 48 | }, 49 | }; 50 | 51 | app.embed("/static-site", embedMap); 52 | 53 | const srcPath = resolve("./src"); 54 | app.static("/disk-src", srcPath); 55 | 56 | server = await app.listen(0); 57 | port = server.port; 58 | }); 59 | 60 | afterAll(() => { 61 | server?.stop?.(true); 62 | }); 63 | 64 | test("Embed: GET /static-site/index.html should return correct content", async () => { 65 | const res = await fetch(makeURL(port, "/static-site/index.html")); 66 | const text = await res.text(); 67 | expect(res.status).toBe(200); 68 | expect(res.headers.get("Content-Type")).toBe("text/html"); 69 | expect(text).toContain("

Home

"); 70 | }); 71 | 72 | test("Embed: GET /static-site/ should fallback to index.html", async () => { 73 | const res = await fetch(makeURL(port, "/static-site/")); 74 | const text = await res.text(); 75 | expect(res.status).toBe(200); 76 | expect(text).toContain("

Home

"); 77 | }); 78 | 79 | test("Embed: GET /static-site/styles/main.css should return CSS", async () => { 80 | const res = await fetch(makeURL(port, "/static-site/styles/main.css")); 81 | const text = await res.text(); 82 | expect(res.status).toBe(200); 83 | expect(res.headers.get("Content-Type")).toBe("text/css"); 84 | expect(text).toBe(expectedEmbedded["/styles/main.css"].content); 85 | }); 86 | 87 | test("Embed: GET /static-site/images/logo.png should return binary data", async () => { 88 | const res = await fetch(makeURL(port, "/static-site/images/logo.png")); 89 | expect(res.status).toBe(200); 90 | expect(res.headers.get("Content-Type")).toBe("image/png"); 91 | const buffer = await res.arrayBuffer(); 92 | const bytes = new Uint8Array(buffer); 93 | expect(bytes).toEqual(expectedEmbedded["/images/logo.png"].content); 94 | }); 95 | 96 | test("Static (Disk): GET /disk-src/Xerus.ts should return file content", async () => { 97 | const res = await fetch(makeURL(port, "/disk-src/Xerus.ts")); 98 | const text = await res.text(); 99 | expect(res.status).toBe(200); 100 | expect(text).toContain("export class Xerus"); 101 | }); 102 | 103 | test("Static (Disk): Traversal attempt should fail", async () => { 104 | const res = await fetch(makeURL(port, "/disk-src/../package.json")); 105 | expect(res.status).toBe(404); 106 | }); 107 | 108 | test("Static (Disk): Non-existent file should 404", async () => { 109 | const res = await fetch(makeURL(port, "/disk-src/does-not-exist.ts")); 110 | expect(res.status).toBe(404); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /tests/aar_flexible_validation.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { z } from "zod"; 3 | 4 | import { Xerus } from "../src/Xerus"; 5 | import { XerusRoute } from "../src/XerusRoute"; 6 | import { Method } from "../src/Method"; 7 | import { HTTPContext } from "../src/HTTPContext"; 8 | import { SystemErr } from "../src/SystemErr"; 9 | import { SystemErrCode } from "../src/SystemErrCode"; 10 | import type { XerusValidator } from "../src/XerusValidator"; 11 | import { header, param, query } from "../src/std/Request"; 12 | import { json } from "../src/std/Response"; 13 | 14 | function makeURL(port: number, path: string) { 15 | return `http://127.0.0.1:${port}${path}`; 16 | } 17 | 18 | // --- Validators 19 | 20 | class HeaderValidator implements XerusValidator { 21 | async validate(c: HTTPContext) { 22 | const val = header(c, "X-Secret") ?? ""; 23 | if (val !== "xerus-power") { 24 | throw new SystemErr(SystemErrCode.VALIDATION_FAILED, "Invalid Secret"); 25 | } 26 | return { val }; 27 | } 28 | } 29 | 30 | class IdParamValidator implements XerusValidator { 31 | async validate(c: HTTPContext) { 32 | const id = Number(param(c, "id")); 33 | z.number().int().parse(id); 34 | return { id }; 35 | } 36 | } 37 | 38 | class PageQueryValidator implements XerusValidator { 39 | async validate(c: HTTPContext) { 40 | const page = Number(query(c, "page")); 41 | z.number().min(1).parse(page); 42 | return { page }; 43 | } 44 | } 45 | 46 | // --- Routes 47 | 48 | class HeaderRoute extends XerusRoute { 49 | method = Method.GET; 50 | path = "/flex/header"; 51 | validators = [HeaderValidator]; 52 | 53 | async handle(c: HTTPContext) { 54 | c.validated(HeaderValidator); 55 | json(c, { status: "ok" }); 56 | } 57 | } 58 | 59 | class ParamRoute extends XerusRoute { 60 | method = Method.GET; 61 | path = "/flex/param/:id"; 62 | validators = [IdParamValidator]; 63 | 64 | async handle(c: HTTPContext) { 65 | const { id } = c.validated(IdParamValidator); 66 | json(c, { id }); 67 | } 68 | } 69 | 70 | class QueryRoute extends XerusRoute { 71 | method = Method.GET; 72 | path = "/flex/query"; 73 | validators = [PageQueryValidator]; 74 | 75 | async handle(c: HTTPContext) { 76 | const { page } = c.validated(PageQueryValidator); 77 | json(c, { page }); 78 | } 79 | } 80 | 81 | describe("Flexible validation (header / param / query)", () => { 82 | let server: any; 83 | let port: number; 84 | 85 | beforeAll(async () => { 86 | const app = new Xerus(); 87 | app.mount(HeaderRoute, ParamRoute, QueryRoute); 88 | 89 | server = await app.listen(0); 90 | port = server.port; 91 | }); 92 | 93 | afterAll(() => { 94 | server?.stop?.(true); 95 | }); 96 | 97 | test("Flexible: HEADER validation should pass with correct key", async () => { 98 | const res = await fetch(makeURL(port, "/flex/header"), { 99 | headers: { "X-Secret": "xerus-power" }, 100 | }); 101 | const data = await res.json(); 102 | expect(res.status).toBe(200); 103 | expect(data.status).toBe("ok"); 104 | }); 105 | 106 | test("Flexible: HEADER validation should fail with wrong key", async () => { 107 | const res = await fetch(makeURL(port, "/flex/header"), { 108 | headers: { "X-Secret": "wrong" }, 109 | }); 110 | expect(res.status).toBe(400); 111 | }); 112 | 113 | test("Flexible: PARAM validation should parse and validate numeric ID", async () => { 114 | const res = await fetch(makeURL(port, "/flex/param/123")); 115 | const data = await res.json(); 116 | expect(res.status).toBe(200); 117 | expect(data.id).toBe(123); 118 | }); 119 | 120 | test("Flexible: PARAM validation should fail non-numeric ID", async () => { 121 | const res = await fetch(makeURL(port, "/flex/param/abc")); 122 | expect(res.status).toBe(400); 123 | }); 124 | 125 | test("Flexible: QUERY key validation should pass valid number", async () => { 126 | const res = await fetch(makeURL(port, "/flex/query?page=5")); 127 | const data = await res.json(); 128 | expect(res.status).toBe(200); 129 | expect(data.page).toBe(5); 130 | }); 131 | 132 | test("Flexible: QUERY key validation should fail invalid number", async () => { 133 | const res = await fetch(makeURL(port, "/flex/query?page=0")); 134 | expect(res.status).toBe(400); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /tests/abq_class_system_handlers.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { Xerus } from "../src/Xerus"; 3 | import { XerusRoute } from "../src/XerusRoute"; 4 | import { Method } from "../src/Method"; 5 | import type { HTTPContext } from "../src/HTTPContext"; 6 | import type { ServiceLifecycle } from "../src/RouteFields"; 7 | import type { XerusValidator } from "../src/XerusValidator"; 8 | import { json, setHeader } from "../src/std/Response"; 9 | import { query } from "../src/std/Request"; 10 | 11 | function makeURL(port: number, path: string) { 12 | return `http://127.0.0.1:${port}${path}`; 13 | } 14 | 15 | describe("Class-based System Handlers (onNotFound / onErr)", () => { 16 | let server: any; 17 | let port: number; 18 | 19 | beforeAll(async () => { 20 | const app = new Xerus(); 21 | 22 | // 1. Define a Service to prove Dependency Injection works in system routes 23 | class SystemAuditService implements XerusService { 24 | async before(c: HTTPContext) { 25 | setHeader(c, "X-System-Audit", "Logged"); 26 | } 27 | } 28 | 29 | // 2. Define a Validator to prove Validation works in system routes 30 | class QueryTagValidator implements XerusValidator<{ tag: string }> { 31 | async validate(c: HTTPContext) { 32 | const tag = query(c, "tag") || "none"; 33 | return { tag }; 34 | } 35 | } 36 | 37 | // 3. Define the Class-based Not Found Handler 38 | class CustomNotFoundHandler extends XerusRoute { 39 | // Method/Path are ignored by system handlers but required by abstract class 40 | method = Method.GET; 41 | path = ""; 42 | 43 | // Inject Service and Validator 44 | services = [SystemAuditService]; 45 | validators = [QueryTagValidator]; 46 | 47 | async handle(c: HTTPContext) { 48 | const { tag } = c.validated(QueryTagValidator); 49 | json(c, { 50 | error: "Resource Not Found", 51 | tag, 52 | isClass: true 53 | }, 404); 54 | } 55 | } 56 | 57 | // 4. Define the Class-based Error Handler 58 | class CustomErrorHandler extends XerusRoute { 59 | method = Method.GET; 60 | path = ""; 61 | 62 | services = [SystemAuditService]; 63 | 64 | async handle(c: HTTPContext) { 65 | // Verify we can access the error that was thrown via c.err 66 | const errorMsg = c.err instanceof Error ? c.err.message : String(c.err); 67 | 68 | json(c, { 69 | error: "Internal Error", 70 | details: errorMsg, 71 | handledByClass: true 72 | }, 500); 73 | } 74 | } 75 | 76 | // 5. Route that throws an error to trigger onErr 77 | class ThrowingRoute extends XerusRoute { 78 | method = Method.GET; 79 | path = "/trigger-error"; 80 | async handle(_c: HTTPContext) { 81 | throw new Error("Intentional Crash"); 82 | } 83 | } 84 | 85 | // Mount the system handlers using the new Class API 86 | app.onNotFound(CustomNotFoundHandler); 87 | app.onErr(CustomErrorHandler); 88 | 89 | app.mount(ThrowingRoute); 90 | 91 | server = await app.listen(0); 92 | port = server.port; 93 | }); 94 | 95 | afterAll(() => { 96 | server?.stop?.(true); 97 | }); 98 | 99 | test("onNotFound (Class): Should execute handle(), run Services, and run Validators", async () => { 100 | // Request a non-existent route with a query param 101 | const res = await fetch(makeURL(port, "/does-not-exist?tag=testing")); 102 | const data = await res.json(); 103 | 104 | expect(res.status).toBe(404); 105 | 106 | // Check Handler Logic 107 | expect(data.error).toBe("Resource Not Found"); 108 | expect(data.isClass).toBe(true); 109 | 110 | // Check Validator Logic 111 | expect(data.tag).toBe("testing"); 112 | 113 | // Check Service Logic (Middleware hook) 114 | expect(res.headers.get("X-System-Audit")).toBe("Logged"); 115 | }); 116 | 117 | test("onErr (Class): Should capture c.err, run Services, and return custom response", async () => { 118 | // Request the route that throws 119 | const res = await fetch(makeURL(port, "/trigger-error")); 120 | const data = await res.json(); 121 | 122 | expect(res.status).toBe(500); 123 | 124 | // Check Error capturing 125 | expect(data.error).toBe("Internal Error"); 126 | expect(data.details).toBe("Intentional Crash"); 127 | expect(data.handledByClass).toBe(true); 128 | 129 | // Check Service Logic (Middleware hook) within error handler 130 | expect(res.headers.get("X-System-Audit")).toBe("Logged"); 131 | }); 132 | }); -------------------------------------------------------------------------------- /tests/aai_cookies.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import { HTTPContext } from "../src/HTTPContext"; 7 | import { reqCookie } from "../src/std/Request"; 8 | import { setCookie, clearCookie, json } from "../src/std/Response"; 9 | 10 | function makeURL(port: number, path: string) { 11 | return `http://127.0.0.1:${port}${path}`; 12 | } 13 | 14 | describe("Cookies", () => { 15 | let server: any; 16 | let port: number; 17 | 18 | beforeAll(async () => { 19 | const app = new Xerus(); 20 | 21 | class SetCookieRoute extends XerusRoute { 22 | method = Method.GET; 23 | path = "/cookies/set"; 24 | async handle(c: HTTPContext) { 25 | setCookie(c, "theme", "dark", { path: "/", httpOnly: true }); 26 | json(c, { message: "Cookie set" }); 27 | } 28 | } 29 | 30 | class SetComplexCookie extends XerusRoute { 31 | method = Method.GET; 32 | path = "/cookies/set-complex"; 33 | async handle(c: HTTPContext) { 34 | setCookie(c, "session_id", "12345", { 35 | httpOnly: true, 36 | secure: true, 37 | sameSite: "Strict", 38 | maxAge: 3600, 39 | }); 40 | setCookie(c, "preferences", "compact", { path: "/admin" }); 41 | json(c, { message: "Complex cookies set" }); 42 | } 43 | } 44 | 45 | class GetCookie extends XerusRoute { 46 | method = Method.GET; 47 | path = "/cookies/get"; 48 | async handle(c: HTTPContext) { 49 | const theme = reqCookie(c, "theme"); 50 | json(c, { theme }); 51 | } 52 | } 53 | 54 | class ClearCookie extends XerusRoute { 55 | method = Method.GET; 56 | path = "/cookies/clear"; 57 | async handle(c: HTTPContext) { 58 | clearCookie(c, "theme"); 59 | json(c, { message: "Cookie cleared" }); 60 | } 61 | } 62 | 63 | app.mount( 64 | SetCookieRoute, 65 | SetComplexCookie, 66 | GetCookie, 67 | ClearCookie, 68 | ); 69 | 70 | server = await app.listen(0); 71 | port = server.port; 72 | }); 73 | 74 | afterAll(() => { 75 | server?.stop?.(true); 76 | }); 77 | 78 | test("GET /cookies/set should return Set-Cookie header", async () => { 79 | const res = await fetch(makeURL(port, "/cookies/set")); 80 | const setCookieHeader = res.headers.get("Set-Cookie"); 81 | expect(res.status).toBe(200); 82 | expect(setCookieHeader).toContain("theme=dark"); 83 | expect(setCookieHeader).toContain("HttpOnly"); 84 | expect(setCookieHeader).toContain("Path=/"); 85 | }); 86 | 87 | test("GET /cookies/set-complex should set multiple distinct headers", async () => { 88 | const res = await fetch(makeURL(port, "/cookies/set-complex")); 89 | const cookies = res.headers.getSetCookie(); 90 | 91 | expect(cookies.length).toBe(2); 92 | 93 | expect(cookies[0]).toContain("session_id=12345"); 94 | expect(cookies[0]).toContain("Secure"); 95 | expect(cookies[0]).toContain("SameSite=Strict"); 96 | expect(cookies[0]).toContain("Max-Age=3600"); 97 | 98 | expect(cookies[1]).toContain("preferences=compact"); 99 | expect(cookies[1]).toContain("Path=/admin"); 100 | }); 101 | 102 | test("GET /cookies/get should parse incoming Cookie header", async () => { 103 | const res = await fetch(makeURL(port, "/cookies/get"), { 104 | headers: { 105 | Cookie: "theme=light; other=value", 106 | }, 107 | }); 108 | const data = await res.json(); 109 | expect(res.status).toBe(200); 110 | expect(data.theme).toBe("light"); 111 | }); 112 | 113 | test("GET /cookies/get should return undefined for missing cookie", async () => { 114 | const res = await fetch(makeURL(port, "/cookies/get")); 115 | const data = await res.json(); 116 | expect(data.theme).toBeUndefined(); 117 | }); 118 | 119 | test("GET /cookies/clear should set expiration in the past", async () => { 120 | const res = await fetch(makeURL(port, "/cookies/clear")); 121 | const setCookieHeader = res.headers.get("Set-Cookie"); 122 | expect(setCookieHeader).toContain("theme="); 123 | expect(setCookieHeader).toContain("Max-Age=0"); 124 | expect(setCookieHeader).toContain( 125 | "Expires=Thu, 01 Jan 1970 00:00:00 GMT", 126 | ); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /tests/abm_ws_per_message_scope_isolation.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { Xerus } from "../src/Xerus"; 3 | import { XerusRoute } from "../src/XerusRoute"; 4 | import { Method } from "../src/Method"; 5 | import type { HTTPContext } from "../src/HTTPContext"; 6 | import type { XerusValidator } from "../src/XerusValidator"; 7 | import { ws as wsCtx } from "../src/std/Request"; 8 | 9 | function wsURL(port: number, path: string) { 10 | return `ws://127.0.0.1:${port}${path}`; 11 | } 12 | 13 | describe("websocket: resetForWSEvent resets scope (validators/services do not leak across messages)", () => { 14 | let server: any; 15 | let port: number; 16 | 17 | beforeAll(async () => { 18 | const app = new Xerus(); 19 | 20 | let validatorRuns = 0; 21 | let serviceInits = 0; 22 | 23 | class MsgValidator implements XerusValidator<{ msg: string }> { 24 | async validate(c: HTTPContext) { 25 | validatorRuns++; 26 | return { msg: String(c._wsMessage ?? "") }; 27 | } 28 | } 29 | 30 | class PerMessageService { 31 | id: string = crypto.randomUUID(); 32 | async init(_c: HTTPContext) { 33 | serviceInits++; 34 | } 35 | } 36 | 37 | class WSOpen extends XerusRoute { 38 | method = Method.WS_OPEN; 39 | path = "/ws/scope"; 40 | async handle(c: HTTPContext) { 41 | wsCtx(c).send("open"); 42 | } 43 | } 44 | 45 | class WSMessage extends XerusRoute { 46 | method = Method.WS_MESSAGE; 47 | path = "/ws/scope"; 48 | validators = [MsgValidator]; 49 | services = [PerMessageService]; 50 | 51 | async handle(c: HTTPContext) { 52 | const v = c.validated(MsgValidator); 53 | const svc = c.service(PerMessageService); 54 | 55 | // If scope is leaking, service might be reused across messages, or validator cached. 56 | wsCtx(c).send( 57 | JSON.stringify({ 58 | msg: v.msg, 59 | serviceId: svc.id, 60 | validatorRuns, 61 | serviceInits, 62 | }), 63 | ); 64 | } 65 | } 66 | 67 | class WSClose extends XerusRoute { 68 | method = Method.WS_CLOSE; 69 | path = "/ws/scope"; 70 | async handle(_c: HTTPContext) {} 71 | } 72 | 73 | app.mount(WSOpen, WSMessage, WSClose); 74 | 75 | server = await app.listen(0); 76 | port = server.port; 77 | }); 78 | 79 | afterAll(() => { 80 | server?.stop?.(true); 81 | }); 82 | 83 | test("two messages get two separate services; validator runs per message", async () => { 84 | const url = wsURL(port, "/ws/scope"); 85 | const ws = new WebSocket(url); 86 | 87 | const received: any[] = []; 88 | const waitFor = (n: number) => 89 | new Promise((resolve, reject) => { 90 | const t = setTimeout(() => reject(new Error("timeout waiting for ws msgs")), 2500); 91 | const check = () => { 92 | if (received.length >= n) { 93 | clearTimeout(t); 94 | resolve(); 95 | } else { 96 | setTimeout(check, 5); 97 | } 98 | }; 99 | check(); 100 | }); 101 | 102 | ws.addEventListener("message", (ev) => { 103 | const text = String(ev.data); 104 | if (text === "open") return; 105 | try { 106 | received.push(JSON.parse(text)); 107 | } catch { 108 | received.push({ raw: text }); 109 | } 110 | }); 111 | 112 | await new Promise((resolve, reject) => { 113 | const t = setTimeout(() => reject(new Error("ws open timeout")), 2500); 114 | ws.addEventListener("open", () => { 115 | clearTimeout(t); 116 | resolve(); 117 | }); 118 | ws.addEventListener("error", () => reject(new Error("ws error"))); 119 | }); 120 | 121 | ws.send("one"); 122 | ws.send("two"); 123 | 124 | await waitFor(2); 125 | 126 | ws.close(); 127 | 128 | const r1 = received[0]; 129 | const r2 = received[1]; 130 | 131 | expect(r1.msg).toBe("one"); 132 | expect(r2.msg).toBe("two"); 133 | 134 | // service should be per-message (because resetScope clears services between WS events) 135 | expect(r1.serviceId).not.toBe(r2.serviceId); 136 | 137 | // validator should run once per message 138 | expect(r2.validatorRuns).toBeGreaterThanOrEqual(2); 139 | expect(r2.serviceInits).toBeGreaterThanOrEqual(2); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /tests/aaq_precedence.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import { HTTPContext } from "../src/HTTPContext"; 7 | import { json } from "../src/std/Response"; 8 | import { param } from "../src/std/Request"; 9 | 10 | function makeURL(port: number, path: string) { 11 | return `http://127.0.0.1:${port}${path}`; 12 | } 13 | 14 | describe("Routing precedence", () => { 15 | let server: any; 16 | let port: number; 17 | 18 | beforeAll(async () => { 19 | const app = new Xerus(); 20 | 21 | class ConflictStatic extends XerusRoute { 22 | method = Method.GET; 23 | path = "/conflict/static"; 24 | async handle(c: HTTPContext) { 25 | json(c, { type: "exact" }); 26 | } 27 | } 28 | 29 | class ConflictParam extends XerusRoute { 30 | method = Method.GET; 31 | path = "/conflict/:id"; 32 | async handle(c: HTTPContext) { 33 | json(c, { type: "param", val: param(c, "id") }); 34 | } 35 | } 36 | 37 | class FallbackExact extends XerusRoute { 38 | method = Method.GET; 39 | path = "/fallback/folder/valid"; 40 | async handle(c: HTTPContext) { 41 | json(c, { type: "deep-exact" }); 42 | } 43 | } 44 | 45 | class FallbackParam extends XerusRoute { 46 | method = Method.GET; 47 | path = "/fallback/:id/valid"; 48 | async handle(c: HTTPContext) { 49 | json(c, { type: "deep-param", id: param(c, "id") }); 50 | } 51 | } 52 | 53 | class WildA extends XerusRoute { 54 | method = Method.GET; 55 | path = "/wild/a"; 56 | async handle(c: HTTPContext) { 57 | json(c, { type: "exact-a" }); 58 | } 59 | } 60 | 61 | class WildAny extends XerusRoute { 62 | method = Method.GET; 63 | path = "/wild/*"; 64 | async handle(c: HTTPContext) { 65 | json(c, { type: "wildcard" }); 66 | } 67 | } 68 | 69 | class MixedParam extends XerusRoute { 70 | method = Method.GET; 71 | path = "/mixed/:id"; 72 | async handle(c: HTTPContext) { 73 | json(c, { type: "param-mixed", id: param(c, "id") }); 74 | } 75 | } 76 | 77 | app.mount( 78 | ConflictStatic, 79 | ConflictParam, 80 | FallbackExact, 81 | FallbackParam, 82 | WildA, 83 | WildAny, 84 | MixedParam, 85 | ); 86 | 87 | server = await app.listen(0); 88 | port = server.port; 89 | }); 90 | 91 | afterAll(() => { 92 | server?.stop?.(true); 93 | }); 94 | 95 | test("Precedence: /conflict/static should hit exact match", async () => { 96 | const res = await fetch(makeURL(port, "/conflict/static")); 97 | const data = await res.json(); 98 | expect(data.type).toBe("exact"); 99 | }); 100 | 101 | test("Precedence: /conflict/dynamic should hit param match", async () => { 102 | const res = await fetch(makeURL(port, "/conflict/dynamic")); 103 | const data = await res.json(); 104 | expect(data.type).toBe("param"); 105 | expect(data.val).toBe("dynamic"); 106 | }); 107 | 108 | test("Precedence: /fallback/folder/valid should match exact path", async () => { 109 | const res = await fetch(makeURL(port, "/fallback/folder/valid")); 110 | const data = await res.json(); 111 | expect(data.type).toBe("deep-exact"); 112 | }); 113 | 114 | test("Precedence: /fallback/other/valid should match param path", async () => { 115 | const res = await fetch(makeURL(port, "/fallback/other/valid")); 116 | const data = await res.json(); 117 | expect(data.type).toBe("deep-param"); 118 | expect(data.id).toBe("other"); 119 | }); 120 | 121 | test("Precedence: Wildcard should capture non-matching paths", async () => { 122 | const res = await fetch(makeURL(port, "/wild/anything-else")); 123 | const data = await res.json(); 124 | expect(data.type).toBe("wildcard"); 125 | }); 126 | 127 | test("Precedence: Exact should beat wildcard", async () => { 128 | const res = await fetch(makeURL(port, "/wild/a")); 129 | const data = await res.json(); 130 | expect(data.type).toBe("exact-a"); 131 | }); 132 | 133 | test("Precedence: Param should handle fallback if exact not found", async () => { 134 | const res = await fetch(makeURL(port, "/mixed/static")); 135 | const data = await res.json(); 136 | expect(data.type).toBe("param-mixed"); 137 | expect(data.id).toBe("static"); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /tests/aaf_route_grouping.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { BodyType } from "../src/BodyType"; 3 | import type { HTTPContext } from "../src/HTTPContext"; 4 | import { Method } from "../src/Method"; 5 | import type { ServiceLifecycle } from "../src/RouteFields"; 6 | import { json, setHeader, text } from "../src/std/Response"; 7 | import { SystemErr } from "../src/SystemErr"; 8 | import { SystemErrCode } from "../src/SystemErrCode"; 9 | import type { XerusValidator } from "../src/XerusValidator"; 10 | import { Xerus } from "../src/Xerus"; 11 | import { XerusRoute } from "../src/XerusRoute"; 12 | import { parseBody } from "../src/std/Body"; 13 | 14 | 15 | 16 | function makeURL(port: number, path: string) { 17 | return `http://127.0.0.1:${port}${path}`; 18 | } 19 | 20 | describe("Route grouping: prefix + middleware/service", () => { 21 | let server: any; 22 | let port: number; 23 | 24 | beforeAll(async () => { 25 | const app = new Xerus(); 26 | 27 | // --- services / validators used by routes (defined inline to keep test self-contained) 28 | 29 | class GroupHeaderService implements XerusService { 30 | async before(c: HTTPContext) { 31 | setHeader(c, "X-Group-Auth", "passed"); 32 | } 33 | } 34 | 35 | class AnyJsonBody implements XerusValidator { 36 | async validate(c: HTTPContext) { 37 | const data = parseBody(c, BodyType.JSON); 38 | if (!data || typeof data !== "object" || Array.isArray(data)) { 39 | throw new SystemErr( 40 | SystemErrCode.VALIDATION_FAILED, 41 | "Expected JSON object body", 42 | ); 43 | } 44 | return data; 45 | } 46 | } 47 | 48 | // --- routes 49 | 50 | class ApiV1 extends XerusRoute { 51 | method = Method.GET; 52 | path = "/api/v1"; 53 | async handle(c: HTTPContext) { 54 | json(c, { version: "v1" }); 55 | } 56 | } 57 | 58 | class ApiEcho extends XerusRoute { 59 | method = Method.POST; 60 | path = "/api/echo"; 61 | validators = [AnyJsonBody]; 62 | 63 | async handle(c: HTTPContext) { 64 | const received = c.validated(AnyJsonBody); 65 | json(c, { received }); 66 | } 67 | } 68 | 69 | class AdminDashboard extends XerusRoute { 70 | method = Method.GET; 71 | path = "/admin/dashboard"; 72 | services = [GroupHeaderService]; 73 | 74 | async handle(c: HTTPContext) { 75 | text(c, "Welcome to the Dashboard"); 76 | } 77 | } 78 | 79 | class AdminSettings extends XerusRoute { 80 | method = Method.DELETE; 81 | path = "/admin/settings"; 82 | services = [GroupHeaderService]; 83 | 84 | async handle(c: HTTPContext) { 85 | json(c, { deleted: true }); 86 | } 87 | } 88 | 89 | app.mount(ApiV1, ApiEcho, AdminDashboard, AdminSettings); 90 | 91 | server = await app.listen(0); 92 | port = server.port; 93 | }); 94 | 95 | afterAll(() => { 96 | server?.stop?.(true); 97 | }); 98 | 99 | test("Group Prefix: GET /api/v1 should return version", async () => { 100 | const res = await fetch(makeURL(port, "/api/v1")); 101 | const data = await res.json(); 102 | expect(res.status).toBe(200); 103 | expect(data.version).toBe("v1"); 104 | }); 105 | 106 | test("Group Prefix: POST /api/echo should parse body correctly", async () => { 107 | const payload = { foo: "bar" }; 108 | const res = await fetch(makeURL(port, "/api/echo"), { 109 | method: "POST", 110 | headers: { "Content-Type": "application/json" }, 111 | body: JSON.stringify(payload), 112 | }); 113 | const data = await res.json(); 114 | expect(res.status).toBe(200); 115 | expect(data.received.foo).toBe("bar"); 116 | }); 117 | 118 | test("Group Middleware: GET /admin/dashboard should have group header", async () => { 119 | const res = await fetch(makeURL(port, "/admin/dashboard")); 120 | const body = await res.text(); 121 | expect(res.status).toBe(200); 122 | expect(body).toBe("Welcome to the Dashboard"); 123 | expect(res.headers.get("X-Group-Auth")).toBe("passed"); 124 | }); 125 | 126 | test("Group Method: DELETE /admin/settings should work in group", async () => { 127 | const res = await fetch(makeURL(port, "/admin/settings"), { 128 | method: "DELETE", 129 | }); 130 | const data = await res.json(); 131 | expect(res.status).toBe(200); 132 | expect(data.deleted).toBe(true); 133 | expect(res.headers.get("X-Group-Auth")).toBe("passed"); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /tests/abh_validator_return_and_freeze.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { Xerus } from "../src/Xerus"; 3 | import { XerusRoute } from "../src/XerusRoute"; 4 | import { Method } from "../src/Method"; 5 | import type { HTTPContext } from "../src/HTTPContext"; 6 | import type { XerusValidator } from "../src/XerusValidator"; 7 | import { json } from "../src/std/Response"; 8 | 9 | function makeURL(port: number, path: string) { 10 | return `http://127.0.0.1:${port}${path}`; 11 | } 12 | 13 | async function readJSON(res: Response) { 14 | const ct = (res.headers.get("content-type") ?? "").toLowerCase(); 15 | if (!ct.includes("application/json")) { 16 | return { _nonJson: await res.text() }; 17 | } 18 | return await res.json(); 19 | } 20 | 21 | describe("validators: must return value + deepFreeze behavior", () => { 22 | let server: any; 23 | let port: number; 24 | 25 | beforeAll(async () => { 26 | const app = new Xerus(); 27 | 28 | class UndefinedValidator implements XerusValidator { 29 | async validate(_c: HTTPContext) { 30 | // INTENTIONALLY returns undefined 31 | return undefined; 32 | } 33 | } 34 | 35 | class FreezeValidator implements XerusValidator { 36 | async validate(_c: HTTPContext) { 37 | return { 38 | a: 1, 39 | nested: { b: 2 }, 40 | arr: [{ x: 1 }, { x: 2 }], 41 | }; 42 | } 43 | } 44 | 45 | class ValidatorReturnRoute extends XerusRoute { 46 | method = Method.GET; 47 | path = "/v/undefined"; 48 | validators = [UndefinedValidator]; 49 | async handle(_c: HTTPContext) { 50 | // should never reach: validator should throw first 51 | throw new Error("should-not-reach"); 52 | } 53 | } 54 | 55 | class ValidatorFreezeRoute extends XerusRoute { 56 | method = Method.GET; 57 | path = "/v/freeze"; 58 | validators = [FreezeValidator]; 59 | async handle(c: HTTPContext) { 60 | const v = c.validated(FreezeValidator); 61 | 62 | // prove frozen: Object.isFrozen on plain objects/arrays should be true 63 | const frozen = { 64 | root: Object.isFrozen(v as any), 65 | nested: Object.isFrozen((v as any).nested), 66 | arr: Object.isFrozen((v as any).arr), 67 | arr0: Object.isFrozen((v as any).arr[0]), 68 | }; 69 | 70 | // attempt mutation: should not change values (and in strict mode often throws) 71 | let mutationError: string | null = null; 72 | try { 73 | (v as any).a = 999; 74 | (v as any).nested.b = 999; 75 | (v as any).arr[0].x = 999; 76 | (v as any).arr.push({ x: 999 }); 77 | } catch (e: any) { 78 | mutationError = e?.message ?? String(e); 79 | } 80 | 81 | json(c, { 82 | frozen, 83 | afterAttempt: { 84 | a: (v as any).a, 85 | nestedB: (v as any).nested.b, 86 | arr0x: (v as any).arr[0].x, 87 | arrLen: (v as any).arr.length, 88 | }, 89 | mutationError, 90 | }); 91 | } 92 | } 93 | 94 | app.mount(ValidatorReturnRoute, ValidatorFreezeRoute); 95 | 96 | server = await app.listen(0); 97 | port = server.port; 98 | }); 99 | 100 | afterAll(() => { 101 | server?.stop?.(true); 102 | }); 103 | 104 | test("validator that returns undefined becomes a 500 with INTERNAL_SERVER_ERROR payload", async () => { 105 | const res = await fetch(makeURL(port, "/v/undefined")); 106 | expect(res.status).toBe(500); 107 | 108 | const body = await readJSON(res); 109 | expect(body?.error?.code).toBe("INTERNAL_SERVER_ERROR"); 110 | expect(String(body?.error?.detail ?? "")).toContain("did not return a value"); 111 | }); 112 | 113 | test("validator values are deep-frozen by default and mutation does not succeed", async () => { 114 | const res = await fetch(makeURL(port, "/v/freeze")); 115 | expect(res.status).toBe(200); 116 | 117 | const body = await readJSON(res); 118 | 119 | expect(body.frozen.root).toBe(true); 120 | expect(body.frozen.nested).toBe(true); 121 | expect(body.frozen.arr).toBe(true); 122 | expect(body.frozen.arr0).toBe(true); 123 | 124 | // values should remain unchanged 125 | expect(body.afterAttempt.a).toBe(1); 126 | expect(body.afterAttempt.nestedB).toBe(2); 127 | expect(body.afterAttempt.arr0x).toBe(1); 128 | expect(body.afterAttempt.arrLen).toBe(2); 129 | 130 | // mutationError may be null depending on runtime strictness, 131 | // but either way state should not change (asserted above). 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/std/Response.ts: -------------------------------------------------------------------------------- 1 | // --- START FILE: src/std/Response.ts --- 2 | import { HTTPContext } from "../HTTPContext"; 3 | import { SystemErrCode } from "../SystemErrCode"; 4 | import { ContextState } from "../ContextState"; 5 | import { href } from "../Href"; 6 | import type { CookieOptions } from "../CookieOptions"; 7 | import { SystemErr } from "../SystemErr"; 8 | 9 | export function setStatus(c: HTTPContext, code: number): void { 10 | c.ensureConfigurable(); 11 | c.res.setStatus(code); 12 | } 13 | 14 | export function status(c: HTTPContext, code: number): void { 15 | setStatus(c, code); 16 | } 17 | 18 | export function setHeader(c: HTTPContext, name: string, value: string): void { 19 | c.ensureConfigurable(); 20 | if (/[\r\n]/.test(value)) { 21 | throw new SystemErr( 22 | SystemErrCode.INTERNAL_SERVER_ERR, 23 | `Attempted to set invalid header: ${name}`, 24 | ); 25 | } 26 | c.res.headers.set(name, value); 27 | } 28 | 29 | export function appendHeader(c: HTTPContext, name: string, value: string): void { 30 | c.ensureConfigurable(); 31 | if (/[\r\n]/.test(value)) { 32 | throw new SystemErr( 33 | SystemErrCode.INTERNAL_SERVER_ERR, 34 | `Attempted to set invalid header: ${name}`, 35 | ); 36 | } 37 | c.res.headers.append(name, value); 38 | } 39 | 40 | export function html(c: HTTPContext, content: string, code?: number): void { 41 | c.ensureBodyModifiable(); 42 | setHeader(c, "Content-Type", "text/html"); 43 | if (code !== undefined) c.res.setStatus(code); 44 | c.res.body(content); 45 | c.finalize(); 46 | } 47 | 48 | export function text(c: HTTPContext, content: string, code?: number): void { 49 | c.ensureBodyModifiable(); 50 | if (!c.res.headers.get("Content-Type")) { 51 | setHeader(c, "Content-Type", "text/plain"); 52 | } 53 | if (code !== undefined) c.res.setStatus(code); 54 | c.res.body(content); 55 | c.finalize(); 56 | } 57 | 58 | export function json(c: HTTPContext, data: any, code?: number): void { 59 | c.ensureBodyModifiable(); 60 | setHeader(c, "Content-Type", "application/json"); 61 | if (code !== undefined) c.res.setStatus(code); 62 | c.res.body(JSON.stringify(data)); 63 | c.finalize(); 64 | } 65 | 66 | export function errorJSON( 67 | c: HTTPContext, 68 | status: number, 69 | code: string, 70 | message: string, 71 | extra?: Record, 72 | ): void { 73 | c.ensureBodyModifiable(); 74 | setHeader(c, "Content-Type", "application/json"); 75 | c.res.setStatus(status); 76 | c.res.body( 77 | JSON.stringify({ 78 | error: { code, message, ...(extra ?? {}) }, 79 | }), 80 | ); 81 | c.finalize(); 82 | } 83 | 84 | export function redirect( 85 | c: HTTPContext, 86 | path: string, 87 | query?: Record, 88 | status: number = 302, 89 | ): void { 90 | c.ensureConfigurable(); 91 | let location = path; 92 | 93 | if (query) { 94 | const q: Record = {}; 95 | for (const [key, value] of Object.entries(query)) { 96 | if (value === undefined || value === null) continue; 97 | q[key] = value as any; 98 | } 99 | location = href(path, q); 100 | } 101 | 102 | if (/[\r\n]/.test(location)) { 103 | throw new SystemErr( 104 | SystemErrCode.INTERNAL_SERVER_ERR, 105 | "Redirect location contains invalid characters.", 106 | ); 107 | } 108 | 109 | c.res.setStatus(status); 110 | c.res.headers.set("Location", location); 111 | c.finalize(); 112 | } 113 | 114 | export function stream(c: HTTPContext, stream: ReadableStream): void { 115 | c.ensureConfigurable(); 116 | setHeader(c, "Content-Type", "application/octet-stream"); 117 | c.res.body(stream); 118 | c._state = ContextState.STREAMING; 119 | } 120 | 121 | export async function file(c: HTTPContext, path: string): Promise { 122 | c.ensureBodyModifiable(); 123 | c.ensureConfigurable(); 124 | const f = Bun.file(path); 125 | if (!(await f.exists())) { 126 | throw new SystemErr(SystemErrCode.FILE_NOT_FOUND, `file does not exist at ${path}`); 127 | } 128 | c.res.headers.set("Content-Type", f.type || "application/octet-stream"); 129 | c.res.body(f); 130 | c.finalize(); 131 | } 132 | 133 | /** 134 | * Canonical cookie writers (go through c.cookies.response). 135 | */ 136 | export function setCookie( 137 | c: HTTPContext, 138 | name: string, 139 | value: string, 140 | options?: CookieOptions, 141 | ): void { 142 | c.ensureConfigurable(); 143 | c.cookies.response.set(name, value, options ?? {}); 144 | } 145 | 146 | export function clearCookie( 147 | c: HTTPContext, 148 | name: string, 149 | options?: { path?: string; domain?: string }, 150 | ): void { 151 | c.ensureConfigurable(); 152 | c.cookies.response.clear(name, options); 153 | } 154 | // --- END FILE: src/std/Response.ts --- 155 | -------------------------------------------------------------------------------- /tests/aae_basic_methods.test.ts: -------------------------------------------------------------------------------- 1 | import { Xerus } from "../src/Xerus"; 2 | import { XerusRoute } from "../src/XerusRoute"; 3 | import { Method } from "../src/Method"; 4 | import type { HTTPContext } from "../src/HTTPContext"; 5 | import { SystemErrCode } from "../src/SystemErrCode"; 6 | import { BodyType } from "../src/BodyType"; 7 | import type { XerusValidator } from "../src/XerusValidator"; 8 | import { parseBody } from "../src/std/Body"; 9 | import { 10 | json, 11 | redirect, 12 | setHeader, 13 | setStatus, 14 | text, 15 | } from "../src/std/Response"; 16 | import { header, query } from "../src/std/Request"; 17 | import { SystemErr } from "../src/SystemErr"; 18 | 19 | class JsonObjectBody implements XerusValidator { 20 | async validate(c: HTTPContext) { 21 | const body = await parseBody(c, BodyType.JSON); 22 | if (!body || typeof body !== "object" || Array.isArray(body)) { 23 | throw new SystemErr( 24 | SystemErrCode.VALIDATION_FAILED, 25 | "Expected JSON object body", 26 | ); 27 | } 28 | return body; 29 | } 30 | } 31 | 32 | class Root extends XerusRoute { 33 | method = Method.GET; 34 | path = "/"; 35 | async handle(c: HTTPContext) { 36 | json(c, { message: "Hello, world!" }); 37 | } 38 | } 39 | 40 | class CreateItem extends XerusRoute { 41 | method = Method.POST; 42 | path = "/items"; 43 | validators = [JsonObjectBody]; 44 | async handle(c: HTTPContext) { 45 | const body = c.validated(JsonObjectBody); 46 | setStatus(c, 201); 47 | json(c, { message: "Item created", data: body }); 48 | } 49 | } 50 | 51 | class UpdateItem extends XerusRoute { 52 | method = Method.PUT; 53 | path = "/items/1"; 54 | validators = [JsonObjectBody]; 55 | async handle(c: HTTPContext) { 56 | const body = c.validated(JsonObjectBody); 57 | json(c, { message: "Item 1 updated", data: body }); 58 | } 59 | } 60 | 61 | class DeleteItem extends XerusRoute { 62 | method = Method.DELETE; 63 | path = "/items/1"; 64 | async handle(c: HTTPContext) { 65 | json(c, { message: "Item 1 deleted" }); 66 | } 67 | } 68 | 69 | class RedirSimple extends XerusRoute { 70 | method = Method.GET; 71 | path = "/redir/simple"; 72 | async handle(c: HTTPContext) { 73 | redirect(c, "/"); 74 | } 75 | } 76 | 77 | class RedirQuery extends XerusRoute { 78 | method = Method.GET; 79 | path = "/redir/query"; 80 | async handle(c: HTTPContext) { 81 | redirect(c, "/?existing=1", { new: "2" }); 82 | } 83 | } 84 | 85 | class RedirUnsafe extends XerusRoute { 86 | method = Method.GET; 87 | path = "/redir/unsafe"; 88 | async handle(c: HTTPContext) { 89 | const dangerous = "Hack\r\nLocation: google.com"; 90 | redirect(c, "/", { msg: dangerous }); 91 | } 92 | } 93 | 94 | class Ping extends XerusRoute { 95 | method = Method.GET; 96 | path = "/basics/ping"; 97 | async handle(c: HTTPContext) { 98 | setHeader(c, "X-Ping", "pong"); 99 | text(c, "pong"); 100 | } 101 | } 102 | 103 | class HeadPing extends XerusRoute { 104 | method = Method.HEAD; 105 | path = "/basics/ping"; 106 | async handle(c: HTTPContext) { 107 | setHeader(c, "X-Ping", "pong"); 108 | setStatus(c, 200); 109 | text(c, ""); 110 | } 111 | } 112 | 113 | class OptionsPing extends XerusRoute { 114 | method = Method.OPTIONS; 115 | path = "/basics/ping"; 116 | async handle(c: HTTPContext) { 117 | setHeader(c, "Allow", "GET, HEAD, OPTIONS"); 118 | setStatus(c, 204); 119 | text(c, ""); 120 | } 121 | } 122 | 123 | class EchoQuery extends XerusRoute { 124 | method = Method.GET; 125 | path = "/basics/echo-query"; 126 | async handle(c: HTTPContext) { 127 | const a = query(c, "a") || null; 128 | const b = query(c, "b") || null; 129 | json(c, { a, b }); 130 | } 131 | } 132 | 133 | class EchoHeader extends XerusRoute { 134 | method = Method.GET; 135 | path = "/basics/echo-header"; 136 | async handle(c: HTTPContext) { 137 | const v = header(c, "x-test-header") ?? ""; 138 | setHeader(c, "X-Echo-Test", v); 139 | json(c, { value: v }); 140 | } 141 | } 142 | 143 | class StatusTest extends XerusRoute { 144 | method = Method.GET; 145 | path = "/basics/status"; 146 | async handle(c: HTTPContext) { 147 | setStatus(c, 418); 148 | text(c, "teapot"); 149 | } 150 | } 151 | 152 | class JsonTest extends XerusRoute { 153 | method = Method.GET; 154 | path = "/basics/json"; 155 | async handle(c: HTTPContext) { 156 | json(c, { ok: true, msg: "✨ unicode ok" }); 157 | } 158 | } 159 | 160 | export function basicMethods(app: Xerus) { 161 | app.mount( 162 | Root, 163 | CreateItem, 164 | UpdateItem, 165 | DeleteItem, 166 | RedirSimple, 167 | RedirQuery, 168 | RedirUnsafe, 169 | Ping, 170 | HeadPing, 171 | OptionsPing, 172 | EchoQuery, 173 | EchoHeader, 174 | StatusTest, 175 | JsonTest, 176 | ); 177 | } 178 | -------------------------------------------------------------------------------- /tests/aay_ws_advanced.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import type { HTTPContext } from "../src/HTTPContext"; 7 | import type { InjectableStore } from "../src/RouteFields"; 8 | import { param, ws } from "../src/std/Request"; 9 | import { json } from "../src/std/Response"; 10 | import { TestStore } from "./TestStore"; 11 | 12 | function makeWSURL(port: number, path: string) { 13 | return `ws://127.0.0.1:${port}${path}`; 14 | } 15 | function makeHTTPURL(port: number, path: string) { 16 | return `http://127.0.0.1:${port}${path}`; 17 | } 18 | 19 | /* ====================== 20 | WebSocket Routes 21 | ====================== */ 22 | 23 | // 1️⃣ Pub/Sub Room 24 | class RoomOpen extends XerusRoute { 25 | method = Method.WS_OPEN; 26 | path = "/ws/room/:name"; 27 | service = [TestStore]; 28 | 29 | async handle(c: HTTPContext) { 30 | const socket = ws(c); 31 | const room = param(c, "name"); 32 | socket.subscribe(room); 33 | socket.publish(room, `User joined ${room}`); 34 | } 35 | } 36 | 37 | class RoomMessage extends XerusRoute { 38 | method = Method.WS_MESSAGE; 39 | path = "/ws/room/:name"; 40 | service = [TestStore]; 41 | 42 | async handle(c: HTTPContext) { 43 | const socket = ws(c); 44 | const room = param(c, "name"); 45 | socket.publish(room, socket.message); 46 | } 47 | } 48 | 49 | // 2️⃣ Binary echo 50 | class BinaryEcho extends XerusRoute { 51 | method = Method.WS_MESSAGE; 52 | path = "/ws/binary"; 53 | service = [TestStore]; 54 | 55 | async handle(c: HTTPContext) { 56 | const socket = ws(c); 57 | socket.send(socket.message); 58 | } 59 | } 60 | 61 | // 3️⃣ Lifecycle tracking 62 | let closedConnections = 0; 63 | 64 | class WsStats extends XerusRoute { 65 | method = Method.GET; 66 | path = "/ws-stats"; 67 | service = [TestStore]; 68 | 69 | async handle(c: HTTPContext) { 70 | json(c, { closed: closedConnections }); 71 | } 72 | } 73 | 74 | class LifecycleClose extends XerusRoute { 75 | method = Method.WS_CLOSE; 76 | path = "/ws/lifecycle"; 77 | service = [TestStore]; 78 | 79 | async handle(_c: HTTPContext) { 80 | closedConnections++; 81 | } 82 | } 83 | 84 | /* ====================== 85 | Tests 86 | ====================== */ 87 | 88 | describe("WebSocket Advanced", () => { 89 | let server: any; 90 | let port: number; 91 | 92 | beforeAll(async () => { 93 | const app = new Xerus(); 94 | app.mount( 95 | RoomOpen, 96 | RoomMessage, 97 | BinaryEcho, 98 | WsStats, 99 | LifecycleClose, 100 | ); 101 | 102 | server = await app.listen(0); 103 | port = server.port; 104 | }); 105 | 106 | afterAll(() => { 107 | server?.stop?.(true); 108 | }); 109 | 110 | test("WebSocket: Binary data should be preserved", async () => { 111 | const socket = new WebSocket(makeWSURL(port, "/ws/binary")); 112 | socket.binaryType = "arraybuffer"; 113 | 114 | const input = new Uint8Array([1, 2, 3, 4, 5]); 115 | 116 | const result = await new Promise((resolve) => { 117 | socket.onopen = () => socket.send(input); 118 | socket.onmessage = (event) => { 119 | socket.close(); 120 | resolve(new Uint8Array(event.data)); 121 | }; 122 | }); 123 | 124 | expect(result).toEqual(input); 125 | }); 126 | 127 | test("WebSocket: Pub/Sub should broadcast to other clients", async () => { 128 | const clientA = new WebSocket(makeWSURL(port, "/ws/room/lobby")); 129 | const clientB = new WebSocket(makeWSURL(port, "/ws/room/lobby")); 130 | 131 | // 1. Create the promise, but DO NOT await it yet 132 | const resultPromise = new Promise((resolve) => { 133 | clientB.onmessage = (event) => { 134 | if (event.data === "hello from A") { 135 | clientA.close(); 136 | clientB.close(); 137 | resolve(event.data); // Resolve with the data 138 | } 139 | }; 140 | }); 141 | 142 | // 2. Wait a moment for connections to establish 143 | await new Promise((r) => setTimeout(r, 100)); 144 | 145 | // 3. Trigger the action that fulfills the promise 146 | clientA.send("hello from A"); 147 | 148 | // 4. Now await the result 149 | const result = await resultPromise; 150 | expect(result).toBe("hello from A"); 151 | }); 152 | 153 | test("WebSocket: Server-side close handler should trigger", async () => { 154 | const pre = await fetch(makeHTTPURL(port, "/ws-stats")); 155 | const before = (await pre.json()).closed; 156 | 157 | const socket = new WebSocket(makeWSURL(port, "/ws/lifecycle")); 158 | 159 | await new Promise((resolve) => { 160 | socket.onopen = () => { 161 | socket.close(); 162 | setTimeout(resolve, 100); 163 | }; 164 | }); 165 | 166 | const post = await fetch(makeHTTPURL(port, "/ws-stats")); 167 | const after = (await post.json()).closed; 168 | 169 | expect(after).toBe(before + 1); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /tests/aar_hardening.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import { HTTPContext } from "../src/HTTPContext"; 7 | import type { InjectableStore } from "../src/RouteFields"; 8 | import { json, setHeader, stream, text } from "../src/std/Response"; 9 | 10 | function makeURL(port: number, path: string) { 11 | return `http://127.0.0.1:${port}${path}`; 12 | } 13 | 14 | /* ====================== 15 | Services + Routes 16 | ====================== */ 17 | 18 | class PollutionStore implements InjectableStore { 19 | storeKey = "PollutionStore"; 20 | value?: string; 21 | } 22 | 23 | class PollutionSet extends XerusRoute { 24 | method = Method.GET; 25 | path = "/harden/pollution/set"; 26 | services = [PollutionStore]; 27 | 28 | async handle(c: HTTPContext) { 29 | const store = c.service(PollutionStore); 30 | store.value = "I should be cleaned up"; 31 | json(c, { set: true }); 32 | } 33 | } 34 | 35 | class PollutionCheck extends XerusRoute { 36 | method = Method.GET; 37 | path = "/harden/pollution/check"; 38 | services = [PollutionStore]; 39 | 40 | async handle(c: HTTPContext) { 41 | const store = c.service(PollutionStore); 42 | const val = store.value; 43 | json(c, { polluted: !!val, value: val }); 44 | } 45 | } 46 | 47 | class BrokenService implements InjectableStore { 48 | storeKey = "BrokenService"; 49 | async init(_c: HTTPContext) { 50 | throw new Error("Database Connection Failed inside Service"); 51 | } 52 | } 53 | 54 | class BrokenServiceRoute extends XerusRoute { 55 | method = Method.GET; 56 | path = "/harden/service-fail"; 57 | services = [BrokenService]; 58 | 59 | async handle(c: HTTPContext) { 60 | text(c, "Should not reach here"); 61 | } 62 | } 63 | 64 | class LateHeaderRoute extends XerusRoute { 65 | method = Method.GET; 66 | path = "/harden/late-header"; 67 | 68 | async handle(c: HTTPContext) { 69 | json(c, { ok: true }); 70 | setHeader(c, "X-Late", "Too late"); 71 | } 72 | } 73 | 74 | class StreamSafetyRoute extends XerusRoute { 75 | method = Method.GET; 76 | path = "/harden/stream-safety"; 77 | 78 | async handle(c: HTTPContext) { 79 | const s = new ReadableStream({ 80 | start(ctrl) { 81 | ctrl.enqueue(new TextEncoder().encode("stream data")); 82 | ctrl.close(); 83 | }, 84 | }); 85 | stream(c, s); 86 | 87 | try { 88 | setHeader(c, "X-Fail", "True"); 89 | } catch { 90 | // expected: headers immutable after streaming begins 91 | } 92 | } 93 | } 94 | 95 | /* ====================== 96 | Tests 97 | ====================== */ 98 | 99 | describe("Hardening", () => { 100 | let server: any; 101 | let port: number; 102 | 103 | beforeAll(async () => { 104 | const app = new Xerus(); 105 | 106 | // If your hardening expectations depend on context pooling, ensure pool is on 107 | // (safe even if Xerus defaults differ). 108 | app.setHTTPContextPool?.(50); 109 | 110 | app.mount( 111 | PollutionSet, 112 | PollutionCheck, 113 | BrokenServiceRoute, 114 | LateHeaderRoute, 115 | StreamSafetyRoute, 116 | ); 117 | 118 | server = await app.listen(0); 119 | port = server.port; 120 | }); 121 | 122 | afterAll(() => { 123 | server?.stop?.(true); 124 | }); 125 | 126 | test("Hardening: Object Pool should strictly reset context between requests", async () => { 127 | await fetch(makeURL(port, "/harden/pollution/set")); 128 | const res = await fetch(makeURL(port, "/harden/pollution/check")); 129 | const data = await res.json(); 130 | 131 | expect(data.polluted).toBe(false); 132 | expect(data.value).toBeUndefined(); 133 | }); 134 | 135 | test("Hardening: Service init() failure should trigger 500 error", async () => { 136 | const res = await fetch(makeURL(port, "/harden/service-fail")); 137 | const data = await res.json(); 138 | 139 | expect(res.status).toBe(500); 140 | expect(data.error.detail).toBe("Database Connection Failed inside Service"); 141 | }); 142 | 143 | test("Hardening: Headers should be mutable after body written (Onion Pattern support)", async () => { 144 | const res = await fetch(makeURL(port, "/harden/late-header")); 145 | 146 | expect(res.status).toBe(200); 147 | expect(res.headers.get("X-Late")).toBe("Too late"); 148 | }); 149 | 150 | test("Hardening: Headers should be IMMUTABLE after Streaming starts", async () => { 151 | const res = await fetch(makeURL(port, "/harden/stream-safety")); 152 | 153 | expect(res.status).toBe(200); 154 | const body = await res.text(); 155 | expect(body).toBe("stream data"); 156 | expect(res.headers.get("X-Fail")).toBeNull(); 157 | }); 158 | 159 | test("Hardening: Duplicate route registration should throw at startup", async () => { 160 | class A extends XerusRoute { 161 | method = Method.GET; 162 | path = "/duplicate"; 163 | async handle(_c: any) {} 164 | } 165 | 166 | const app = new Xerus(); 167 | app.mount(A); 168 | 169 | expect(() => { 170 | app.mount(A); 171 | }).toThrow("ROUTE_ALREADY_REGISTERED"); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /src/Cookies.ts: -------------------------------------------------------------------------------- 1 | import type { CookieOptions } from "./CookieOptions"; 2 | 3 | function safeDecode(v: string) { 4 | try { 5 | return decodeURIComponent(v); 6 | } catch { 7 | return v; 8 | } 9 | } 10 | 11 | function safeEncode(v: string) { 12 | try { 13 | return encodeURIComponent(v); 14 | } catch { 15 | return v; 16 | } 17 | } 18 | 19 | export function parseCookieHeader(header: string): Record { 20 | const out: Record = {}; 21 | const parts = header.split(";"); 22 | for (const part of parts) { 23 | const s = part.trim(); 24 | if (!s) continue; 25 | const eq = s.indexOf("="); 26 | if (eq === -1) continue; 27 | const k = s.slice(0, eq).trim(); 28 | const v = s.slice(eq + 1); 29 | if (!k) continue; 30 | out[k] = safeDecode(v); 31 | } 32 | return out; 33 | } 34 | 35 | export function serializeSetCookie( 36 | name: string, 37 | value: string, 38 | options: CookieOptions, 39 | ): string { 40 | let cookieString = `${name}=${safeEncode(value)}`; 41 | 42 | const path = options.path ?? "/"; 43 | const httpOnly = options.httpOnly ?? true; 44 | const sameSite = options.sameSite ?? "Lax"; 45 | 46 | if (path) cookieString += `; Path=${path}`; 47 | if (options.domain) cookieString += `; Domain=${options.domain}`; 48 | if (options.maxAge !== undefined) cookieString += `; Max-Age=${options.maxAge}`; 49 | if (options.expires) cookieString += `; Expires=${options.expires.toUTCString()}`; 50 | if (httpOnly) cookieString += `; HttpOnly`; 51 | if (options.secure) cookieString += `; Secure`; 52 | if (sameSite) cookieString += `; SameSite=${sameSite}`; 53 | 54 | return cookieString; 55 | } 56 | 57 | /** 58 | * CookieJar is RESPONSE-only: it stores Set-Cookie lines to be sent out. 59 | * Request cookie parsing belongs to HTTPContext (via RequestCookies below). 60 | */ 61 | export class CookieJar { 62 | private setCookies: string[] = []; 63 | 64 | reset(): void { 65 | this.setCookies = []; 66 | } 67 | 68 | set(name: string, value: string, options: CookieOptions = {}) { 69 | this.setCookies.push(serializeSetCookie(name, value, options)); 70 | } 71 | 72 | clear(name: string, options?: { path?: string; domain?: string }) { 73 | this.set(name, "", { 74 | path: options?.path ?? "/", 75 | domain: options?.domain, 76 | maxAge: 0, 77 | expires: new Date(0), 78 | }); 79 | } 80 | 81 | getSetCookieLines(): string[] { 82 | return this.setCookies.slice(); 83 | } 84 | 85 | ref(name: string): CookieRef { 86 | return new CookieRef(this, name); 87 | } 88 | } 89 | 90 | export class CookieRef { 91 | private jar: CookieJar; 92 | private _name: string; 93 | 94 | constructor(jar: CookieJar, name: string) { 95 | this.jar = jar; 96 | this._name = name; 97 | } 98 | 99 | get name(): string { 100 | return this._name; 101 | } 102 | 103 | set(value: string, options: CookieOptions = {}): this { 104 | this.jar.set(this._name, value, options); 105 | return this; 106 | } 107 | 108 | clear(options?: { path?: string; domain?: string }): this { 109 | this.jar.clear(this._name, options); 110 | return this; 111 | } 112 | } 113 | 114 | /** 115 | * RequestCookies parses from the inbound Cookie header. 116 | * It is NOT backed by the response jar. 117 | */ 118 | export class RequestCookies { 119 | private header: string | null = null; 120 | private parsed: Record | null = null; 121 | 122 | reset(cookieHeader: string | null) { 123 | this.header = cookieHeader; 124 | this.parsed = null; 125 | } 126 | 127 | private ensureParsed() { 128 | if (this.parsed) return; 129 | this.parsed = this.header ? parseCookieHeader(this.header) : {}; 130 | } 131 | 132 | get(name: string): string | undefined { 133 | this.ensureParsed(); 134 | return this.parsed![name]; 135 | } 136 | 137 | ref(name: string): RequestCookieRef { 138 | return new RequestCookieRef(this, name); 139 | } 140 | } 141 | 142 | /** 143 | * ResponseCookies writes Set-Cookie lines via the response jar. 144 | */ 145 | export class ResponseCookies { 146 | constructor(private jar: CookieJar) {} 147 | 148 | set(name: string, value: string, options: CookieOptions = {}) { 149 | this.jar.set(name, value, options); 150 | } 151 | 152 | clear(name: string, options?: { path?: string; domain?: string }) { 153 | this.jar.clear(name, options); 154 | } 155 | 156 | ref(name: string): ResponseCookieRef { 157 | return new ResponseCookieRef(this, name); 158 | } 159 | } 160 | 161 | export class RequestCookieRef { 162 | constructor(private view: RequestCookies, private _name: string) {} 163 | 164 | get name() { 165 | return this._name; 166 | } 167 | 168 | get(): string | undefined { 169 | return this.view.get(this._name); 170 | } 171 | } 172 | 173 | export class ResponseCookieRef { 174 | constructor(private writer: ResponseCookies, private _name: string) {} 175 | 176 | get name() { 177 | return this._name; 178 | } 179 | 180 | set(value: string, options: CookieOptions = {}): this { 181 | this.writer.set(this._name, value, options); 182 | return this; 183 | } 184 | 185 | clear(options?: { path?: string; domain?: string }): this { 186 | this.writer.clear(this._name, options); 187 | return this; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /tests/abj_service_graph_lifecycle_and_on_error_order.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | import { Xerus } from "../src/Xerus"; 3 | import { XerusRoute } from "../src/XerusRoute"; 4 | import { Method } from "../src/Method"; 5 | import type { HTTPContext } from "../src/HTTPContext"; 6 | import { json } from "../src/std/Response"; 7 | 8 | function makeURL(port: number, path: string) { 9 | return `http://127.0.0.1:${port}${path}`; 10 | } 11 | async function readJSON(res: Response) { 12 | const ct = (res.headers.get("content-type") ?? "").toLowerCase(); 13 | if (!ct.includes("application/json")) return { _text: await res.text() }; 14 | return await res.json(); 15 | } 16 | 17 | describe("service graph lifecycle: init/before/after order + onError reverse order", () => { 18 | let server: any; 19 | let port: number; 20 | 21 | beforeAll(async () => { 22 | const app = new Xerus(); 23 | 24 | // We’ll record ordering into a shared array for each request via global closure. 25 | // This is safe per-test as each request asserts relative ordering. 26 | const events: string[] = []; 27 | 28 | class DepService { 29 | async init(_c: HTTPContext) { 30 | events.push("dep:init"); 31 | } 32 | async before(_c: HTTPContext) { 33 | events.push("dep:before"); 34 | } 35 | async after(_c: HTTPContext) { 36 | events.push("dep:after"); 37 | } 38 | async onError(_c: HTTPContext, _err: unknown) { 39 | events.push("dep:onError"); 40 | } 41 | } 42 | 43 | class RootService { 44 | services = [DepService]; 45 | 46 | async init(_c: HTTPContext) { 47 | events.push("root:init"); 48 | } 49 | async before(_c: HTTPContext) { 50 | events.push("root:before"); 51 | } 52 | async after(_c: HTTPContext) { 53 | events.push("root:after"); 54 | } 55 | async onError(_c: HTTPContext, _err: unknown) { 56 | events.push("root:onError"); 57 | } 58 | } 59 | 60 | class OkRoute extends XerusRoute { 61 | method = Method.GET; 62 | path = "/svc/ok"; 63 | services = [RootService]; 64 | async handle(c: HTTPContext) { 65 | json(c, { ok: true }); 66 | } 67 | } 68 | 69 | class BoomRoute extends XerusRoute { 70 | method = Method.GET; 71 | path = "/svc/boom"; 72 | services = [RootService]; 73 | async handle(_c: HTTPContext) { 74 | throw new Error("boom"); 75 | } 76 | } 77 | 78 | class EventsRoute extends XerusRoute { 79 | method = Method.GET; 80 | path = "/svc/events"; 81 | async handle(c: HTTPContext) { 82 | // expose and reset events 83 | const copy = events.slice(); 84 | events.length = 0; 85 | json(c, { events: copy }); 86 | } 87 | } 88 | 89 | app.mount(OkRoute, BoomRoute, EventsRoute); 90 | 91 | server = await app.listen(0); 92 | port = server.port; 93 | }); 94 | 95 | afterAll(() => { 96 | server?.stop?.(true); 97 | }); 98 | 99 | test("happy path: init happens before before; after runs in reverse service order", async () => { 100 | const res = await fetch(makeURL(port, "/svc/ok")); 101 | expect(res.status).toBe(200); 102 | 103 | const eventsRes = await fetch(makeURL(port, "/svc/events")); 104 | const body = await readJSON(eventsRes); 105 | const ev: string[] = body.events; 106 | 107 | // Expect dependency init before root init (since root resolves dep first) 108 | const depInit = ev.indexOf("dep:init"); 109 | const rootInit = ev.indexOf("root:init"); 110 | expect(depInit).toBeGreaterThanOrEqual(0); 111 | expect(rootInit).toBeGreaterThanOrEqual(0); 112 | expect(depInit).toBeLessThan(rootInit); 113 | 114 | // before order: dep then root (because activateServices visits deps then pushes root last, 115 | // and before iterates in that order) 116 | const depBefore = ev.indexOf("dep:before"); 117 | const rootBefore = ev.indexOf("root:before"); 118 | expect(depBefore).toBeLessThan(rootBefore); 119 | 120 | // after order: reverse => root then dep 121 | const rootAfter = ev.indexOf("root:after"); 122 | const depAfter = ev.indexOf("dep:after"); 123 | expect(rootAfter).toBeGreaterThanOrEqual(0); 124 | expect(depAfter).toBeGreaterThanOrEqual(0); 125 | expect(rootAfter).toBeLessThan(depAfter); 126 | }); 127 | 128 | test("error path: onError runs in reverse active-service order", async () => { 129 | const res = await fetch(makeURL(port, "/svc/boom")); 130 | expect(res.status).toBe(500); 131 | 132 | const eventsRes = await fetch(makeURL(port, "/svc/events")); 133 | const body = await readJSON(eventsRes); 134 | const ev: string[] = body.events; 135 | 136 | // onError reverse => root:onError then dep:onError 137 | const rootOnErr = ev.indexOf("root:onError"); 138 | const depOnErr = ev.indexOf("dep:onError"); 139 | expect(rootOnErr).toBeGreaterThanOrEqual(0); 140 | expect(depOnErr).toBeGreaterThanOrEqual(0); 141 | expect(rootOnErr).toBeLessThan(depOnErr); 142 | 143 | // "after" should NOT run because route threw and executeRoute goes through catch path 144 | expect(ev.includes("root:after")).toBe(false); 145 | expect(ev.includes("dep:after")).toBe(false); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /tests/aat_http_context_edge_cases.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from "bun:test"; 2 | 3 | import { Xerus } from "../src/Xerus"; 4 | import { XerusRoute } from "../src/XerusRoute"; 5 | import { Method } from "../src/Method"; 6 | import { HTTPContext } from "../src/HTTPContext"; 7 | import { BodyType } from "../src/BodyType"; 8 | import { parseBody } from "../src/std/Body"; 9 | import { json, setHeader, text } from "../src/std/Response"; 10 | 11 | function makeURL(port: number, path: string) { 12 | return `http://127.0.0.1:${port}${path}`; 13 | } 14 | 15 | describe("HTTPContext edge cases", () => { 16 | let server: any; 17 | let port: number; 18 | 19 | beforeAll(async () => { 20 | const app = new Xerus(); 21 | 22 | class JsonThenText extends XerusRoute { 23 | method = Method.POST; 24 | path = "/ctx/reparse/json-then-text"; 25 | async handle(c: HTTPContext) { 26 | const j = await parseBody(c, BodyType.JSON); 27 | const raw = await parseBody(c, BodyType.TEXT); 28 | json(c, { ok: true, json: j, raw }); 29 | } 30 | } 31 | 32 | class JsonThenForm extends XerusRoute { 33 | method = Method.POST; 34 | path = "/ctx/reparse/json-then-form"; 35 | async handle(c: HTTPContext) { 36 | await parseBody(c, BodyType.JSON); 37 | await parseBody(c, BodyType.FORM); 38 | json(c, { ok: false, shouldNot: "reach" }); 39 | } 40 | } 41 | 42 | class FormThenJson extends XerusRoute { 43 | method = Method.POST; 44 | path = "/ctx/reparse/form-then-json"; 45 | async handle(c: HTTPContext) { 46 | await parseBody(c, BodyType.FORM); 47 | await parseBody(c, BodyType.JSON); 48 | json(c, { ok: false, shouldNot: "reach" }); 49 | } 50 | } 51 | 52 | class MultipartThenJson extends XerusRoute { 53 | method = Method.POST; 54 | path = "/ctx/reparse/multipart-then-json"; 55 | async handle(c: HTTPContext) { 56 | await parseBody(c, BodyType.MULTIPART_FORM); 57 | await parseBody(c, BodyType.JSON); 58 | json(c, { ok: false, shouldNot: "reach" }); 59 | } 60 | } 61 | 62 | class HeaderNewline extends XerusRoute { 63 | method = Method.GET; 64 | path = "/ctx/header/newline"; 65 | async handle(c: HTTPContext) { 66 | setHeader(c, "X-Test", "ok\r\ninjected: true"); 67 | text(c, "should not reach"); 68 | } 69 | } 70 | 71 | app.mount( 72 | JsonThenText, 73 | JsonThenForm, 74 | FormThenJson, 75 | MultipartThenJson, 76 | HeaderNewline, 77 | ); 78 | 79 | server = await app.listen(0); 80 | port = server.port; 81 | }); 82 | 83 | afterAll(() => { 84 | server?.stop?.(true); 85 | }); 86 | 87 | test("JSON -> TEXT should return same raw payload", async () => { 88 | const payload = { a: 1, b: "two" }; 89 | const res = await fetch(makeURL(port, "/ctx/reparse/json-then-text"), { 90 | method: "POST", 91 | headers: { "Content-Type": "application/json" }, 92 | body: JSON.stringify(payload), 93 | }); 94 | 95 | const j = await res.json(); 96 | expect(res.status).toBe(200); 97 | expect(j.ok).toBe(true); 98 | expect(j.json).toEqual(payload); 99 | expect(typeof j.raw).toBe("string"); 100 | expect(j.raw).toBe(JSON.stringify(payload)); 101 | }); 102 | 103 | test("JSON -> FORM should be blocked", async () => { 104 | const res = await fetch(makeURL(port, "/ctx/reparse/json-then-form"), { 105 | method: "POST", 106 | headers: { "Content-Type": "application/json" }, 107 | body: JSON.stringify({ x: 1 }), 108 | }); 109 | 110 | const j = await res.json(); 111 | expect(res.status).toBe(400); 112 | expect(j.error.code).toBe("BODY_PARSING_FAILED"); 113 | expect(j.error.message).toContain("BODY_PARSING_FAILED"); 114 | }); 115 | 116 | test("FORM -> JSON should be blocked", async () => { 117 | const res = await fetch(makeURL(port, "/ctx/reparse/form-then-json"), { 118 | method: "POST", 119 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 120 | body: "x=1&y=2", 121 | }); 122 | 123 | const j = await res.json(); 124 | expect(res.status).toBe(400); 125 | expect(j.error.code).toBe("BODY_PARSING_FAILED"); 126 | expect(j.error.message).toContain("BODY_PARSING_FAILED"); 127 | }); 128 | 129 | test("MULTIPART -> JSON should be blocked", async () => { 130 | const fd = new FormData(); 131 | fd.append("a", "1"); 132 | 133 | const res = await fetch(makeURL(port, "/ctx/reparse/multipart-then-json"), { 134 | method: "POST", 135 | body: fd, 136 | }); 137 | 138 | const j = await res.json(); 139 | expect(res.status).toBe(400); 140 | expect(j.error.code).toBe("BODY_PARSING_FAILED"); 141 | expect(j.error.message).toContain("BODY_PARSING_FAILED"); 142 | }); 143 | 144 | test("Header newline injection should throw 500 INTERNAL_SERVER_ERROR", async () => { 145 | const res = await fetch(makeURL(port, "/ctx/header/newline")); 146 | const j = await res.json(); 147 | 148 | expect(res.status).toBe(500); 149 | expect(j.error.code).toBe("INTERNAL_SERVER_ERROR"); 150 | expect(j.error.message).toBe("Internal Server Error"); 151 | expect(j.error.detail).toContain("Attempted to set invalid header"); 152 | }); 153 | }); 154 | --------------------------------------------------------------------------------