Files
smartserve/ts/core/smartserve.classes.smartserve.ts

586 lines
17 KiB
TypeScript

/**
* 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';
import { createOpenApiHandler, createSwaggerUiHandler } from '../openapi/openapi.handlers.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<string, IWebSocketPeer> | 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<ISmartServeInstance> {
if (this.instance) {
throw new ServerAlreadyRunningError();
}
// Register OpenAPI endpoints if configured
if (this.options.openapi && this.options.openapi.enabled !== false) {
this.setupOpenApi();
}
// 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<void> {
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);
}
}
}
}
/**
* Setup OpenAPI documentation endpoints
*/
private setupOpenApi(): void {
const openapi = this.options.openapi!;
const specPath = openapi.specPath ?? '/openapi.json';
const docsPath = openapi.docsPath ?? '/docs';
// Create generator options
const generatorOptions = {
info: openapi.info,
servers: openapi.servers,
securitySchemes: openapi.securitySchemes,
tags: openapi.tags,
};
// Register OpenAPI spec endpoint
ControllerRegistry.addRoute(specPath, 'GET', createOpenApiHandler(generatorOptions));
// Register Swagger UI endpoint
ControllerRegistry.addRoute(docsPath, 'GET', createSwaggerUiHandler(specPath, openapi.info.title));
}
/**
* Create the main request handler
*/
private createRequestHandler(): TRequestHandler {
return async (request: Request, connectionInfo: IConnectionInfo): Promise<Response> => {
// 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<string, string>,
connectionInfo: IConnectionInfo
): Promise<IRequestContext> {
// Parse query params
const query: Record<string, string> = {};
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<Response> {
// 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<Response> {
// 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' },
}
);
}
}