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

132
ts/adapters/adapter.base.ts Normal file
View File

@@ -0,0 +1,132 @@
import * as plugins from '../plugins.js';
import type {
ISmartServeOptions,
ISmartServeInstance,
IRequestContext,
IConnectionInfo,
IServerStats,
TRuntime,
THttpMethod,
} from '../core/smartserve.interfaces.js';
/**
* Adapter characteristics - what each runtime supports
*/
export interface IAdapterCharacteristics {
/** Zero-copy streaming support */
zeroCopyStreaming: boolean;
/** HTTP/2 support */
http2Support: boolean;
/** Maximum concurrent connections */
maxConnections: number | 'unlimited';
/** Native WebSocket upgrade support */
nativeWebSocket: boolean;
}
/**
* Handler function that receives web standard Request and returns Response
*/
export type TRequestHandler = (
request: Request,
info: IConnectionInfo
) => Response | Promise<Response>;
/**
* Abstract base adapter for all runtime implementations
*/
export abstract class BaseAdapter {
protected options: ISmartServeOptions;
protected handler: TRequestHandler | null = null;
protected stats: IServerStats = {
uptime: 0,
requestsTotal: 0,
requestsActive: 0,
connectionsTotal: 0,
connectionsActive: 0,
bytesReceived: 0,
bytesSent: 0,
};
protected startTime: number = 0;
constructor(options: ISmartServeOptions) {
this.options = options;
}
/**
* Runtime name
*/
abstract get name(): TRuntime;
/**
* Adapter characteristics
*/
abstract get characteristics(): IAdapterCharacteristics;
/**
* Check if this adapter is supported in current runtime
*/
abstract isSupported(): boolean;
/**
* Start the server
*/
abstract start(handler: TRequestHandler): Promise<ISmartServeInstance>;
/**
* Stop the server
*/
abstract stop(): Promise<void>;
/**
* Get current server statistics
*/
getStats(): IServerStats {
return {
...this.stats,
uptime: this.startTime > 0 ? Date.now() - this.startTime : 0,
};
}
/**
* Create ISmartServeInstance from adapter
*/
protected createInstance(): ISmartServeInstance {
return {
port: this.options.port,
hostname: this.options.hostname ?? '0.0.0.0',
secure: !!this.options.tls,
runtime: this.name,
stop: () => this.stop(),
stats: () => this.getStats(),
};
}
/**
* Parse URL from request for cross-platform compatibility
*/
protected parseUrl(request: Request): URL {
return new URL(request.url);
}
/**
* Parse query parameters from URL
*/
protected parseQuery(url: URL): Record<string, string> {
const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
return query;
}
/**
* Get HTTP method from request
*/
protected parseMethod(request: Request): THttpMethod {
const method = request.method.toUpperCase();
if (['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'].includes(method)) {
return method as THttpMethod;
}
return 'GET';
}
}

161
ts/adapters/adapter.bun.ts Normal file
View File

@@ -0,0 +1,161 @@
import type { ISmartServeInstance, IConnectionInfo } from '../core/smartserve.interfaces.js';
import { BaseAdapter, type IAdapterCharacteristics, type TRequestHandler } from './adapter.base.js';
// Bun types (for type checking without requiring Bun runtime)
declare const Bun: {
serve(options: any): { stop(): void; port: number; hostname: string };
file(path: string): any;
};
/**
* Bun adapter - zero overhead, native Request/Response
*/
export class BunAdapter extends BaseAdapter {
private server: { stop(): void; port: number; hostname: string } | null = null;
get name(): 'bun' {
return 'bun';
}
get characteristics(): IAdapterCharacteristics {
return {
zeroCopyStreaming: true,
http2Support: false, // Bun currently HTTP/1.1 only
maxConnections: 'unlimited',
nativeWebSocket: true,
};
}
isSupported(): boolean {
return typeof (globalThis as any).Bun !== 'undefined';
}
async start(handler: TRequestHandler): Promise<ISmartServeInstance> {
if (!this.isSupported()) {
throw new Error('Bun runtime is not available');
}
this.handler = handler;
this.startTime = Date.now();
const serveOptions: any = {
port: this.options.port,
hostname: this.options.hostname ?? '0.0.0.0',
fetch: async (request: Request, server: any) => {
this.stats.requestsTotal++;
this.stats.requestsActive++;
try {
// Handle WebSocket upgrade
if (this.options.websocket && request.headers.get('upgrade') === 'websocket') {
const upgraded = server.upgrade(request);
if (upgraded) {
return undefined; // Bun handles the upgrade
}
}
// Create connection info
const connectionInfo: IConnectionInfo = {
remoteAddr: server.requestIP(request)?.address ?? 'unknown',
remotePort: 0,
localAddr: this.options.hostname ?? '0.0.0.0',
localPort: this.options.port,
encrypted: !!this.options.tls,
};
return await handler(request, connectionInfo);
} catch (error) {
if (this.options.onError) {
return this.options.onError(error as Error, request);
}
return new Response('Internal Server Error', { status: 500 });
} finally {
this.stats.requestsActive--;
}
},
};
// Add TLS if configured
if (this.options.tls) {
serveOptions.tls = {
cert: typeof this.options.tls.cert === 'string'
? Bun.file(this.options.tls.cert)
: this.options.tls.cert,
key: typeof this.options.tls.key === 'string'
? Bun.file(this.options.tls.key)
: this.options.tls.key,
passphrase: this.options.tls.passphrase,
};
}
// Add WebSocket handlers if configured
if (this.options.websocket) {
serveOptions.websocket = this.createWebSocketHandler();
}
this.server = Bun.serve(serveOptions);
return this.createInstance();
}
async stop(): Promise<void> {
if (this.server) {
this.server.stop();
this.server = null;
}
}
private createWebSocketHandler(): any {
const hooks = this.options.websocket;
if (!hooks) return undefined;
return {
open: (ws: any) => {
const peer = this.wrapBunWebSocket(ws);
hooks.onOpen?.(peer);
},
message: (ws: any, message: string | ArrayBuffer) => {
const peer = this.wrapBunWebSocket(ws);
const msg = {
type: typeof message === 'string' ? 'text' as const : 'binary' as const,
text: typeof message === 'string' ? message : undefined,
data: message instanceof ArrayBuffer ? new Uint8Array(message) : undefined,
size: typeof message === 'string' ? message.length : (message as ArrayBuffer).byteLength,
};
hooks.onMessage?.(peer, msg);
},
close: (ws: any, code: number, reason: string) => {
const peer = this.wrapBunWebSocket(ws);
hooks.onClose?.(peer, code, reason);
},
error: (ws: any, error: Error) => {
const peer = this.wrapBunWebSocket(ws);
hooks.onError?.(peer, error);
},
ping: (ws: any, data: ArrayBuffer) => {
const peer = this.wrapBunWebSocket(ws);
hooks.onPing?.(peer, new Uint8Array(data));
},
pong: (ws: any, data: ArrayBuffer) => {
const peer = this.wrapBunWebSocket(ws);
hooks.onPong?.(peer, new Uint8Array(data));
},
};
}
private wrapBunWebSocket(ws: any): any {
return {
id: ws.data?.id ?? crypto.randomUUID(),
url: ws.data?.url ?? '',
get readyState() { return ws.readyState; },
protocol: ws.protocol ?? '',
extensions: ws.extensions ?? '',
send: (data: string) => ws.send(data),
sendBinary: (data: Uint8Array | ArrayBuffer) => ws.send(data),
close: (code?: number, reason?: string) => ws.close(code, reason),
ping: (data?: Uint8Array) => ws.ping(data),
terminate: () => ws.terminate(),
context: {} as any,
data: ws.data?.customData ?? new Map(),
};
}
}

141
ts/adapters/adapter.deno.ts Normal file
View File

