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

45
ts/core/index.ts Normal file
View File

@@ -0,0 +1,45 @@
// Main server class
export { SmartServe } from './smartserve.classes.smartserve.js';
// Interfaces
export type {
// HTTP types
THttpMethod,
TRuntime,
// Request/Response
IRequestContext,
TRouteHandler,
IMethodOptions,
IRouteOptions,
// Interceptors
TRequestInterceptor,
TResponseInterceptor,
TGuardFunction,
IInterceptOptions,
IGuardOptions,
// WebSocket
IWebSocketMessage,
IWebSocketPeer,
IWebSocketHooks,
// Server config
ITLSConfig,
IKeepAliveConfig,
IStaticOptions,
IDirectoryListingOptions,
IFileEntry,
IWebDAVConfig,
ISmartServeOptions,
// Server instance
IServerStats,
ISmartServeInstance,
IConnectionInfo,
} from './smartserve.interfaces.js';
// Errors
export {
HttpError,
RouteNotFoundError,
UnsupportedRuntimeError,
ServerAlreadyRunningError,
ServerNotRunningError,
} from './smartserve.errors.js';

View File

@@ -0,0 +1,379 @@
/**
* 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' },
}
);
}
}

View File

@@ -0,0 +1,129 @@
/**
* Custom error classes for @push.rocks/smartserve
*/
/**
* HTTP error with status code
* Thrown from handlers to return specific HTTP responses
*/
export class HttpError extends Error {
public readonly status: number;
public readonly details?: Record<string, unknown>;
constructor(
status: number,
message: string,
details?: Record<string, unknown>
) {
super(message);
this.name = 'HttpError';
this.status = status;
this.details = details;
}
/**
* Convert to HTTP Response
*/
toResponse(): Response {
return new Response(
JSON.stringify({
error: this.message,
status: this.status,
...(this.details ? { details: this.details } : {}),
}),
{
status: this.status,
headers: { 'Content-Type': 'application/json' },
}
);
}
// Common HTTP errors as static factory methods
static badRequest(message = 'Bad Request', details?: Record<string, unknown>): HttpError {
return new HttpError(400, message, details);
}
static unauthorized(message = 'Unauthorized', details?: Record<string, unknown>): HttpError {
return new HttpError(401, message, details);
}
static forbidden(message = 'Forbidden', details?: Record<string, unknown>): HttpError {
return new HttpError(403, message, details);
}
static notFound(message = 'Not Found', details?: Record<string, unknown>): HttpError {
return new HttpError(404, message, details);
}
static methodNotAllowed(message = 'Method Not Allowed', details?: Record<string, unknown>): HttpError {
return new HttpError(405, message, details);
}
static conflict(message = 'Conflict', details?: Record<string, unknown>): HttpError {
return new HttpError(409, message, details);
}
static unprocessableEntity(message = 'Unprocessable Entity', details?: Record<string, unknown>): HttpError {
return new HttpError(422, message, details);
}
static tooManyRequests(message = 'Too Many Requests', details?: Record<string, unknown>): HttpError {
return new HttpError(429, message, details);
}
static internalServerError(message = 'Internal Server Error', details?: Record<string, unknown>): HttpError {
return new HttpError(500, message, details);
}
static notImplemented(message = 'Not Implemented', details?: Record<string, unknown>): HttpError {
return new HttpError(501, message, details);
}
static badGateway(message = 'Bad Gateway', details?: Record<string, unknown>): HttpError {
return new HttpError(502, message, details);
}
static serviceUnavailable(message = 'Service Unavailable', details?: Record<string, unknown>): HttpError {
return new HttpError(503, message, details);
}
}
/**
* Error thrown when route is not found
*/
export class RouteNotFoundError extends HttpError {
constructor(path: string, method: string) {
super(404, `Route not found: ${method} ${path}`, { path, method });
this.name = 'RouteNotFoundError';
}
}
/**
* Error thrown when adapter is not supported
*/
export class UnsupportedRuntimeError extends Error {
constructor(runtime: string) {
super(`Unsupported runtime: ${runtime}`);
this.name = 'UnsupportedRuntimeError';
}
}
/**
* Error thrown when server is already running
*/
export class ServerAlreadyRunningError extends Error {
constructor() {
super('Server is already running');
this.name = 'ServerAlreadyRunningError';
}
}
/**
* Error thrown when server is not running
*/
export class ServerNotRunningError extends Error {
constructor() {
super('Server is not running');
this.name = 'ServerNotRunningError';
}
}

