This commit is contained in:
2025-11-29 15:24:00 +00:00
commit 9411b5ee49
42 changed files with 14742 additions and 0 deletions

View File

@@ -0,0 +1,215 @@
/**
* Interceptor decorators (@Guard, @Transform, @Intercept)
*
* All three decorators have unified semantics:
* - @Guard is sugar for @Intercept({ request: guardFn })
* - @Transform is sugar for @Intercept({ response: transformFn })
* - @Intercept provides full control over both request and response
*/
import type {
IRequestContext,
IInterceptOptions,
IGuardOptions,
TGuardFunction,
TRequestInterceptor,
TResponseInterceptor,
} from '../core/smartserve.interfaces.js';
import { addClassInterceptor, addMethodInterceptor } from './decorators.metadata.js';
/**
* Create a decorator that can be applied to both classes and methods
*/
function createInterceptDecorator(options: IInterceptOptions) {
// Class decorator
function classDecorator<TClass extends new (...args: any[]) => any>(
target: TClass,
context: ClassDecoratorContext<TClass>
): TClass {
addClassInterceptor(target, options);
return target;
}
// Method decorator
function methodDecorator<This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
context.addInitializer(function (this: This) {
addMethodInterceptor(this, context.name, options);
});
return target;
}
// Return overloaded function that works for both
return function (
target: any,
context: ClassDecoratorContext | ClassMethodDecoratorContext
) {
if (context.kind === 'class') {
return classDecorator(target, context as ClassDecoratorContext);
} else if (context.kind === 'method') {
return methodDecorator(target, context as ClassMethodDecoratorContext);
}
throw new Error('Interceptor decorators can only be applied to classes or methods');
};
}
/**
* @Guard decorator - validates requests before handler execution
*
* Guards return boolean: true to allow, false to reject with 403 Forbidden
*
* @example
* ```typescript
* // Single guard
* @Guard(isAuthenticated)
*
* // Multiple guards (all must pass)
* @Guard([isAuthenticated, hasRole('admin')])
*
* // With custom rejection response
* @Guard(isAuthenticated, {
* onReject: () => new Response('Unauthorized', { status: 401 })
* })
* ```
*/
export function Guard<TBody = unknown>(
guardOrGuards: TGuardFunction<TBody> | TGuardFunction<TBody>[],
options?: IGuardOptions
) {
const guards = Array.isArray(guardOrGuards) ? guardOrGuards : [guardOrGuards];
const interceptor: TRequestInterceptor<TBody> = async (ctx) => {
for (const guard of guards) {
const allowed = await guard(ctx);
if (!allowed) {
if (options?.onReject) {
return options.onReject(ctx);
}
return new Response(JSON.stringify({ error: 'Forbidden' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
// Return undefined to continue with original context
return undefined;
};
return createInterceptDecorator({ request: interceptor });
}
/**
* @Transform decorator - modifies response after handler execution
*
* @example
* ```typescript
* // Single transform
* @Transform(data => ({ success: true, data }))
*
* // Multiple transforms (applied in order)
* @Transform([addTimestamp, wrapResponse])
* ```
*/
export function Transform<TRes = unknown>(
transformOrTransforms: TResponseInterceptor<TRes> | TResponseInterceptor<TRes>[]
) {
const transforms = Array.isArray(transformOrTransforms)
? transformOrTransforms
: [transformOrTransforms];
return createInterceptDecorator({ response: transforms });
}
/**
* @Intercept decorator - full control over request and response interception
*
* @example
* ```typescript
* @Intercept({
* request: async (ctx) => {
* ctx.state.startTime = Date.now();
* return ctx;
* },
* response: (res, ctx) => ({
* ...res,
* duration: Date.now() - ctx.state.startTime
* })
* })
* ```
*/
export function Intercept<TBody = unknown, TRes = unknown>(
options: IInterceptOptions<TBody, TRes>
) {
return createInterceptDecorator(options as IInterceptOptions);
}
// =============================================================================
// Common Guard Utilities
// =============================================================================
/**
* Create a guard that checks for a specific header
*/
export function hasHeader(headerName: string, expectedValue?: string): TGuardFunction {
return (ctx) => {
const value = ctx.headers.get(headerName);
if (!value) return false;
if (expectedValue !== undefined) return value === expectedValue;
return true;
};
}
/**
* Create a guard that checks for Bearer token
*/
export function hasBearerToken(): TGuardFunction {
return (ctx) => {
const auth = ctx.headers.get('Authorization');
return auth?.startsWith('Bearer ') ?? false;
};
}
/**
* Create a rate limiting guard
*/
export function rateLimit(
maxRequests: number,
windowMs: number
): TGuardFunction {
const requests = new Map<string, number[]>();
return (ctx) => {
const ip = ctx.headers.get('x-forwarded-for')?.split(',')[0] ?? 'unknown';
const now = Date.now();
const windowStart = now - windowMs;
const timestamps = requests.get(ip)?.filter((t) => t > windowStart) ?? [];
if (timestamps.length >= maxRequests) {
return false;
}
timestamps.push(now);
requests.set(ip, timestamps);
return true;
};
}
// =============================================================================
// Common Transform Utilities
// =============================================================================
/**
* Wrap response in a success envelope
*/
export function wrapSuccess<T>(data: T): { success: true; data: T } {
return { success: true, data };
}
/**
* Add timestamp to response
*/
export function addTimestamp<T extends object>(data: T): T & { timestamp: number } {
return { ...data, timestamp: Date.now() };
}

View File

@@ -0,0 +1,141 @@
/**
* Metadata storage for decorators using Symbol.metadata (TC39 Stage 3)
* Falls back to WeakMap for environments without Symbol.metadata
*/
import type { IControllerMetadata, IRouteMetadata } from './decorators.types.js';
import type { IInterceptOptions } from '../core/smartserve.interfaces.js';
// Symbol for storing metadata when Symbol.metadata is not available
const CONTROLLER_METADATA = Symbol('smartserve:controller');
/**
* Get or create controller metadata for a class
* Uses symbol property on the class itself for metadata storage
*/
export function getControllerMetadata(target: any): IControllerMetadata {
// Store metadata on the class itself using symbol
if (!target[CONTROLLER_METADATA]) {
target[CONTROLLER_METADATA] = createEmptyMetadata();
}
return target[CONTROLLER_METADATA];
}
/**
* Get controller metadata from prototype (for instance lookup)
*/
export function getMetadataFromInstance(instance: any): IControllerMetadata | undefined {
const constructor = instance.constructor;
return getControllerMetadata(constructor);
}
/**
* Set base path for a controller
*/
export function setBasePath(target: any, path: string): void {
const metadata = getControllerMetadata(target);
metadata.basePath = normalizePath(path);
metadata.target = target;
}
/**
* Add a route to a controller
*/
export function addRoute(
target: any,
methodName: string | symbol,
route: Omit<IRouteMetadata, 'methodName' | 'interceptors'>
): void {
const metadata = getControllerMetadata(target.constructor);
// Get existing route or create new one
let existingRoute = metadata.routes.get(methodName);
if (!existingRoute) {
existingRoute = {
...route,
methodName,
interceptors: [],
};
metadata.routes.set(methodName, existingRoute);
} else {
// Update existing route
existingRoute.method = route.method;
existingRoute.path = route.path;
existingRoute.options = { ...existingRoute.options, ...route.options };
}
}
/**
* Add class-level interceptor
*/
export function addClassInterceptor(target: any, interceptor: IInterceptOptions): void {
const metadata = getControllerMetadata(target);
metadata.classInterceptors.push(interceptor);
}
/**
* Add method-level interceptor
*/
export function addMethodInterceptor(
target: any,
methodName: string | symbol,
interceptor: IInterceptOptions
): void {
const metadata = getControllerMetadata(target.constructor);
let route = metadata.routes.get(methodName);
if (!route) {
// Create placeholder route (will be completed by @Get/@Post/etc.)
route = {
method: 'GET',
path: '',
methodName,
interceptors: [],
options: {},
};
metadata.routes.set(methodName, route);
}
route.interceptors.push(interceptor);
}
/**
* Create empty metadata object
*/
function createEmptyMetadata(): IControllerMetadata {
return {
basePath: '',
classInterceptors: [],
routes: new Map(),
};
}
/**
* Normalize path to ensure consistent format
*/
export function normalizePath(path: string): string {
if (!path) return '';
// Ensure leading slash
let normalized = path.startsWith('/') ? path : `/${path}`;
// Remove trailing slash (unless it's just '/')
if (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
/**
* Combine base path and route path
*/
export function combinePaths(basePath: string, routePath: string): string {
const base = normalizePath(basePath);
const route = normalizePath(routePath);
if (!base) return route || '/';
if (!route) return base;
return `${base}${route}`;
}

View File

@@ -0,0 +1,117 @@
/**
* HTTP method decorators (@Get, @Post, @Put, @Delete, @Patch, @All)
*/
import type { THttpMethod, IMethodOptions } from '../core/smartserve.interfaces.js';
import { addRoute, normalizePath } from './decorators.metadata.js';
/**
* Factory for creating HTTP method decorators
*/
function createMethodDecorator(httpMethod: THttpMethod) {
return function (pathOrOptions?: string | IMethodOptions) {
return function <This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
if (context.kind !== 'method') {
throw new Error(`@${httpMethod} can only decorate methods`);
}
const options: IMethodOptions = typeof pathOrOptions === 'string'
? { path: pathOrOptions }
: pathOrOptions ?? {};
// Use addInitializer to ensure we have access to the class prototype
context.addInitializer(function (this: This) {
addRoute(this, context.name, {
method: httpMethod,
path: normalizePath(options.path ?? ''),
options,
handler: target as unknown as Function,
});
});
return target;
};
};
}
/**
* @Get decorator - handles GET requests
*
* @example
* ```typescript
* @Get('/users')
* listUsers(ctx: IRequestContext) { ... }
*
* @Get('/:id')
* getUser(ctx: IRequestContext) { ... }
* ```
*/
export const Get = createMethodDecorator('GET');
/**
* @Post decorator - handles POST requests
*
* @example
* ```typescript
* @Post('/users')
* createUser(ctx: IRequestContext<ICreateUserBody>) { ... }
* ```
*/
export const Post = createMethodDecorator('POST');
/**
* @Put decorator - handles PUT requests
*
* @example
* ```typescript
* @Put('/users/:id')
* updateUser(ctx: IRequestContext<IUpdateUserBody>) { ... }
* ```
*/
export const Put = createMethodDecorator('PUT');
/**
* @Delete decorator - handles DELETE requests
*
* @example
* ```typescript
* @Delete('/users/:id')
* deleteUser(ctx: IRequestContext) { ... }
* ```
*/
export const Delete = createMethodDecorator('DELETE');
/**
* @Patch decorator - handles PATCH requests
*
* @example
* ```typescript
* @Patch('/users/:id')
* patchUser(ctx: IRequestContext<IPartialUser>) { ... }
* ```
*/
export const Patch = createMethodDecorator('PATCH');
/**
* @Head decorator - handles HEAD requests
*/
export const Head = createMethodDecorator('HEAD');
/**
* @Options decorator - handles OPTIONS requests
*/
export const Options = createMethodDecorator('OPTIONS');
/**
* @All decorator - handles all HTTP methods
*
* @example
* ```typescript
* @All('/proxy/*')
* proxyRequest(ctx: IRequestContext) { ... }
* ```
*/
export const All = createMethodDecorator('ALL');

View File

@@ -0,0 +1,198 @@
/**
* Controller registry - stores all registered controllers
*/
import type { IControllerMetadata, IRegisteredController, ICompiledRoute } from './decorators.types.js';
import type { IRequestContext, IInterceptOptions, THttpMethod } from '../core/smartserve.interfaces.js';
import { getControllerMetadata, combinePaths } from './decorators.metadata.js';
/**
* Global registry of all controllers
*/
export class ControllerRegistry {
private static controllers: Map<Function, IControllerMetadata> = new Map();
private static instances: Map<Function, any> = new Map();
private static compiledRoutes: ICompiledRoute[] = [];
private static routesCompiled = false;
/**
* Register a controller class
*/
static registerClass(target: Function): void {
const metadata = getControllerMetadata(target);
metadata.target = target as new (...args: any[]) => any;
this.controllers.set(target, metadata);
this.routesCompiled = false;
}
/**
* Register a controller instance
*/
static registerInstance(instance: any): void {
const constructor = instance.constructor;
const metadata = getControllerMetadata(constructor);
// Store instance
this.instances.set(constructor, instance);
// Register class if not already registered
if (!this.controllers.has(constructor)) {
this.registerClass(constructor);
}
this.routesCompiled = false;
}
/**
* Get all registered controllers
*/
static getControllers(): IRegisteredController[] {
const result: IRegisteredController[] = [];
for (const [constructor, metadata] of this.controllers) {
// Get or create instance
let instance = this.instances.get(constructor);
if (!instance && metadata.target) {
instance = new metadata.target();
this.instances.set(constructor, instance);
}
if (instance) {
result.push({ instance, metadata });
}
}
return result;
}
/**
* Compile all routes for fast matching
*/
static compileRoutes(): ICompiledRoute[] {
if (this.routesCompiled) {
return this.compiledRoutes;
}
this.compiledRoutes = [];
for (const { instance, metadata } of this.getControllers()) {
for (const [methodName, route] of metadata.routes) {
const fullPath = combinePaths(metadata.basePath, route.path);
const { regex, paramNames } = this.pathToRegex(fullPath);
// Combine class and method interceptors
const interceptors: IInterceptOptions[] = [
...metadata.classInterceptors,
...route.interceptors,
];
// Create bound handler
const handler = async (ctx: IRequestContext): Promise<any> => {
const method = instance[methodName];
if (typeof method !== 'function') {
throw new Error(`Method ${String(methodName)} not found on controller`);
}
return method.call(instance, ctx);
};
this.compiledRoutes.push({
pattern: fullPath,
regex,
paramNames,
method: route.method,
handler,
interceptors,
});
}
}
// Sort routes by specificity (more specific paths first)
this.compiledRoutes.sort((a, b) => {
// Routes without wildcards come first
const aHasWildcard = a.pattern.includes('*');
const bHasWildcard = b.pattern.includes('*');
if (aHasWildcard !== bHasWildcard) return aHasWildcard ? 1 : -1;
// Routes with more segments come first
const aSegments = a.pattern.split('/').length;
const bSegments = b.pattern.split('/').length;
if (aSegments !== bSegments) return bSegments - aSegments;
// Routes without params come first
const aParams = a.paramNames.length;
const bParams = b.paramNames.length;
return aParams - bParams;
});
this.routesCompiled = true;
return this.compiledRoutes;
}
/**
* Match a request to a compiled route
*/
static matchRoute(path: string, method: THttpMethod): {
route: ICompiledRoute;
params: Record<string, string>;
} | null {
const routes = this.compileRoutes();
for (const route of routes) {
// Check method match
if (route.method !== 'ALL' && route.method !== method) {
continue;
}
// Check path match
const match = route.regex.exec(path);
if (match) {
// Extract params
const params: Record<string, string> = {};
route.paramNames.forEach((name, index) => {
params[name] = match[index + 1];
});
return { route, params };
}
}
return null;
}
/**
* Convert path pattern to regex
* Supports :param and * wildcard
*/
private static pathToRegex(path: string): { regex: RegExp; paramNames: string[] } {
const paramNames: string[] = [];
let regexStr = path
// Escape special regex chars (except : and *)
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
// Convert :param to capture group
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
paramNames.push(name);
return '([^/]+)';
})
// Convert * to wildcard
.replace(/\*/g, '(.*)');
// Anchor the regex
regexStr = `^${regexStr}$`;
return {
regex: new RegExp(regexStr),
paramNames,
};
}
/**
* Clear all registered controllers (useful for testing)
*/
static clear(): void {
this.controllers.clear();
this.instances.clear();
this.compiledRoutes = [];
this.routesCompiled = false;
}
}

View File

@@ -0,0 +1,45 @@
/**
* @Route class decorator
* Marks a class as a controller with a base path
*/
import type { IRouteOptions } from '../core/smartserve.interfaces.js';
import { setBasePath, normalizePath } from './decorators.metadata.js';
import { ControllerRegistry } from './decorators.registry.js';
/**
* @Route decorator - marks a class as a route controller
*
* @example
* ```typescript
* @Route('/api/users')
* class UserController {
* @Get('/:id')
* getUser(ctx: IRequestContext) { ... }
* }
* ```
*/
export function Route(pathOrOptions?: string | IRouteOptions) {
return function <TClass extends new (...args: any[]) => any>(
target: TClass,
context: ClassDecoratorContext<TClass>
): TClass {
if (context.kind !== 'class') {
throw new Error('@Route can only decorate classes');
}
const path = typeof pathOrOptions === 'string'
? pathOrOptions
: pathOrOptions?.path ?? '';
// Store base path in metadata
setBasePath(target, path);
// Register controller after class initialization
context.addInitializer(function (this: TClass) {
ControllerRegistry.registerClass(target);
});
return target;
};
}

View File

@@ -0,0 +1,76 @@
/**
* Decorator type definitions for @push.rocks/smartserve
* Uses TC39 Stage 3 decorators (TypeScript 5.2+)
*/
import type {
THttpMethod,
IRequestContext,
IInterceptOptions,
IMethodOptions,
IRouteOptions,
} from '../core/smartserve.interfaces.js';
// =============================================================================
// Metadata Types
// =============================================================================
/**
* Metadata stored on controller classes
*/
export interface IControllerMetadata {
/** Base path for all routes in this controller */
basePath: string;
/** Class-level interceptors (apply to all methods) */
classInterceptors: IInterceptOptions[];
/** Route definitions by method name */
routes: Map<string | symbol, IRouteMetadata>;
/** Controller class reference */
target?: new (...args: any[]) => any;
}
/**
* Metadata for individual route methods
*/
export interface IRouteMetadata {
/** HTTP method */
method: THttpMethod;
/** Path segment (combined with class basePath) */
path: string;
/** Method-level interceptors */
interceptors: IInterceptOptions[];
/** Response options */
options: IMethodOptions;
/** Method name */
methodName: string | symbol;
/** Handler function reference */
handler?: Function;
}
/**
* Registered controller with metadata
*/
export interface IRegisteredController {
/** Controller instance */
instance: any;
/** Controller metadata */
metadata: IControllerMetadata;
}
/**
* Compiled route for fast matching
*/
export interface ICompiledRoute {
/** Full path pattern */
pattern: string;
/** Regex for matching */
regex: RegExp;
/** Parameter names */
paramNames: string[];
/** HTTP method */
method: THttpMethod;
/** Handler function */
handler: (ctx: IRequestContext) => Promise<any>;
/** Combined interceptors (class + method) */
interceptors: IInterceptOptions[];
}

47
ts/decorators/index.ts Normal file
View File

@@ -0,0 +1,47 @@
// Type exports
export type {
IControllerMetadata,
IRouteMetadata,
IRegisteredController,
ICompiledRoute,
} from './decorators.types.js';
// Route decorator
export { Route } from './decorators.route.js';
// HTTP method decorators
export {
Get,
Post,
Put,
Delete,
Patch,
Head,
Options,
All,
} from './decorators.methods.js';
// Interceptor decorators
export {
Guard,
Transform,
Intercept,
// Utility guards
hasHeader,
hasBearerToken,
rateLimit,
// Utility transforms
wrapSuccess,
addTimestamp,
} from './decorators.interceptors.js';
// Registry
export { ControllerRegistry } from './decorators.registry.js';
// Metadata utilities
export {
getControllerMetadata,
getMetadataFromInstance,
normalizePath,
combinePaths,
} from './decorators.metadata.js';