586 lines
17 KiB
TypeScript
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' },
|
|
}
|
|
);
|
|
}
|
|
}
|