@@ -0,0 +1,141 @@
import type { ISmartServeInstance, IConnectionInfo } from '../core/smartserve.interfaces.js';
import { BaseAdapter, type IAdapterCharacteristics, type TRequestHandler } from './adapter.base.js';
// Deno types (for type checking without requiring Deno runtime)
declare const Deno: {
serve(options: any, handler?: any): { shutdown(): Promise<void>; finished: Promise<void> };
upgradeWebSocket(request: Request): { socket: WebSocket; response: Response };
};
/**
* Deno adapter - zero overhead, native Request/Response
*/
export class DenoAdapter extends BaseAdapter {
private server: { shutdown(): Promise<void>; finished: Promise<void> } | null = null;
get name(): 'deno' {
return 'deno';
}
get characteristics(): IAdapterCharacteristics {
return {
zeroCopyStreaming: true,
http2Support: true,
maxConnections: 'unlimited',
nativeWebSocket: true,
};
}
isSupported(): boolean {
return typeof (globalThis as any).Deno !== 'undefined';
}
async start(handler: TRequestHandler): Promise<ISmartServeInstance> {
if (!this.isSupported()) {
throw new Error('Deno runtime is not available');
}
this.handler = handler;
this.startTime = Date.now();
const serveOptions: any = {
port: this.options.port,
hostname: this.options.hostname ?? '0.0.0.0',
};
// Add TLS if configured
if (this.options.tls) {
serveOptions.cert = this.options.tls.cert;
serveOptions.key = this.options.tls.key;
}
this.server = Deno.serve(serveOptions, async (request: Request, info: any) => {
this.stats.requestsTotal++;
this.stats.requestsActive++;
try {
// Handle WebSocket upgrade
if (this.options.websocket && request.headers.get('upgrade') === 'websocket') {
const { socket, response } = Deno.upgradeWebSocket(request);
this.attachWebSocketHooks(socket, request);
return response;
}
// Create connection info
const connectionInfo: IConnectionInfo = {
remoteAddr: info?.remoteAddr?.hostname ?? 'unknown',
remotePort: info?.remoteAddr?.port ?? 0,
localAddr: this.options.hostname ?? '0.0.0.0',
localPort: this.options.port,
encrypted: !!this.options.tls,
};
return await handler(request, connectionInfo);
} catch (error) {
if (this.options.onError) {
return this.options.onError(error as Error, request);
}
return new Response('Internal Server Error', { status: 500 });
} finally {
this.stats.requestsActive--;
}
});
return this.createInstance();
}
async stop(): Promise<void> {
if (this.server) {
await this.server.shutdown();
await this.server.finished;
this.server = null;
}
}
private attachWebSocketHooks(socket: WebSocket, request: Request): void {
const hooks = this.options.websocket;
if (!hooks) return;
const peer = this.createWebSocketPeer(socket, request);
socket.onopen = () => {
hooks.onOpen?.(peer);
};
socket.onmessage = (event) => {
const message = {
type: typeof event.data === 'string' ? 'text' as const : 'binary' as const,
text: typeof event.data === 'string' ? event.data : undefined,
data: event.data instanceof Uint8Array ? event.data : undefined,
size: typeof event.data === 'string' ? event.data.length : (event.data as ArrayBuffer).byteLength,
};
hooks.onMessage?.(peer, message);
};
socket.onclose = (event) => {
hooks.onClose?.(peer, event.code, event.reason);
};
socket.onerror = (event) => {
hooks.onError?.(peer, new Error('WebSocket error'));
};
}
private createWebSocketPeer(socket: WebSocket, request: Request): any {
const url = this.parseUrl(request);
return {
id: crypto.randomUUID(),
url: url.pathname,
get readyState() { return socket.readyState; },
protocol: socket.protocol,
extensions: socket.extensions,
send: (data: string) => socket.send(data),
sendBinary: (data: Uint8Array | ArrayBuffer) => socket.send(data),
close: (code?: number, reason?: string) => socket.close(code, reason),
ping: () => { /* Deno handles ping/pong automatically */ },
terminate: () => socket.close(),
context: {} as any, // Will be populated with IRequestContext
data: new Map(),
};
}
}

View File

@@ -0,0 +1,77 @@
import * as plugins from '../plugins.js';
import type { ISmartServeOptions, TRuntime } from '../core/smartserve.interfaces.js';
import { UnsupportedRuntimeError } from '../core/smartserve.errors.js';
import type { BaseAdapter } from './adapter.base.js';
/**
* Factory for creating runtime-specific adapters
* Uses @push.rocks/smartenv for runtime detection
*/
export class AdapterFactory {
private static smartenv = new plugins.smartenv.Smartenv();
/**
* Detect current runtime
*/
static detectRuntime(): TRuntime {
if (this.smartenv.isBrowser) {
throw new UnsupportedRuntimeError('browser');
}
// Check for Deno
if (typeof (globalThis as any).Deno !== 'undefined') {
return 'deno';
}
// Check for Bun
if (typeof (globalThis as any).Bun !== 'undefined') {
return 'bun';
}
// Default to Node.js
return 'node';
}
/**
* Create an adapter for the current runtime
*/
static async createAdapter(options: ISmartServeOptions): Promise<BaseAdapter> {
const runtime = this.detectRuntime();
switch (runtime) {
case 'deno': {
const { DenoAdapter } = await import('./adapter.deno.js');
return new DenoAdapter(options);
}
case 'bun': {
const { BunAdapter } = await import('./adapter.bun.js');
return new BunAdapter(options);
}
case 'node': {
const { NodeAdapter } = await import('./adapter.node.js');
return new NodeAdapter(options);
}
default:
throw new UnsupportedRuntimeError(runtime);
}
}
/**
* Check if a specific runtime is available
*/
static isRuntimeAvailable(runtime: TRuntime): boolean {
switch (runtime) {
case 'deno':
return typeof (globalThis as any).Deno !== 'undefined';
case 'bun':
return typeof (globalThis as any).Bun !== 'undefined';
case 'node':
return typeof process !== 'undefined' && !!process.versions?.node;
default:
return false;
}
}
}

317
ts/adapters/adapter.node.ts Normal file
View File

@@ -0,0 +1,317 @@
import * as plugins from '../plugins.js';
import type { ISmartServeInstance, IConnectionInfo } from '../core/smartserve.interfaces.js';
import { BaseAdapter, type IAdapterCharacteristics, type TRequestHandler } from './adapter.base.js';
/**
* Node.js adapter - converts IncomingMessage/ServerResponse to web standards
*/
export class NodeAdapter extends BaseAdapter {
private server: plugins.http.Server | plugins.https.Server | null = null;
get name(): 'node' {
return 'node';
}
get characteristics(): IAdapterCharacteristics {
return {
zeroCopyStreaming: false, // Requires conversion
http2Support: true,
maxConnections: 16384,
nativeWebSocket: false, // Requires ws library
};
}
isSupported(): boolean {
return typeof process !== 'undefined' && !!process.versions?.node;
}
async start(handler: TRequestHandler): Promise<ISmartServeInstance> {
this.handler = handler;
this.startTime = Date.now();
const requestListener = this.createRequestListener(handler);
if (this.options.tls) {
// Convert Uint8Array to Buffer if needed
const toBuffer = (data: string | Uint8Array | undefined): string | Buffer | undefined => {
if (data === undefined) return undefined;
if (typeof data === 'string') return data;
return Buffer.from(data);
};
const tlsOptions: plugins.https.ServerOptions = {
cert: toBuffer(this.options.tls.cert),
key: toBuffer(this.options.tls.key),
ca: toBuffer(this.options.tls.ca),
passphrase: this.options.tls.passphrase,
};
if (this.options.tls.alpnProtocols) {
tlsOptions.ALPNProtocols = this.options.tls.alpnProtocols;
}
if (this.options.tls.minVersion) {
tlsOptions.minVersion = this.options.tls.minVersion;
}
this.server = plugins.https.createServer(tlsOptions, requestListener);
} else {
this.server = plugins.http.createServer(requestListener);
}
// Configure keep-alive
if (this.options.keepAlive?.enabled) {
this.server.keepAliveTimeout = this.options.keepAlive.timeout ?? 5000;
(this.server as any).maxRequestsPerSocket = this.options.keepAlive.maxRequests ?? 1000;
}
// Set up connection tracking
this.server.on('connection', (socket) => {
this.stats.connectionsTotal++;
this.stats.connectionsActive++;
socket.on('close', () => {
this.stats.connectionsActive--;
});
});
// WebSocket upgrade handling (if ws library available)
if (this.options.websocket) {
await this.setupWebSocket();
}
return new Promise((resolve, reject) => {
this.server!.listen(
this.options.port,
this.options.hostname ?? '0.0.0.0',
() => {
resolve(this.createInstance());
}
);
this.server!.on('error', reject);
});
}
async stop(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.server) {
this.server.close((err) => {
if (err) reject(err);
else resolve();
});
this.server = null;
} else {
resolve();
}
});
}
/**
* Create Node.js request listener that converts to web standards
*/
private createRequestListener(handler: TRequestHandler) {
return async (
req: plugins.http.IncomingMessage,
res: plugins.http.ServerResponse
) => {
this.stats.requestsTotal++;
this.stats.requestsActive++;
try {
// Convert to web standard Request
const request = this.toWebRequest(req);
// Create connection info
const connectionInfo: IConnectionInfo = {
remoteAddr: req.socket.remoteAddress ?? 'unknown',
remotePort: req.socket.remotePort ?? 0,
localAddr: req.socket.localAddress ?? '0.0.0.0',
localPort: req.socket.localPort ?? this.options.port,
encrypted: !!(req.socket as any).encrypted,
tlsVersion: (req.socket as any).getCipher?.()?.version,
};
// Call handler and send response
const response = await handler(request, connectionInfo);
await this.sendResponse(res, response);
} catch (error) {
if (this.options.onError) {
try {
const errorResponse = await this.options.onError(error as Error);
await this.sendResponse(res, errorResponse);
} catch {
res.statusCode = 500;
res.end('Internal Server Error');
}
} else {
res.statusCode = 500;
res.end('Internal Server Error');
}
} finally {
this.stats.requestsActive--;
}
};
}
/**
* Convert Node.js IncomingMessage to Web Standard Request
*/
private toWebRequest(req: plugins.http.IncomingMessage): Request {
const protocol = (req.socket as any).encrypted ? 'https' : 'http';
const host = req.headers.host ?? 'localhost';
const url = new URL(req.url ?? '/', `${protocol}://${host}`);
// Convert headers
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value !== undefined) {
if (Array.isArray(value)) {
value.forEach(v => headers.append(key, v));
} else {
headers.set(key, value);
}
}
}
// Create body stream for non-GET/HEAD requests
let body: ReadableStream<Uint8Array> | null = null;
const method = req.method?.toUpperCase() ?? 'GET';
if (method !== 'GET' && method !== 'HEAD') {
body = new ReadableStream({
start(controller) {
req.on('data', (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk));
});
req.on('end', () => {
controller.close();
});
req.on('error', (err) => {
controller.error(err);
});
},
cancel() {
req.destroy();
},
});
}
// Use proper init object for Request
const init: RequestInit = {
method,
headers,
};
if (body) {
init.body = body;
// @ts-ignore - duplex is needed for streaming body in Node.js
init.duplex = 'half';
}
return new Request(url.toString(), init);
}
/**
* Send Web Standard Response via Node.js ServerResponse
*/
private async sendResponse(
res: plugins.http.ServerResponse,
response: Response
): Promise<void> {
res.statusCode = response.status;
res.statusMessage = response.statusText;
// Set headers
response.headers.forEach((value, key) => {
res.setHeader(key, value);
});
// Stream body
if (response.body) {
const reader = response.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
this.stats.bytesSent += value.byteLength;
}
} finally {
reader.releaseLock();
}
}
res.end();
}
/**
* Set up WebSocket support using ws library
*/
private async setupWebSocket(): Promise<void> {
const hooks = this.options.websocket;
if (!hooks || !this.server) return;
try {
// Dynamic import of ws library
const { WebSocketServer } = await import('ws');
const wss = new WebSocketServer({ noServer: true });
this.server.on('upgrade', (request, socket, head) => {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
wss.on('connection', (ws: any, request: any) => {
const peer = this.wrapNodeWebSocket(ws, request);
hooks.onOpen?.(peer);
ws.on('message', (data: Buffer | string) => {
const message = {
type: typeof data === 'string' ? 'text' as const : 'binary' as const,
text: typeof data === 'string' ? data : undefined,
data: Buffer.isBuffer(data) ? new Uint8Array(data) : undefined,
size: typeof data === 'string' ? data.length : data.length,
};
hooks.onMessage?.(peer, message);
});
ws.on('close', (code: number, reason: Buffer) => {
hooks.onClose?.(peer, code, reason.toString());
});
ws.on('error', (error: Error) => {
hooks.onError?.(peer, error);
});
ws.on('ping', (data: Buffer) => {
hooks.onPing?.(peer, new Uint8Array(data));
});
ws.on('pong', (data: Buffer) => {
hooks.onPong?.(peer, new Uint8Array(data));
});
});
} catch {
console.warn('WebSocket support requires the "ws" package. Install with: pnpm add ws');
}
}
private wrapNodeWebSocket(ws: any, request: any): any {
return {
id: crypto.randomUUID(),
url: request.url ?? '',
get readyState() { return ws.readyState; },
protocol: ws.protocol ?? '',
extensions: ws.extensions ?? '',
send: (data: string) => ws.send(data),
sendBinary: (data: Uint8Array | ArrayBuffer) => ws.send(data),
close: (code?: number, reason?: string) => ws.close(code, reason),
ping: (data?: Uint8Array) => ws.ping(data),
terminate: () => ws.terminate(),
context: {} as any,
data: new Map(),
};
}
}

3
ts/adapters/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { BaseAdapter } from './adapter.base.js';
export type { IAdapterCharacteristics, TRequestHandler } from './adapter.base.js';
export { AdapterFactory } from './adapter.factory.js';

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;
}