View File

@@ -0,0 +1,348 @@
/**
* Core interfaces for @push.rocks/smartserve
* Uses Web Standards API (Request/Response) for cross-platform compatibility
*/
// =============================================================================
// HTTP Types
// =============================================================================
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'ALL';
export type TRuntime = 'node' | 'deno' | 'bun';
// =============================================================================
// Request Context
// =============================================================================
/**
* Request context passed to handlers and interceptors
* Wraps Web Standard Request with additional utilities
*/
export interface IRequestContext<TBody = unknown> {
/** Original Web Standards Request */
readonly request: Request;
/** Parsed request body (typed) */
readonly body: TBody;
/** URL path parameters extracted from route */
readonly params: Record<string, string>;
/** URL query parameters */
readonly query: Record<string, string>;
/** Request headers accessor */
readonly headers: Headers;
/** Matched route path pattern */
readonly path: string;
/** HTTP method */
readonly method: THttpMethod;
/** Full URL object */
readonly url: URL;
/** Runtime environment */
readonly runtime: TRuntime;
/** Route-specific state bag for passing data between interceptors */
state: Record<string, unknown>;
}
// =============================================================================
// Interceptor Types
// =============================================================================
/**
* Request interceptor - runs BEFORE the handler
* Can:
* - Return modified context to continue
* - Return Response to short-circuit
* - Return void/undefined to continue with original context
* - Throw to trigger error handling
*/
export type TRequestInterceptor<TBody = unknown> = (
ctx: IRequestContext<TBody>
) => Promise<IRequestContext<TBody> | Response | void> | IRequestContext<TBody> | Response | void;
/**
* Response interceptor - runs AFTER the handler
* Can:
* - Return modified response data
* - Return a Response object directly
*/
export type TResponseInterceptor<TRes = unknown> = (
response: TRes,
ctx: IRequestContext
) => Promise<TRes | Response> | TRes | Response;
/**
* Guard function - simplified boolean check for authorization
* Returns true to allow, false to reject with 403
*/
export type TGuardFunction<TBody = unknown> = (
ctx: IRequestContext<TBody>
) => Promise<boolean> | boolean;
/**
* Combined interceptor options for @Intercept decorator
*/
export interface IInterceptOptions<TBody = unknown, TRes = unknown> {
/** Request interceptors (run before handler) */
request?: TRequestInterceptor<TBody> | TRequestInterceptor<TBody>[];
/** Response interceptors (run after handler) */
response?: TResponseInterceptor<TRes> | TResponseInterceptor<TRes>[];
}
/**
* Options for @Guard decorator
*/
export interface IGuardOptions {
/** Custom response when guard rejects (default: 403 Forbidden) */
onReject?: (ctx: IRequestContext) => Response | Promise<Response>;
}
// =============================================================================
// Route Handler Types
// =============================================================================
/**
* Route handler function signature
*/
export type TRouteHandler<TReq = unknown, TRes = unknown> = (
ctx: IRequestContext<TReq>
) => Promise<TRes> | TRes;
/**
* Options for method decorators (@Get, @Post, etc.)
*/
export interface IMethodOptions {
/** Path segment (appended to class route) */
path?: string;
/** Content-Type for response */
contentType?: string;
/** HTTP status code for successful response */
statusCode?: number;
}
/**
* Options for @Route class decorator
*/
export interface IRouteOptions {
/** Base path for all routes in this controller */
path?: string;
}
// =============================================================================
// WebSocket Types
// =============================================================================
/**
* WebSocket message types
*/
export interface IWebSocketMessage {
type: 'text' | 'binary';
text?: string;
data?: Uint8Array;
size: number;
}
/**
* WebSocket peer connection
*/
export interface IWebSocketPeer {
/** Unique connection ID */
id: string;
/** Connection URL */
url: string;
/** WebSocket ready state */
readyState: 0 | 1 | 2 | 3;
/** Negotiated subprotocol */
protocol: string;
/** Negotiated extensions */
extensions: string;
/** Send text message */
send(data: string): void;
/** Send binary message */
sendBinary(data: Uint8Array | ArrayBuffer): void;
/** Close connection */
close(code?: number, reason?: string): void;
/** Send ping */
ping(data?: Uint8Array): void;
/** Force close without handshake */
terminate(): void;
/** Request context from upgrade */
context: IRequestContext;
/** Custom per-peer data storage */
data: Map<string, unknown>;
}
/**
* WebSocket event hooks
*/
export interface IWebSocketHooks {
onOpen?: (peer: IWebSocketPeer) => void | Promise<void>;
onMessage?: (peer: IWebSocketPeer, message: IWebSocketMessage) => void | Promise<void>;
onClose?: (peer: IWebSocketPeer, code: number, reason: string) => void | Promise<void>;
onError?: (peer: IWebSocketPeer, error: Error) => void | Promise<void>;
onPing?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>;
onPong?: (peer: IWebSocketPeer, data: Uint8Array) => void | Promise<void>;
}
// =============================================================================
// Server Configuration
// =============================================================================
/**
* TLS/SSL configuration
*/
export interface ITLSConfig {
/** Certificate (PEM format) */
cert: string | Uint8Array;
/** Private key (PEM format) */
key: string | Uint8Array;
/** CA chain (PEM format) */
ca?: string | Uint8Array;
/** ALPN protocols */
alpnProtocols?: string[];
/** Minimum TLS version */
minVersion?: 'TLSv1.2' | 'TLSv1.3';
/** Passphrase for encrypted key */
passphrase?: string;
}
/**
* Keep-alive configuration
*/
export interface IKeepAliveConfig {
enabled: boolean;
timeout?: number;
maxRequests?: number;
}
/**
* Static file serving options
*/
export interface IStaticOptions {
/** Root directory path */
root: string;
/** Index files to look for */
index?: string[];
/** How to handle dotfiles */
dotFiles?: 'allow' | 'deny' | 'ignore';
/** Generate ETags */
etag?: boolean;
/** Add Last-Modified header */
lastModified?: boolean;
/** Cache-Control header value or function */
cacheControl?: string | ((path: string) => string);
/** File extensions to try */
extensions?: string[];
/** Enable directory listing */
directoryListing?: boolean | IDirectoryListingOptions;
}
/**
* Directory listing options
*/
export interface IDirectoryListingOptions {
/** Custom template function */
template?: (files: IFileEntry[]) => string | Response;
/** Show hidden files */
showHidden?: boolean;
/** Sort field */
sortBy?: 'name' | 'size' | 'modified';
/** Sort order */
sortOrder?: 'asc' | 'desc';
}
/**
* File entry for directory listing
*/
export interface IFileEntry {
name: string;
path: string;
isDirectory: boolean;
size: number;
modified: Date;
}
/**
* WebDAV configuration
*/
export interface IWebDAVConfig {
/** Root directory path */
root: string;
/** Authentication handler */
auth?: (ctx: IRequestContext) => boolean | Promise<boolean>;
/** Enable locking */
locking?: boolean;
}
/**
* Main server configuration
*/
export interface ISmartServeOptions {
/** Port to listen on */
port: number;
/** Hostname to bind to */
hostname?: string;
/** TLS configuration for HTTPS */
tls?: ITLSConfig;
/** WebSocket configuration */
websocket?: IWebSocketHooks;
/** Static file serving */
static?: IStaticOptions | string;
/** WebDAV configuration */
webdav?: IWebDAVConfig;
/** Connection timeout (ms) */
connectionTimeout?: number;
/** Keep-alive settings */
keepAlive?: IKeepAliveConfig;
/** Global error handler */
onError?: (error: Error, request?: Request) => Response | Promise<Response>;
}
// =============================================================================
// Server Instance
// =============================================================================
/**
* Server statistics
*/
export interface IServerStats {
uptime: number;
requestsTotal: number;
requestsActive: number;
connectionsTotal: number;
connectionsActive: number;
bytesReceived: number;
bytesSent: number;
}
/**
* Running server instance
*/
export interface ISmartServeInstance {
/** Listening port */
port: number;
/** Bound hostname */
hostname: string;
/** Is HTTPS enabled */
secure: boolean;
/** Runtime environment */
runtime: TRuntime;
/** Stop the server */
stop(): Promise<void>;
/** Get server statistics */
stats(): IServerStats;
}
// =============================================================================
// Connection Info
// =============================================================================
/**
* Connection information
*/
export interface IConnectionInfo {
remoteAddr: string;
remotePort: number;
localAddr: string;
localPort: number;
encrypted: boolean;
tlsVersion?: string;
}