Files
smartserve/ts/core/smartserve.classes.smartserve.ts
2025-11-29 15:24:00 +00:00

380 lines
10 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,
} from './smartserve.interfaces.js';
import { HttpError, RouteNotFoundError, ServerAlreadyRunningError } from './smartserve.errors.js';
import { AdapterFactory, type BaseAdapter, type TRequestHandler } from '../adapters/index.js';
import { ControllerRegistry, type ICompiledRoute } from '../decorators/index.js';
import { FileServer } from '../files/index.js';
import { WebDAVHandler } from '../protocols/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;
constructor(options: ISmartServeOptions) {
this.options = {
hostname: '0.0.0.0',
...options,
};
// 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();
}
// Create adapter for current runtime
this.adapter = await AdapterFactory.createAdapter(this.options);
// 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;
}
/**
* Create the main request handler
*/
private createRequestHandler(): TRequestHandler {
return async (request: Request, connectionInfo: IConnectionInfo): Promise<Response> => {
// Use custom handler if set
if (this.customHandler) {
return this.customHandler(request, connectionInfo);
}
// 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 {
return await this.webdavHandler.handle(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 {
return await this.webdavHandler.handle(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) {
return staticResponse;
}
} 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
return await this.executeRoute(route, context);
} 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' },
});
}
/**
* 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' },
}
);
}
}