/** * Main SmartServe server class * Cross-platform HTTP server with decorator-based routing */ import * as plugins from '../plugins.js'; import type { ISmartServeOptions, ISmartServeInstance, IRequestContext, IConnectionInfo, THttpMethod, IInterceptOptions, TRequestInterceptor, TResponseInterceptor, IWebSocketPeer, IWebSocketConnectionCallbacks, } from './smartserve.interfaces.js'; import { HttpError, RouteNotFoundError, ServerAlreadyRunningError, WebSocketConfigError } from './smartserve.errors.js'; import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js'; import { ControllerRegistry, type ICompiledRoute, type IRouteCompressionOptions } from '../decorators/index.js'; import { FileServer } from '../files/index.js'; import { WebDAVHandler } from '../protocols/index.js'; import { normalizeCompressionConfig, shouldCompressResponse, selectCompressionAlgorithm, compressResponse, type ICompressionConfig, } from '../compression/index.js'; /** * SmartServe - Cross-platform HTTP server * * @example * ```typescript * const server = new SmartServe({ port: 3000 }); * * // Register decorated controllers * server.register(UserController); * server.register(ProductController); * * await server.start(); * ``` */ export class SmartServe { private options: ISmartServeOptions; private adapter: BaseAdapter | null = null; private instance: ISmartServeInstance | null = null; private customHandler: TRequestHandler | null = null; private fileServer: FileServer | null = null; private webdavHandler: WebDAVHandler | null = null; private compressionConfig: ICompressionConfig; /** WebSocket connection registry (only active when typedRouter is set) */ private wsConnections: Map | null = null; constructor(options: ISmartServeOptions) { // Validate websocket configuration - mutual exclusivity if (options.websocket) { const { typedRouter, onMessage } = options.websocket; if (typedRouter && onMessage) { throw new WebSocketConfigError( 'Cannot use both typedRouter and onMessage. ' + 'typedRouter handles message routing automatically.' ); } } this.options = { hostname: '0.0.0.0', ...options, }; // Initialize compression config (enabled by default) this.compressionConfig = normalizeCompressionConfig(options.compression); // Initialize connection registry only when typedRouter is configured if (this.options.websocket?.typedRouter) { this.wsConnections = new Map(); } // Initialize file server if static options provided if (this.options.static) { this.fileServer = new FileServer(this.options.static); } // Initialize WebDAV handler if configured if (this.options.webdav) { this.webdavHandler = new WebDAVHandler(this.options.webdav); } } /** * Register a controller class or instance */ register(controllerOrInstance: Function | object): this { if (typeof controllerOrInstance === 'function') { // It's a class constructor const instance = new (controllerOrInstance as new () => any)(); ControllerRegistry.registerInstance(instance); } else { // It's an instance ControllerRegistry.registerInstance(controllerOrInstance); } return this; } /** * Set a custom request handler (bypasses decorator routing) */ setHandler(handler: TRequestHandler): this { this.customHandler = handler; return this; } /** * Start the server */ async start(): Promise { if (this.instance) { throw new ServerAlreadyRunningError(); } // Prepare options with internal callbacks if typedRouter is configured let adapterOptions = this.options; if (this.options.websocket?.typedRouter && this.wsConnections) { // Clone options and add internal callbacks for adapter communication const connectionCallbacks: IWebSocketConnectionCallbacks = { onRegister: (peer: IWebSocketPeer) => { this.wsConnections!.set(peer.id, peer); this.options.websocket?.onConnectionOpen?.(peer); }, onUnregister: (peerId: string) => { const peer = this.wsConnections!.get(peerId); if (peer) { this.wsConnections!.delete(peerId); this.options.websocket?.onConnectionClose?.(peer); } }, }; adapterOptions = { ...this.options, websocket: { ...this.options.websocket, // Internal property for adapter communication (not part of public API) _connectionCallbacks: connectionCallbacks, } as typeof this.options.websocket & { _connectionCallbacks: IWebSocketConnectionCallbacks }, }; } // Create adapter for current runtime this.adapter = await AdapterFactory.createAdapter(adapterOptions); // Create request handler const handler = this.createRequestHandler(); // Start server this.instance = await this.adapter.start(handler); return this.instance; } /** * Stop the server */ async stop(): Promise { if (this.adapter) { await this.adapter.stop(); this.adapter = null; this.instance = null; } } /** * Get server instance (if running) */ getInstance(): ISmartServeInstance | null { return this.instance; } /** * Check if server is running */ isRunning(): boolean { return this.instance !== null; } // =========================================================================== // WebSocket Connection Management (only available with typedRouter) // =========================================================================== /** * Get all active WebSocket connections * Only available when typedRouter is configured */ getWebSocketConnections(): IWebSocketPeer[] { if (!this.wsConnections) { return []; } return Array.from(this.wsConnections.values()); } /** * Get WebSocket connections filtered by tag * Only available when typedRouter is configured */ getWebSocketConnectionsByTag(tag: string): IWebSocketPeer[] { if (!this.wsConnections) { return []; } return Array.from(this.wsConnections.values()).filter((peer) => peer.tags.has(tag)); } /** * Broadcast message to all WebSocket connections * Only available when typedRouter is configured */ broadcastWebSocket(data: string | object): void { if (!this.wsConnections) { return; } const message = typeof data === 'string' ? data : JSON.stringify(data); for (const peer of this.wsConnections.values()) { try { peer.send(message); } catch (error) { console.error(`Failed to send to peer ${peer.id}:`, error); } } } /** * Broadcast message to WebSocket connections with specific tag * Only available when typedRouter is configured */ broadcastWebSocketByTag(tag: string, data: string | object): void { if (!this.wsConnections) { return; } const message = typeof data === 'string' ? data : JSON.stringify(data); for (const peer of this.wsConnections.values()) { if (peer.tags.has(tag)) { try { peer.send(message); } catch (error) { console.error(`Failed to send to peer ${peer.id}:`, error); } } } } /** * Create the main request handler */ private createRequestHandler(): TRequestHandler { return async (request: Request, connectionInfo: IConnectionInfo): Promise => { // Use custom handler if set if (this.customHandler) { const response = await this.customHandler(request, connectionInfo); // Apply compression to custom handler responses return this.applyCompression(response, request); } // Parse URL and method const url = new URL(request.url); const method = request.method.toUpperCase() as THttpMethod; // Handle WebDAV requests first if handler is configured if (this.webdavHandler && this.webdavHandler.isWebDAVRequest(request)) { try { const response = await this.webdavHandler.handle(request); return this.applyCompression(response, request); } catch (error) { return this.handleError(error as Error, request); } } // Match route first const match = ControllerRegistry.matchRoute(url.pathname, method); if (!match) { // No route found, try WebDAV for GET/PUT/DELETE/HEAD (standard HTTP methods WebDAV also handles) if (this.webdavHandler) { try { const response = await this.webdavHandler.handle(request); return this.applyCompression(response, request); } catch (error) { return this.handleError(error as Error, request); } } // Try static files if (this.fileServer && (method === 'GET' || method === 'HEAD')) { try { const staticResponse = await this.fileServer.serve(request); if (staticResponse) { // Apply compression to static file responses return this.applyCompression(staticResponse, request); } } catch (error) { return this.handleError(error as Error, request); } } // Still no match, return 404 const error = new RouteNotFoundError(url.pathname, method); if (this.options.onError) { return this.options.onError(error, request); } return error.toResponse(); } const { route, params } = match; try { // Create request context const context = await this.createContext(request, url, params, connectionInfo); // Run interceptors and handler const response = await this.executeRoute(route, context); // Apply compression with route-specific settings return this.applyCompression(response, request, route.compression); } catch (error) { return this.handleError(error as Error, request); } }; } /** * Create request context from Request object */ private async createContext( request: Request, url: URL, params: Record, connectionInfo: IConnectionInfo ): Promise { // Parse query params const query: Record = {}; url.searchParams.forEach((value, key) => { query[key] = value; }); // Parse body (lazy) let body: any = undefined; const contentType = request.headers.get('content-type'); if (request.method !== 'GET' && request.method !== 'HEAD') { if (contentType?.includes('application/json')) { try { body = await request.json(); } catch { body = null; } } else if (contentType?.includes('application/x-www-form-urlencoded')) { try { const text = await request.text(); body = Object.fromEntries(new URLSearchParams(text)); } catch { body = null; } } else if (contentType?.includes('text/')) { try { body = await request.text(); } catch { body = null; } } } return { request, body, params, query, headers: request.headers, path: url.pathname, method: request.method.toUpperCase() as THttpMethod, url, runtime: this.adapter?.name ?? 'node', state: {}, }; } /** * Execute route with interceptor chain */ private async executeRoute( route: ICompiledRoute, context: IRequestContext ): Promise { // Collect all request interceptors const requestInterceptors: TRequestInterceptor[] = []; const responseInterceptors: TResponseInterceptor[] = []; for (const interceptor of route.interceptors) { if (interceptor.request) { const reqs = Array.isArray(interceptor.request) ? interceptor.request : [interceptor.request]; requestInterceptors.push(...reqs); } if (interceptor.response) { const ress = Array.isArray(interceptor.response) ? interceptor.response : [interceptor.response]; responseInterceptors.push(...ress); } } // Run request interceptors let currentContext = context; for (const interceptor of requestInterceptors) { const result = await interceptor(currentContext); if (result instanceof Response) { // Short-circuit with response return result; } if (result && typeof result === 'object' && 'request' in result) { // Updated context currentContext = result as IRequestContext; } // undefined means continue with current context } // Execute handler let handlerResult = await route.handler(currentContext); // Run response interceptors (in reverse order for onion model) for (let i = responseInterceptors.length - 1; i >= 0; i--) { const interceptor = responseInterceptors[i]; const result = await interceptor(handlerResult, currentContext); if (result instanceof Response) { return result; } handlerResult = result; } // Convert result to Response return this.resultToResponse(handlerResult); } /** * Convert handler result to Response */ private resultToResponse(result: any): Response { // Already a Response if (result instanceof Response) { return result; } // Null/undefined if (result === null || result === undefined) { return new Response(null, { status: 204 }); } // String if (typeof result === 'string') { return new Response(result, { headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); } // Object/Array - serialize as JSON return new Response(JSON.stringify(result), { headers: { 'Content-Type': 'application/json' }, }); } /** * Apply compression to response if applicable */ private async applyCompression( response: Response, request: Request, routeCompression?: IRouteCompressionOptions ): Promise { // Check route-level override first if (routeCompression?.enabled === false) { return response; } // Build effective config (merge route settings with global) const effectiveConfig: ICompressionConfig = { ...this.compressionConfig, }; // Route-level compression settings override global if (routeCompression?.level !== undefined) { effectiveConfig.level = routeCompression.level; } // If route forces compression, ensure it's enabled if (routeCompression?.enabled === true) { effectiveConfig.enabled = true; } // Check if compression should be applied if (!shouldCompressResponse(response, request, effectiveConfig)) { return response; } // Select best algorithm const algorithm = selectCompressionAlgorithm(request, effectiveConfig); if (algorithm === 'identity') { return response; } // Apply compression return compressResponse(response, algorithm, effectiveConfig.level, effectiveConfig.threshold); } /** * Handle errors */ private handleError(error: Error, request: Request): Response { // Custom error handler if (this.options.onError) { try { const result = this.options.onError(error, request); if (result instanceof Promise) { // Can't await here, return 500 console.error('Error in error handler:', error); return new Response('Internal Server Error', { status: 500 }); } return result; } catch { // Error in error handler } } // HttpError if (error instanceof HttpError) { return error.toResponse(); } // Unknown error console.error('Unhandled error:', error); return new Response( JSON.stringify({ error: 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, } ); } }