View File

@@ -0,0 +1,215 @@
/**
* Interceptor decorators (@Guard, @Transform, @Intercept)
*
* All three decorators have unified semantics:
* - @Guard is sugar for @Intercept({ request: guardFn })
* - @Transform is sugar for @Intercept({ response: transformFn })
* - @Intercept provides full control over both request and response
*/
import type {
IRequestContext,
IInterceptOptions,
IGuardOptions,
TGuardFunction,
TRequestInterceptor,
TResponseInterceptor,
} from '../core/smartserve.interfaces.js';
import { addClassInterceptor, addMethodInterceptor } from './decorators.metadata.js';
/**
* Create a decorator that can be applied to both classes and methods
*/
function createInterceptDecorator(options: IInterceptOptions) {
// Class decorator
function classDecorator<TClass extends new (...args: any[]) => any>(
target: TClass,
context: ClassDecoratorContext<TClass>
): TClass {
addClassInterceptor(target, options);
return target;
}
// Method decorator
function methodDecorator<This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
context.addInitializer(function (this: This) {
addMethodInterceptor(this, context.name, options);
});
return target;
}
// Return overloaded function that works for both
return function (
target: any,
context: ClassDecoratorContext | ClassMethodDecoratorContext
) {
if (context.kind === 'class') {
return classDecorator(target, context as ClassDecoratorContext);
} else if (context.kind === 'method') {
return methodDecorator(target, context as ClassMethodDecoratorContext);
}
throw new Error('Interceptor decorators can only be applied to classes or methods');
};
}
/**
* @Guard decorator - validates requests before handler execution
*
* Guards return boolean: true to allow, false to reject with 403 Forbidden
*
* @example
* ```typescript
* // Single guard
* @Guard(isAuthenticated)
*
* // Multiple guards (all must pass)
* @Guard([isAuthenticated, hasRole('admin')])
*
* // With custom rejection response
* @Guard(isAuthenticated, {
* onReject: () => new Response('Unauthorized', { status: 401 })
* })
* ```
*/
export function Guard<TBody = unknown>(
guardOrGuards: TGuardFunction<TBody> | TGuardFunction<TBody>[],
options?: IGuardOptions
) {
const guards = Array.isArray(guardOrGuards) ? guardOrGuards : [guardOrGuards];
const interceptor: TRequestInterceptor<TBody> = async (ctx) => {
for (const guard of guards) {
const allowed = await guard(ctx);
if (!allowed) {
if (options?.onReject) {
return options.onReject(ctx);
}
return new Response(JSON.stringify({ error: 'Forbidden' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
// Return undefined to continue with original context
return undefined;
};
return createInterceptDecorator({ request: interceptor });
}
/**
* @Transform decorator - modifies response after handler execution
*
* @example
* ```typescript
* // Single transform
* @Transform(data => ({ success: true, data }))
*
* // Multiple transforms (applied in order)
* @Transform([addTimestamp, wrapResponse])
* ```
*/
export function Transform<TRes = unknown>(
transformOrTransforms: TResponseInterceptor<TRes> | TResponseInterceptor<TRes>[]
) {
const transforms = Array.isArray(transformOrTransforms)
? transformOrTransforms
: [transformOrTransforms];
return createInterceptDecorator({ response: transforms });
}
/**
* @Intercept decorator - full control over request and response interception
*
* @example
* ```typescript
* @Intercept({
* request: async (ctx) => {
* ctx.state.startTime = Date.now();
* return ctx;
* },
* response: (res, ctx) => ({
* ...res,
* duration: Date.now() - ctx.state.startTime
* })
* })
* ```
*/
export function Intercept<TBody = unknown, TRes = unknown>(
options: IInterceptOptions<TBody, TRes>
) {
return createInterceptDecorator(options as IInterceptOptions);
}
// =============================================================================
// Common Guard Utilities
// =============================================================================
/**
* Create a guard that checks for a specific header
*/
export function hasHeader(headerName: string, expectedValue?: string): TGuardFunction {
return (ctx) => {
const value = ctx.headers.get(headerName);
if (!value) return false;
if (expectedValue !== undefined) return value === expectedValue;
return true;
};
}
/**
* Create a guard that checks for Bearer token
*/
export function hasBearerToken(): TGuardFunction {
return (ctx) => {
const auth = ctx.headers.get('Authorization');
return auth?.startsWith('Bearer ') ?? false;
};
}
/**
* Create a rate limiting guard
*/
export function rateLimit(
maxRequests: number,
windowMs: number
): TGuardFunction {
const requests = new Map<string, number[]>();
return (ctx) => {
const ip = ctx.headers.get('x-forwarded-for')?.split(',')[0] ?? 'unknown';
const now = Date.now();
const windowStart = now - windowMs;
const timestamps = requests.get(ip)?.filter((t) => t > windowStart) ?? [];
if (timestamps.length >= maxRequests) {
return false;
}
timestamps.push(now);
requests.set(ip, timestamps);
return true;
};
}
// =============================================================================
// Common Transform Utilities
// =============================================================================
/**
* Wrap response in a success envelope
*/
export function wrapSuccess<T>(data: T): { success: true; data: T } {
return { success: true, data };
}
/**
* Add timestamp to response
*/
export function addTimestamp<T extends object>(data: T): T & { timestamp: number } {
return { ...data, timestamp: Date.now() };
}

View File

@@ -0,0 +1,141 @@
/**
* Metadata storage for decorators using Symbol.metadata (TC39 Stage 3)
* Falls back to WeakMap for environments without Symbol.metadata
*/
import type { IControllerMetadata, IRouteMetadata } from './decorators.types.js';
import type { IInterceptOptions } from '../core/smartserve.interfaces.js';
// Symbol for storing metadata when Symbol.metadata is not available
const CONTROLLER_METADATA = Symbol('smartserve:controller');
/**
* Get or create controller metadata for a class
* Uses symbol property on the class itself for metadata storage
*/
export function getControllerMetadata(target: any): IControllerMetadata {
// Store metadata on the class itself using symbol
if (!target[CONTROLLER_METADATA]) {
target[CONTROLLER_METADATA] = createEmptyMetadata();
}
return target[CONTROLLER_METADATA];
}
/**
* Get controller metadata from prototype (for instance lookup)
*/
export function getMetadataFromInstance(instance: any): IControllerMetadata | undefined {
const constructor = instance.constructor;
return getControllerMetadata(constructor);
}
/**
* Set base path for a controller
*/
export function setBasePath(target: any, path: string): void {
const metadata = getControllerMetadata(target);
metadata.basePath = normalizePath(path);
metadata.target = target;
}
/**
* Add a route to a controller
*/
export function addRoute(
target: any,
methodName: string | symbol,
route: Omit<IRouteMetadata, 'methodName' | 'interceptors'>
): void {
const metadata = getControllerMetadata(target.constructor);
// Get existing route or create new one
let existingRoute = metadata.routes.get(methodName);
if (!existingRoute) {
existingRoute = {
...route,
methodName,
interceptors: [],
};
metadata.routes.set(methodName, existingRoute);
} else {
// Update existing route
existingRoute.method = route.method;
existingRoute.path = route.path;
existingRoute.options = { ...existingRoute.options, ...route.options };
}
}
/**
* Add class-level interceptor
*/
export function addClassInterceptor(target: any, interceptor: IInterceptOptions): void {
const metadata = getControllerMetadata(target);
metadata.classInterceptors.push(interceptor);
}
/**
* Add method-level interceptor
*/
export function addMethodInterceptor(
target: any,
methodName: string | symbol,
interceptor: IInterceptOptions
): void {
const metadata = getControllerMetadata(target.constructor);
let route = metadata.routes.get(methodName);
if (!route) {
// Create placeholder route (will be completed by @Get/@Post/etc.)
route = {
method: 'GET',
path: '',
methodName,
interceptors: [],
options: {},
};
metadata.routes.set(methodName, route);
}
route.interceptors.push(interceptor);
}
/**
* Create empty metadata object
*/
function createEmptyMetadata(): IControllerMetadata {
return {
basePath: '',
classInterceptors: [],
routes: new Map(),
};
}
/**
* Normalize path to ensure consistent format
*/
export function normalizePath(path: string): string {
if (!path) return '';
// Ensure leading slash
let normalized = path.startsWith('/') ? path : `/${path}`;
// Remove trailing slash (unless it's just '/')
if (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
/**
* Combine base path and route path
*/
export function combinePaths(basePath: string, routePath: string): string {
const base = normalizePath(basePath);
const route = normalizePath(routePath);
if (!base) return route || '/';
if (!route) return base;
return `${base}${route}`;
}

View File

@@ -0,0 +1,117 @@
/**
* HTTP method decorators (@Get, @Post, @Put, @Delete, @Patch, @All)
*/
import type { THttpMethod, IMethodOptions } from '../core/smartserve.interfaces.js';
import { addRoute, normalizePath } from './decorators.metadata.js';
/**
* Factory for creating HTTP method decorators
*/
function createMethodDecorator(httpMethod: THttpMethod) {
return function (pathOrOptions?: string | IMethodOptions) {
return function <This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
if (context.kind !== 'method') {
throw new Error(`@${httpMethod} can only decorate methods`);
}
const options: IMethodOptions = typeof pathOrOptions === 'string'
? { path: pathOrOptions }
: pathOrOptions ?? {};
// Use addInitializer to ensure we have access to the class prototype
context.addInitializer(function (this: This) {
addRoute(this, context.name, {
method: httpMethod,
path: normalizePath(options.path ?? ''),
options,
handler: target as unknown as Function,
});
});
return target;
};
};
}
/**
* @Get decorator - handles GET requests
*
* @example
* ```typescript
* @Get('/users')
* listUsers(ctx: IRequestContext) { ... }
*
* @Get('/:id')
* getUser(ctx: IRequestContext) { ... }
* ```
*/
export const Get = createMethodDecorator('GET');
/**
* @Post decorator - handles POST requests
*
* @example
* ```typescript
* @Post('/users')
* createUser(ctx: IRequestContext<ICreateUserBody>) { ... }
* ```
*/
export const Post = createMethodDecorator('POST');
/**
* @Put decorator - handles PUT requests
*
* @example
* ```typescript
* @Put('/users/:id')
* updateUser(ctx: IRequestContext<IUpdateUserBody>) { ... }
* ```
*/
export const Put = createMethodDecorator('PUT');
/**
* @Delete decorator - handles DELETE requests
*
* @example
* ```typescript
* @Delete('/users/:id')
* deleteUser(ctx: IRequestContext) { ... }
* ```
*/
export const Delete = createMethodDecorator('DELETE');
/**
* @Patch decorator - handles PATCH requests
*
* @example
* ```typescript
* @Patch('/users/:id')
* patchUser(ctx: IRequestContext<IPartialUser>) { ... }
* ```
*/
export const Patch = createMethodDecorator('PATCH');
/**
* @Head decorator - handles HEAD requests
*/
export const Head = createMethodDecorator('HEAD');
/**
* @Options decorator - handles OPTIONS requests
*/
export const Options = createMethodDecorator('OPTIONS');
/**
* @All decorator - handles all HTTP methods
*
* @example
* ```typescript
* @All('/proxy/*')
* proxyRequest(ctx: IRequestContext) { ... }
* ```
*/
export const All = createMethodDecorator('ALL');

View File

@@ -0,0 +1,198 @@
/**
* Controller registry - stores all registered controllers
*/
import type { IControllerMetadata, IRegisteredController, ICompiledRoute } from './decorators.types.js';
import type { IRequestContext, IInterceptOptions, THttpMethod } from '../core/smartserve.interfaces.js';
import { getControllerMetadata, combinePaths } from './decorators.metadata.js';
/**
* Global registry of all controllers
*/
export class ControllerRegistry {
private static controllers: Map<Function, IControllerMetadata> = new Map();
private static instances: Map<Function, any> = new Map();
private static compiledRoutes: ICompiledRoute[] = [];
private static routesCompiled = false;
/**
* Register a controller class
*/
static registerClass(target: Function): void {
const metadata = getControllerMetadata(target);
metadata.target = target as new (...args: any[]) => any;
this.controllers.set(target, metadata);
this.routesCompiled = false;
}
/**
* Register a controller instance
*/
static registerInstance(instance: any): void {
const constructor = instance.constructor;
const metadata = getControllerMetadata(constructor);
// Store instance
this.instances.set(constructor, instance);
// Register class if not already registered
if (!this.controllers.has(constructor)) {
this.registerClass(constructor);
}
this.routesCompiled = false;
}
/**
* Get all registered controllers
*/
static getControllers(): IRegisteredController[] {
const result: IRegisteredController[] = [];
for (const [constructor, metadata] of this.controllers) {
// Get or create instance
let instance = this.instances.get(constructor);
if (!instance && metadata.target) {
instance = new metadata.target();
this.instances.set(constructor, instance);
}
if (instance) {
result.push({ instance, metadata });
}
}
return result;
}
/**
* Compile all routes for fast matching
*/
static compileRoutes(): ICompiledRoute[] {
if (this.routesCompiled) {
return this.compiledRoutes;
}
this.compiledRoutes = [];
for (const { instance, metadata } of this.getControllers()) {
for (const [methodName, route] of metadata.routes) {
const fullPath = combinePaths(metadata.basePath, route.path);
const { regex, paramNames } = this.pathToRegex(fullPath);
// Combine class and method interceptors
const interceptors: IInterceptOptions[] = [
...metadata.classInterceptors,
...route.interceptors,
];
// Create bound handler
const handler = async (ctx: IRequestContext): Promise<any> => {
const method = instance[methodName];
if (typeof method !== 'function') {
throw new Error(`Method ${String(methodName)} not found on controller`);
}
return method.call(instance, ctx);
};
this.compiledRoutes.push({
pattern: fullPath,
regex,
paramNames,
method: route.method,
handler,
interceptors,
});
}
}
// Sort routes by specificity (more specific paths first)
this.compiledRoutes.sort((a, b) => {
// Routes without wildcards come first
const aHasWildcard = a.pattern.includes('*');
const bHasWildcard = b.pattern.includes('*');
if (aHasWildcard !== bHasWildcard) return aHasWildcard ? 1 : -1;
// Routes with more segments come first
const aSegments = a.pattern.split('/').length;
const bSegments = b.pattern.split('/').length;
if (aSegments !== bSegments) return bSegments - aSegments;
// Routes without params come first
const aParams = a.paramNames.length;
const bParams = b.paramNames.length;
return aParams - bParams;
});
this.routesCompiled = true;
return this.compiledRoutes;
}
/**
* Match a request to a compiled route
*/
static matchRoute(path: string, method: THttpMethod): {
route: ICompiledRoute;
params: Record<string, string>;
} | null {
const routes = this.compileRoutes();
for (const route of routes) {
// Check method match
if (route.method !== 'ALL' && route.method !== method) {
continue;
}
// Check path match
const match = route.regex.exec(path);
if (match) {
// Extract params
const params: Record<string, string> = {};
route.paramNames.forEach((name, index) => {
params[name] = match[index + 1];
});
return { route, params };
}
}
return null;
}
/**
* Convert path pattern to regex
* Supports :param and * wildcard
*/
private static pathToRegex(path: string): { regex: RegExp; paramNames: string[] } {
const paramNames: string[] = [];
let regexStr = path
// Escape special regex chars (except : and *)
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
// Convert :param to capture group
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
paramNames.push(name);
return '([^/]+)';
})
// Convert * to wildcard
.replace(/\*/g, '(.*)');
// Anchor the regex
regexStr = `^${regexStr}$`;
return {
regex: new RegExp(regexStr),
paramNames,
};
}
/**
* Clear all registered controllers (useful for testing)
*/
static clear(): void {
this.controllers.clear();
this.instances.clear();
this.compiledRoutes = [];
this.routesCompiled = false;
}
}

View File

@@ -0,0 +1,45 @@
/**
* @Route class decorator
* Marks a class as a controller with a base path
*/
import type { IRouteOptions } from '../core/smartserve.interfaces.js';
import { setBasePath, normalizePath } from './decorators.metadata.js';
import { ControllerRegistry } from './decorators.registry.js';
/**
* @Route decorator - marks a class as a route controller
*
* @example
* ```typescript
* @Route('/api/users')
* class UserController {
* @Get('/:id')
* getUser(ctx: IRequestContext) { ... }
* }
* ```
*/
export function Route(pathOrOptions?: string | IRouteOptions) {
return function <TClass extends new (...args: any[]) => any>(
target: TClass,
context: ClassDecoratorContext<TClass>
): TClass {
if (context.kind !== 'class') {
throw new Error('@Route can only decorate classes');
}
const path = typeof pathOrOptions === 'string'
? pathOrOptions
: pathOrOptions?.path ?? '';
// Store base path in metadata
setBasePath(target, path);
// Register controller after class initialization
context.addInitializer(function (this: TClass) {
ControllerRegistry.registerClass(target);
});
return target;
};
}

View File

@@ -0,0 +1,76 @@
/**
* Decorator type definitions for @push.rocks/smartserve
* Uses TC39 Stage 3 decorators (TypeScript 5.2+)
*/
import type {
THttpMethod,
IRequestContext,
IInterceptOptions,
IMethodOptions,
IRouteOptions,
} from '../core/smartserve.interfaces.js';
// =============================================================================
// Metadata Types
// =============================================================================
/**
* Metadata stored on controller classes
*/
export interface IControllerMetadata {
/** Base path for all routes in this controller */
basePath: string;
/** Class-level interceptors (apply to all methods) */
classInterceptors: IInterceptOptions[];
/** Route definitions by method name */
routes: Map<string | symbol, IRouteMetadata>;
/** Controller class reference */
target?: new (...args: any[]) => any;
}
/**
* Metadata for individual route methods
*/
export interface IRouteMetadata {
/** HTTP method */
method: THttpMethod;
/** Path segment (combined with class basePath) */
path: string;
/** Method-level interceptors */
interceptors: IInterceptOptions[];
/** Response options */
options: IMethodOptions;
/** Method name */
methodName: string | symbol;
/** Handler function reference */
handler?: Function;
}
/**
* Registered controller with metadata
*/
export interface IRegisteredController {
/** Controller instance */
instance: any;
/** Controller metadata */
metadata: IControllerMetadata;
}
/**
* Compiled route for fast matching
*/
export interface ICompiledRoute {
/** Full path pattern */
pattern: string;
/** Regex for matching */
regex: RegExp;
/** Parameter names */
paramNames: string[];
/** HTTP method */
method: THttpMethod;
/** Handler function */
handler: (ctx: IRequestContext) => Promise<any>;
/** Combined interceptors (class + method) */
interceptors: IInterceptOptions[];
}

47
ts/decorators/index.ts Normal file
View File

@@ -0,0 +1,47 @@
// Type exports
export type {
IControllerMetadata,
IRouteMetadata,
IRegisteredController,
ICompiledRoute,
} from './decorators.types.js';
// Route decorator
export { Route } from './decorators.route.js';
// HTTP method decorators
export {
Get,
Post,
Put,
Delete,
Patch,
Head,
Options,
All,
} from './decorators.methods.js';
// Interceptor decorators
export {
Guard,
Transform,
Intercept,
// Utility guards
hasHeader,
hasBearerToken,
rateLimit,
// Utility transforms
wrapSuccess,
addTimestamp,
} from './decorators.interceptors.js';
// Registry
export { ControllerRegistry } from './decorators.registry.js';
// Metadata utilities
export {
getControllerMetadata,
getMetadataFromInstance,
normalizePath,
combinePaths,
} from './decorators.metadata.js';

385
ts/files/file.server.ts Normal file
View File

@@ -0,0 +1,385 @@
/**
* Static file server with streaming, ETags, and directory listing
*/
import * as plugins from '../plugins.js';
import type {
IStaticOptions,
IDirectoryListingOptions,
IFileEntry,
} from '../core/smartserve.interfaces.js';
import { getMimeType } from '../utils/utils.mime.js';
import { generateETag } from '../utils/utils.etag.js';
/**
* Static file server
*/
export class FileServer {
private options: IStaticOptions;
constructor(options: IStaticOptions | string) {
if (typeof options === 'string') {
this.options = { root: options };
} else {
this.options = options;
}
// Set defaults
this.options.index = this.options.index ?? ['index.html', 'index.htm'];
this.options.dotFiles = this.options.dotFiles ?? 'ignore';
this.options.etag = this.options.etag ?? true;
this.options.lastModified = this.options.lastModified ?? true;
}
/**
* Handle a request for static files
*/
async serve(request: Request): Promise<Response | null> {
const url = new URL(request.url);
let pathname = decodeURIComponent(url.pathname);
// Security: prevent path traversal
if (pathname.includes('..')) {
return new Response('Forbidden', { status: 403 });
}
// Resolve file path
const filePath = plugins.path.join(this.options.root, pathname);
// Check if path is within root
const realRoot = plugins.path.resolve(this.options.root);
const realPath = plugins.path.resolve(filePath);
if (!realPath.startsWith(realRoot)) {
return new Response('Forbidden', { status: 403 });
}
try {
const stat = await plugins.fs.promises.stat(realPath);
if (stat.isDirectory()) {
// Try index files
for (const indexFile of this.options.index!) {
const indexPath = plugins.path.join(realPath, indexFile);
try {
const indexStat = await plugins.fs.promises.stat(indexPath);
if (indexStat.isFile()) {
return this.serveFile(request, indexPath, indexStat);
}
} catch {
// Index file doesn't exist, continue
}
}
// Directory listing
if (this.options.directoryListing) {
return this.serveDirectory(request, realPath, pathname);
}
return new Response('Forbidden', { status: 403 });
}
if (stat.isFile()) {
// Check dotfile policy
const basename = plugins.path.basename(realPath);
if (basename.startsWith('.')) {
if (this.options.dotFiles === 'deny') {
return new Response('Forbidden', { status: 403 });
}
if (this.options.dotFiles === 'ignore') {
return null; // Let other handlers try
}
}
return this.serveFile(request, realPath, stat);
}
return null;
} catch (err: any) {
if (err.code === 'ENOENT') {
return null; // File not found, let other handlers try
}
throw err;
}
}
/**
* Serve a single file with proper headers
*/
private async serveFile(
request: Request,
filePath: string,
stat: plugins.fs.Stats
): Promise<Response> {
const headers = new Headers();
// Content-Type
const mimeType = getMimeType(filePath);
headers.set('Content-Type', mimeType);
// Content-Length
headers.set('Content-Length', stat.size.toString());
// Last-Modified
if (this.options.lastModified) {
headers.set('Last-Modified', stat.mtime.toUTCString());
}
// ETag
let etag: string | undefined;
if (this.options.etag) {
etag = generateETag(stat);
headers.set('ETag', etag);
}
// Cache-Control
if (this.options.cacheControl) {
const cacheControl = typeof this.options.cacheControl === 'function'
? this.options.cacheControl(filePath)
: this.options.cacheControl;
headers.set('Cache-Control', cacheControl);
}
// Check conditional requests
const ifNoneMatch = request.headers.get('If-None-Match');
if (etag && ifNoneMatch === etag) {
return new Response(null, { status: 304, headers });
}
const ifModifiedSince = request.headers.get('If-Modified-Since');
if (ifModifiedSince) {
const clientDate = new Date(ifModifiedSince);
if (stat.mtime <= clientDate) {
return new Response(null, { status: 304, headers });
}
}
// Handle Range requests
const rangeHeader = request.headers.get('Range');
if (rangeHeader) {
return this.servePartial(filePath, stat, rangeHeader, headers);
}
// HEAD request
if (request.method === 'HEAD') {
return new Response(null, { status: 200, headers });
}
// Stream the file
const stream = plugins.fs.createReadStream(filePath);
const readableStream = this.nodeStreamToWebStream(stream);
return new Response(readableStream, { status: 200, headers });
}
/**
* Serve partial content (Range request)
*/
private async servePartial(
filePath: string,
stat: plugins.fs.Stats,
rangeHeader: string,
headers: Headers
): Promise<Response> {
const size = stat.size;
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (!match) {
return new Response('Invalid Range', { status: 416 });
}
let start = match[1] ? parseInt(match[1], 10) : 0;
let end = match[2] ? parseInt(match[2], 10) : size - 1;
// Validate range
if (start >= size || end >= size || start > end) {
headers.set('Content-Range', `bytes */${size}`);
return new Response('Range Not Satisfiable', { status: 416, headers });
}
// Set partial content headers
headers.set('Content-Range', `bytes ${start}-${end}/${size}`);
headers.set('Content-Length', (end - start + 1).toString());
headers.set('Accept-Ranges', 'bytes');
const stream = plugins.fs.createReadStream(filePath, { start, end });
const readableStream = this.nodeStreamToWebStream(stream);
return new Response(readableStream, { status: 206, headers });
}
/**
* Serve directory listing
*/
private async serveDirectory(
request: Request,
dirPath: string,
urlPath: string
): Promise<Response> {
const entries = await plugins.fs.promises.readdir(dirPath, { withFileTypes: true });
const files: IFileEntry[] = [];
for (const entry of entries) {
// Skip hidden files unless configured
const listingOptions = typeof this.options.directoryListing === 'object'
? this.options.directoryListing
: {};
if (entry.name.startsWith('.') && !listingOptions.showHidden) {
continue;
}
const entryPath = plugins.path.join(dirPath, entry.name);
const stat = await plugins.fs.promises.stat(entryPath);
files.push({
name: entry.name,
path: plugins.path.join(urlPath, entry.name),
isDirectory: entry.isDirectory(),
size: stat.size,
modified: stat.mtime,
});
}
// Sort files
const listingOptions = typeof this.options.directoryListing === 'object'
? this.options.directoryListing
: {};
const sortBy = listingOptions.sortBy ?? 'name';
const sortOrder = listingOptions.sortOrder ?? 'asc';
files.sort((a, b) => {
// Directories first
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1;
}
let comparison = 0;
switch (sortBy) {
case 'size':
comparison = a.size - b.size;
break;
case 'modified':
comparison = a.modified.getTime() - b.modified.getTime();
break;
default:
comparison = a.name.localeCompare(b.name);
}
return sortOrder === 'desc' ? -comparison : comparison;
});
// Custom template
if (listingOptions.template) {
const result = listingOptions.template(files);
if (result instanceof Response) {
return result;
}
return new Response(result, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
// Default HTML listing
const html = this.generateDirectoryHtml(urlPath, files);
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
/**
* Generate default directory listing HTML
*/
private generateDirectoryHtml(urlPath: string, files: IFileEntry[]): string {
const formatSize = (size: number): string => {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
};
const formatDate = (date: Date): string => {
return date.toISOString().replace('T', ' ').slice(0, 19);
};
const escapeHtml = (str: string): string => {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
};
const rows = files.map(file => {
const icon = file.isDirectory ? '📁' : '📄';
const href = encodeURIComponent(file.name) + (file.isDirectory ? '/' : '');
const size = file.isDirectory ? '-' : formatSize(file.size);
return `<tr>
<td>${icon} <a href="${href}">${escapeHtml(file.name)}</a></td>
<td>${size}</td>
<td>${formatDate(file.modified)}</td>
</tr>`;
}).join('\n');
// Add parent directory link if not at root
const parentLink = urlPath !== '/' ? `<tr>
<td>📁 <a href="../">..</a></td>
<td>-</td>
<td>-</td>
</tr>` : '';
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Index of ${escapeHtml(urlPath)}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 2em; }
h1 { font-size: 1.5em; margin-bottom: 1em; }
table { border-collapse: collapse; width: 100%; max-width: 800px; }
th, td { text-align: left; padding: 0.5em 1em; border-bottom: 1px solid #eee; }
th { background: #f5f5f5; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
td:nth-child(2), td:nth-child(3) { white-space: nowrap; }
</style>
</head>
<body>
<h1>Index of ${escapeHtml(urlPath)}</h1>
<table>
<thead>
<tr><th>Name</th><th>Size</th><th>Modified</th></tr>
</thead>
<tbody>
${parentLink}
${rows}
</tbody>
</table>
</body>
</html>`;
}
/**
* Convert Node.js stream to Web ReadableStream
*/
private nodeStreamToWebStream(nodeStream: plugins.fs.ReadStream): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
nodeStream.on('data', (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk));
});
nodeStream.on('end', () => {
controller.close();
});
nodeStream.on('error', (err) => {
controller.error(err);
});
},
cancel() {
nodeStream.destroy();
},
});
}
}

1
ts/files/index.ts Normal file
View File

@@ -0,0 +1 @@
export { FileServer } from './file.server.js';

19
ts/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import * as plugins from './plugins.js';
// Core exports
export * from './core/index.js';
// Decorator exports
export * from './decorators/index.js';
// File server exports
export * from './files/index.js';
// Protocol exports (WebDAV, etc.)
export * from './protocols/index.js';
// Utility exports
export * from './utils/index.js';
// Re-export plugins for advanced usage
export { plugins };

5
ts/paths.ts Normal file
View File

@@ -0,0 +1,5 @@
import * as plugins from './plugins.js';
export const packageDir = plugins.path.join(
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
'../'
);

15
ts/plugins.ts Normal file
View File

@@ -0,0 +1,15 @@
// native scope
import * as path from 'path';
import * as http from 'http';
import * as https from 'https';
import * as fs from 'fs';
export { path, http, https, fs };
// @push.rocks scope
import * as smartpath from '@push.rocks/smartpath';
import * as smartenv from '@push.rocks/smartenv';
import * as smartlog from '@push.rocks/smartlog';
import * as lik from '@push.rocks/lik';
export { smartpath, smartenv, smartlog, lik };

1
ts/protocols/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './webdav/index.js';

View File

@@ -0,0 +1,15 @@
export { WebDAVHandler } from './webdav.handler.js';
export type {
TWebDAVMethod,
TWebDAVDepth,
IWebDAVProperty,
IWebDAVResource,
IWebDAVLock,
IWebDAVContext,
} from './webdav.types.js';
export {
generateMultistatus,
generateLockResponse,
generateError,
parsePropfindRequest,
} from './webdav.xml.js';

View File

@@ -0,0 +1,659 @@
/**
* WebDAV protocol handler
* Implements RFC 4918 for network drive mounting
*/
import * as plugins from '../../plugins.js';
import type { IWebDAVConfig, IRequestContext } from '../../core/smartserve.interfaces.js';
import type {
TWebDAVMethod,
TWebDAVDepth,
IWebDAVResource,
IWebDAVLock,
IWebDAVContext,
} from './webdav.types.js';
import {
generateMultistatus,
generateLockResponse,
generateError,
parsePropfindRequest,
} from './webdav.xml.js';
import { getMimeType } from '../../utils/utils.mime.js';
import { generateETag } from '../../utils/utils.etag.js';
/**
* WebDAV handler for serving files with WebDAV protocol
*/
export class WebDAVHandler {
private config: IWebDAVConfig;
private locks: Map<string, IWebDAVLock> = new Map();
constructor(config: IWebDAVConfig) {
this.config = {
locking: true,
...config,
};
}
/**
* Check if request is a WebDAV request
*/
isWebDAVRequest(request: Request): boolean {
const method = request.method.toUpperCase();
const webdavMethods = ['PROPFIND', 'PROPPATCH', 'MKCOL', 'COPY', 'MOVE', 'LOCK', 'UNLOCK'];
return webdavMethods.includes(method);
}
/**
* Handle WebDAV request
*/
async handle(request: Request): Promise<Response> {
const method = request.method.toUpperCase() as TWebDAVMethod;
const url = new URL(request.url);
const path = decodeURIComponent(url.pathname);
// Security check
if (path.includes('..')) {
return new Response('Forbidden', { status: 403 });
}
// Parse WebDAV context
const context = this.parseContext(request);
// Authentication
if (this.config.auth) {
const mockCtx = { headers: request.headers } as IRequestContext;
const authenticated = await this.config.auth(mockCtx);
if (!authenticated) {
return new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="WebDAV"' },
});
}
}
try {
switch (method) {
case 'OPTIONS':
return this.handleOptions();
case 'PROPFIND':
return await this.handlePropfind(request, path, context);
case 'PROPPATCH':
return await this.handleProppatch(request, path);
case 'MKCOL':
return await this.handleMkcol(path);
case 'COPY':
return await this.handleCopy(path, context);
case 'MOVE':
return await this.handleMove(path, context);
case 'LOCK':
return await this.handleLock(request, path, context);
case 'UNLOCK':
return await this.handleUnlock(path, context);
case 'GET':
case 'HEAD':
return await this.handleGet(request, path, method === 'HEAD');
case 'PUT':
return await this.handlePut(request, path, context);
case 'DELETE':
return await this.handleDelete(path, context);
default:
return new Response('Method Not Allowed', { status: 405 });
}
} catch (error: any) {
console.error('WebDAV error:', error);
if (error.code === 'ENOENT') {
return new Response('Not Found', { status: 404 });
}
return new Response('Internal Server Error', { status: 500 });
}
}
/**
* Parse WebDAV-specific headers into context
*/
private parseContext(request: Request): IWebDAVContext {
const depth = (request.headers.get('Depth') ?? '1') as TWebDAVDepth;
const destination = request.headers.get('Destination') ?? undefined;
const overwrite = request.headers.get('Overwrite') !== 'F';
const lockToken = request.headers.get('Lock-Token')?.replace(/[<>]/g, '');
const timeout = this.parseTimeout(request.headers.get('Timeout'));
return {
method: request.method.toUpperCase() as TWebDAVMethod,
depth: depth === 'infinity' ? 'infinity' : depth === '0' ? '0' : '1',
destination,
overwrite,
lockToken,
timeout,
};
}
/**
* Parse timeout header
*/
private parseTimeout(header: string | null): number {
if (!header) return 3600; // 1 hour default
const match = header.match(/Second-(\d+)/i);
if (match) return parseInt(match[1], 10);
if (header.toLowerCase() === 'infinite') return 0;
return 3600;
}
/**
* Resolve file path
*/
private resolvePath(path: string): string {
return plugins.path.join(this.config.root, path);
}
/**
* Handle OPTIONS request
*/
private handleOptions(): Response {
return new Response(null, {
status: 200,
headers: {
'Allow': 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK',
'DAV': '1, 2',
'MS-Author-Via': 'DAV',
},
});
}
/**
* Handle PROPFIND request
*/
private async handlePropfind(
request: Request,
path: string,
context: IWebDAVContext
): Promise<Response> {
const filePath = this.resolvePath(path);
// Parse request body
const body = await request.text();
const { allprop } = parsePropfindRequest(body);
try {
const stat = await plugins.fs.promises.stat(filePath);
const resources: IWebDAVResource[] = [];
// Add the requested resource
resources.push(await this.statToResource(path, stat));
// If directory and depth > 0, list children
if (stat.isDirectory() && context.depth !== '0') {
const entries = await plugins.fs.promises.readdir(filePath, { withFileTypes: true });
for (const entry of entries) {
const childPath = plugins.path.join(path, entry.name);
const childFilePath = plugins.path.join(filePath, entry.name);
const childStat = await plugins.fs.promises.stat(childFilePath);
resources.push(await this.statToResource(childPath, childStat));
// Handle infinite depth (recursive)
if (context.depth === 'infinity' && entry.isDirectory()) {
await this.collectResources(childPath, childFilePath, resources);
}
}
}
const xml = generateMultistatus(resources);
return new Response(xml, {
status: 207, // Multi-Status
headers: {
'Content-Type': 'application/xml; charset=utf-8',
},
});
} catch (error: any) {
if (error.code === 'ENOENT') {
return new Response('Not Found', { status: 404 });
}
throw error;
}
}
/**
* Recursively collect resources for infinite depth
*/
private async collectResources(
basePath: string,
filePath: string,
resources: IWebDAVResource[]
): Promise<void> {
const entries = await plugins.fs.promises.readdir(filePath, { withFileTypes: true });
for (const entry of entries) {
const childPath = plugins.path.join(basePath, entry.name);
const childFilePath = plugins.path.join(filePath, entry.name);
const childStat = await plugins.fs.promises.stat(childFilePath);
resources.push(await this.statToResource(childPath, childStat));
if (entry.isDirectory()) {
await this.collectResources(childPath, childFilePath, resources);
}
}
}
/**
* Convert fs.Stats to WebDAV resource
*/
private async statToResource(path: string, stat: plugins.fs.Stats): Promise<IWebDAVResource> {
const isCollection = stat.isDirectory();
const displayName = plugins.path.basename(path) || '/';
return {
href: encodeURI(path),
isCollection,
displayName,
contentType: isCollection ? 'httpd/unix-directory' : getMimeType(path),
contentLength: isCollection ? undefined : stat.size,
lastModified: stat.mtime,
creationDate: stat.birthtime,
etag: generateETag(stat),
properties: [],
};
}
/**
* Handle PROPPATCH request (property modification)
*/
private async handleProppatch(request: Request, path: string): Promise<Response> {
// For now, we don't support modifying properties
// Return 403 Forbidden for property modification attempts
const filePath = this.resolvePath(path);
try {
await plugins.fs.promises.access(filePath);
return new Response(generateError(403, 'cannot-modify-protected-property'), {
status: 403,
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
});
} catch {
return new Response('Not Found', { status: 404 });
}
}
/**
* Handle MKCOL request (create directory)
*/
private async handleMkcol(path: string): Promise<Response> {
const filePath = this.resolvePath(path);
try {
// Check if parent exists
const parent = plugins.path.dirname(filePath);
await plugins.fs.promises.access(parent);
// Check if already exists
try {
await plugins.fs.promises.access(filePath);
return new Response('Method Not Allowed', { status: 405 }); // Already exists
} catch {
// Good, doesn't exist
}
await plugins.fs.promises.mkdir(filePath);
return new Response(null, { status: 201 }); // Created
} catch (error: any) {
if (error.code === 'ENOENT') {
return new Response('Conflict', { status: 409 }); // Parent doesn't exist
}
throw error;
}
}
/**
* Handle COPY request
*/
private async handleCopy(path: string, context: IWebDAVContext): Promise<Response> {
if (!context.destination) {
return new Response('Bad Request', { status: 400 });
}
const sourcePath = this.resolvePath(path);
const destUrl = new URL(context.destination);
const destPath = this.resolvePath(decodeURIComponent(destUrl.pathname));
// Check if destination exists
let destExists = false;
try {
await plugins.fs.promises.access(destPath);
destExists = true;
if (!context.overwrite) {
return new Response('Precondition Failed', { status: 412 });
}
} catch {
// Destination doesn't exist, that's fine
}
try {
const stat = await plugins.fs.promises.stat(sourcePath);
if (stat.isDirectory()) {
await this.copyDirectory(sourcePath, destPath);
} else {
await plugins.fs.promises.copyFile(sourcePath, destPath);
}
return new Response(null, { status: destExists ? 204 : 201 });
} catch (error: any) {
if (error.code === 'ENOENT') {
return new Response('Not Found', { status: 404 });
}
throw error;
}
}
/**
* Recursively copy directory
*/
private async copyDirectory(src: string, dest: string): Promise<void> {
await plugins.fs.promises.mkdir(dest, { recursive: true });
const entries = await plugins.fs.promises.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = plugins.path.join(src, entry.name);
const destPath = plugins.path.join(dest, entry.name);
if (entry.isDirectory()) {
await this.copyDirectory(srcPath, destPath);
} else {
await plugins.fs.promises.copyFile(srcPath, destPath);
}
}
}
/**
* Handle MOVE request
*/
private async handleMove(path: string, context: IWebDAVContext): Promise<Response> {
if (!context.destination) {
return new Response('Bad Request', { status: 400 });
}
const sourcePath = this.resolvePath(path);
const destUrl = new URL(context.destination);
const destPath = this.resolvePath(decodeURIComponent(destUrl.pathname));
// Check lock
if (this.config.locking && this.isLocked(path) && !this.hasValidLock(path, context.lockToken)) {
return new Response('Locked', { status: 423 });
}
// Check if destination exists
let destExists = false;
try {
await plugins.fs.promises.access(destPath);
destExists = true;
if (!context.overwrite) {
return new Response('Precondition Failed', { status: 412 });
}
// Remove existing destination
await plugins.fs.promises.rm(destPath, { recursive: true, force: true });
} catch {
// Destination doesn't exist, that's fine
}
try {
await plugins.fs.promises.rename(sourcePath, destPath);
// Move lock if exists
const lock = this.locks.get(path);
if (lock) {
this.locks.delete(path);
lock.path = decodeURIComponent(destUrl.pathname);
this.locks.set(lock.path, lock);
}
return new Response(null, { status: destExists ? 204 : 201 });
} catch (error: any) {
if (error.code === 'ENOENT') {
return new Response('Not Found', { status: 404 });
}
throw error;
}
}
/**
* Handle LOCK request
*/
private async handleLock(
request: Request,
path: string,
context: IWebDAVContext
): Promise<Response> {
if (!this.config.locking) {
return new Response('Method Not Allowed', { status: 405 });
}
const filePath = this.resolvePath(path);
// Check if resource exists
try {
await plugins.fs.promises.access(filePath);
} catch {
return new Response('Not Found', { status: 404 });
}
// Check if already locked by someone else
if (this.isLocked(path) && !this.hasValidLock(path, context.lockToken)) {
return new Response('Locked', { status: 423 });
}
// Create lock
const lock: IWebDAVLock = {
token: `opaquelocktoken:${crypto.randomUUID()}`,
owner: 'anonymous', // Could parse from request body
depth: context.depth,
timeout: context.timeout ?? 3600,
scope: 'exclusive',
type: 'write',
path,
created: new Date(),
};
this.locks.set(path, lock);
// Set timeout to remove lock
if (lock.timeout > 0) {
setTimeout(() => {
this.locks.delete(path);
}, lock.timeout * 1000);
}
const xml = generateLockResponse(lock);
return new Response(xml, {
status: 200,
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Lock-Token': `<${lock.token}>`,
},
});
}
/**
* Handle UNLOCK request
*/
private async handleUnlock(path: string, context: IWebDAVContext): Promise<Response> {
if (!this.config.locking) {
return new Response('Method Not Allowed', { status: 405 });
}
if (!context.lockToken) {
return new Response('Bad Request', { status: 400 });
}
const lock = this.locks.get(path);
if (!lock) {
return new Response('Conflict', { status: 409 });
}
if (lock.token !== context.lockToken) {
return new Response('Forbidden', { status: 403 });
}
this.locks.delete(path);
return new Response(null, { status: 204 });
}
/**
* Handle GET/HEAD request
*/
private async handleGet(request: Request, path: string, headOnly: boolean): Promise<Response> {
const filePath = this.resolvePath(path);
try {
const stat = await plugins.fs.promises.stat(filePath);
if (stat.isDirectory()) {
// Return directory listing as HTML (fallback)
return new Response('This is a WebDAV directory', {
status: 200,
headers: { 'Content-Type': 'text/plain' },
});
}
const headers = new Headers({
'Content-Type': getMimeType(filePath),
'Content-Length': stat.size.toString(),
'Last-Modified': stat.mtime.toUTCString(),
'ETag': `"${generateETag(stat)}"`,
});
if (headOnly) {
return new Response(null, { status: 200, headers });
}
const stream = plugins.fs.createReadStream(filePath);
const webStream = new ReadableStream({
start(controller) {
stream.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
cancel() {
stream.destroy();
},
});
return new Response(webStream, { status: 200, headers });
} catch (error: any) {
if (error.code === 'ENOENT') {
return new Response('Not Found', { status: 404 });
}
throw error;
}
}
/**
* Handle PUT request (file upload)
*/
private async handlePut(
request: Request,
path: string,
context: IWebDAVContext
): Promise<Response> {
const filePath = this.resolvePath(path);
// Check lock
if (this.config.locking && this.isLocked(path) && !this.hasValidLock(path, context.lockToken)) {
return new Response('Locked', { status: 423 });
}
// Check if file exists
let exists = false;
try {
await plugins.fs.promises.access(filePath);
exists = true;
} catch {
// File doesn't exist, will create
}
// Ensure parent directory exists
const parent = plugins.path.dirname(filePath);
try {
await plugins.fs.promises.mkdir(parent, { recursive: true });
} catch {
// Parent might already exist
}
// Write file
const body = request.body;
if (body) {
const reader = body.getReader();
const writeStream = plugins.fs.createWriteStream(filePath);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
writeStream.write(value);
}
writeStream.end();
await new Promise<void>((resolve, reject) => {
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});
} catch (error) {
writeStream.destroy();
throw error;
}
} else {
// Empty file
await plugins.fs.promises.writeFile(filePath, '');
}
return new Response(null, { status: exists ? 204 : 201 });
}
/**
* Handle DELETE request
*/
private async handleDelete(path: string, context: IWebDAVContext): Promise<Response> {
const filePath = this.resolvePath(path);
// Check lock
if (this.config.locking && this.isLocked(path) && !this.hasValidLock(path, context.lockToken)) {
return new Response('Locked', { status: 423 });
}
try {
const stat = await plugins.fs.promises.stat(filePath);
if (stat.isDirectory()) {
await plugins.fs.promises.rm(filePath, { recursive: true });
} else {
await plugins.fs.promises.unlink(filePath);
}
// Remove lock if exists
this.locks.delete(path);
return new Response(null, { status: 204 });
} catch (error: any) {
if (error.code === 'ENOENT') {
return new Response('Not Found', { status: 404 });
}
throw error;
}
}
/**
* Check if a path is locked
*/
private isLocked(path: string): boolean {
return this.locks.has(path);
}
/**
* Check if token matches lock
*/
private hasValidLock(path: string, token?: string): boolean {
if (!token) return false;
const lock = this.locks.get(path);
return lock?.token === token;
}
}

View File

@@ -0,0 +1,74 @@
/**
* WebDAV type definitions
*/
export type TWebDAVMethod =
| 'OPTIONS'
| 'GET'
| 'HEAD'
| 'PUT'
| 'DELETE'
| 'PROPFIND'
| 'PROPPATCH'
| 'MKCOL'
| 'COPY'
| 'MOVE'
| 'LOCK'
| 'UNLOCK';
export type TWebDAVDepth = '0' | '1' | 'infinity';
export interface IWebDAVProperty {
namespace: string;
name: string;
value?: string;
}
export interface IWebDAVResource {
href: string;
isCollection: boolean;
properties: IWebDAVProperty[];
displayName?: string;
contentType?: string;
contentLength?: number;
lastModified?: Date;
creationDate?: Date;
etag?: string;
}
export interface IWebDAVLock {
token: string;
owner: string;
depth: TWebDAVDepth;
timeout: number;
scope: 'exclusive' | 'shared';
type: 'write';
path: string;
created: Date;
}
export interface IWebDAVContext {
method: TWebDAVMethod;
depth: TWebDAVDepth;
lockToken?: string;
destination?: string;
overwrite: boolean;
timeout?: number;
}
// Standard WebDAV properties (DAV: namespace)
export const DAV_NAMESPACE = 'DAV:';
export const DAV_PROPERTIES = {
// Required properties
creationdate: 'creationdate',
displayname: 'displayname',
getcontentlanguage: 'getcontentlanguage',
getcontentlength: 'getcontentlength',
getcontenttype: 'getcontenttype',
getetag: 'getetag',
getlastmodified: 'getlastmodified',
lockdiscovery: 'lockdiscovery',
resourcetype: 'resourcetype',
supportedlock: 'supportedlock',
} as const;

View File

@@ -0,0 +1,184 @@
/**
* WebDAV XML generation utilities
*/
import type { IWebDAVResource, IWebDAVProperty, IWebDAVLock } from './webdav.types.js';
import { DAV_NAMESPACE } from './webdav.types.js';
/**
* Escape XML special characters
*/
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Format date for WebDAV (RFC 1123)
*/
function formatDate(date: Date): string {
return date.toUTCString();
}
/**
* Format date for creationdate (ISO 8601)
*/
function formatCreationDate(date: Date): string {
return date.toISOString();
}
/**
* Generate multistatus response for PROPFIND
*/
export function generateMultistatus(resources: IWebDAVResource[]): string {
const responses = resources.map(resource => generateResponse(resource)).join('\n');
return `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="${DAV_NAMESPACE}">
${responses}
</D:multistatus>`;
}
/**
* Generate single response element
*/
function generateResponse(resource: IWebDAVResource): string {
const props: string[] = [];
// Resource type
if (resource.isCollection) {
props.push('<D:resourcetype><D:collection/></D:resourcetype>');
} else {
props.push('<D:resourcetype/>');
}
// Display name
if (resource.displayName) {
props.push(`<D:displayname>${escapeXml(resource.displayName)}</D:displayname>`);
}
// Content type
if (resource.contentType) {
props.push(`<D:getcontenttype>${escapeXml(resource.contentType)}</D:getcontenttype>`);
}
// Content length
if (resource.contentLength !== undefined) {
props.push(`<D:getcontentlength>${resource.contentLength}</D:getcontentlength>`);
}
// Last modified
if (resource.lastModified) {
props.push(`<D:getlastmodified>${formatDate(resource.lastModified)}</D:getlastmodified>`);
}
// Creation date
if (resource.creationDate) {
props.push(`<D:creationdate>${formatCreationDate(resource.creationDate)}</D:creationdate>`);
}
// ETag
if (resource.etag) {
props.push(`<D:getetag>"${escapeXml(resource.etag)}"</D:getetag>`);
}
// Supported lock
props.push(`<D:supportedlock>
<D:lockentry>
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
</D:supportedlock>`);
// Custom properties
for (const prop of resource.properties) {
if (prop.value) {
props.push(`<${prop.name} xmlns="${prop.namespace}">${escapeXml(prop.value)}</${prop.name}>`);
} else {
props.push(`<${prop.name} xmlns="${prop.namespace}"/>`);
}
}
return ` <D:response>
<D:href>${escapeXml(resource.href)}</D:href>
<D:propstat>
<D:prop>
${props.join('\n ')}
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>`;
}
/**
* Generate lock discovery response
*/
export function generateLockDiscovery(locks: IWebDAVLock[]): string {
if (locks.length === 0) {
return '<D:lockdiscovery/>';
}
const lockEntries = locks.map(lock => `
<D:activelock>
<D:locktype><D:write/></D:locktype>
<D:lockscope><D:${lock.scope}/></D:lockscope>
<D:depth>${lock.depth}</D:depth>
<D:owner>${escapeXml(lock.owner)}</D:owner>
<D:timeout>Second-${lock.timeout}</D:timeout>
<D:locktoken><D:href>${escapeXml(lock.token)}</D:href></D:locktoken>
</D:activelock>`).join('');
return `<D:lockdiscovery>${lockEntries}
</D:lockdiscovery>`;
}
/**
* Generate lock response
*/
export function generateLockResponse(lock: IWebDAVLock): string {
return `<?xml version="1.0" encoding="utf-8"?>
<D:prop xmlns:D="${DAV_NAMESPACE}">
${generateLockDiscovery([lock])}
</D:prop>`;
}
/**
* Generate error response
*/
export function generateError(status: number, message: string): string {
return `<?xml version="1.0" encoding="utf-8"?>
<D:error xmlns:D="${DAV_NAMESPACE}">
<D:${message.toLowerCase().replace(/\s+/g, '-')}/>
</D:error>`;
}
/**
* Parse PROPFIND request body to extract requested properties
*/
export function parsePropfindRequest(body: string): { allprop: boolean; propnames: string[] } {
// Simple XML parsing for PROPFIND
if (!body || body.includes('<allprop') || body.includes('<D:allprop')) {
return { allprop: true, propnames: [] };
}
if (body.includes('<propname') || body.includes('<D:propname')) {
return { allprop: false, propnames: [] };
}
// Extract property names
const propnames: string[] = [];
const propMatch = body.match(/<(?:D:)?prop[^>]*>([\s\S]*?)<\/(?:D:)?prop>/i);
if (propMatch) {
const propContent = propMatch[1];
const tagMatches = propContent.matchAll(/<(?:D:)?(\w+)[^>]*\/?>/gi);
for (const match of tagMatches) {
propnames.push(match[1]);
}
}
return { allprop: propnames.length === 0, propnames };
}

2
ts/utils/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { getMimeType, isTextMimeType } from './utils.mime.js';
export { generateETag, generateStrongETag, matchesETag } from './utils.etag.js';

45
ts/utils/utils.etag.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* ETag generation utilities
*/
import type * as plugins from '../plugins.js';
/**
* Generate ETag from file stats
* Uses weak ETag format: W/"size-mtime"
*/
export function generateETag(stat: { size: number; mtime: Date }): string {
const mtime = stat.mtime.getTime().toString(16);
const size = stat.size.toString(16);
return `W/"${size}-${mtime}"`;
}
/**
* Generate strong ETag from content
* Uses hash of content
*/
export async function generateStrongETag(content: Uint8Array): Promise<string> {
const hashBuffer = await crypto.subtle.digest('SHA-256', content as unknown as ArrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return `"${hashHex.slice(0, 32)}"`;
}
/**
* Check if ETag matches
*/
export function matchesETag(etag: string, ifNoneMatch: string | null): boolean {
if (!ifNoneMatch) return false;
// Handle multiple ETags
const etags = ifNoneMatch.split(',').map(e => e.trim());
// Wildcard match
if (etags.includes('*')) return true;
// Weak comparison (ignore W/ prefix)
const normalizeETag = (e: string) => e.replace(/^W\//, '');
const normalizedETag = normalizeETag(etag);
return etags.some(e => normalizeETag(e) === normalizedETag);
}

101
ts/utils/utils.mime.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* MIME type detection based on file extension
*/
const MIME_TYPES: Record<string, string> = {
// Text
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.csv': 'text/csv; charset=utf-8',
'.yaml': 'text/yaml; charset=utf-8',
'.yml': 'text/yaml; charset=utf-8',
// Images
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.bmp': 'image/bmp',
'.avif': 'image/avif',
// Fonts
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.eot': 'application/vnd.ms-fontobject',
// Audio
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.m4a': 'audio/mp4',
'.flac': 'audio/flac',
'.aac': 'audio/aac',
// Video
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.ogv': 'video/ogg',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.mkv': 'video/x-matroska',
// Documents
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// Archives
'.zip': 'application/zip',
'.tar': 'application/x-tar',
'.gz': 'application/gzip',
'.rar': 'application/vnd.rar',
'.7z': 'application/x-7z-compressed',
// Source maps
'.map': 'application/json',
// TypeScript
'.ts': 'text/typescript; charset=utf-8',
'.tsx': 'text/typescript; charset=utf-8',
'.d.ts': 'text/typescript; charset=utf-8',
// WebAssembly
'.wasm': 'application/wasm',
// Manifest
'.webmanifest': 'application/manifest+json',
};
/**
* Get MIME type for a file path
*/
export function getMimeType(filePath: string): string {
const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0] ?? '';
return MIME_TYPES[ext] ?? 'application/octet-stream';
}
/**
* Check if MIME type is text-based
*/
export function isTextMimeType(mimeType: string): boolean {
return mimeType.startsWith('text/') ||
mimeType.includes('json') ||
mimeType.includes('xml') ||
mimeType.includes('javascript');
}