import * as plugins from './plugins.js'; import * as paths from './paths.js'; import * as interfaces from '../dist_ts_interfaces/index.js'; import { DevToolsController } from './controllers/controller.devtools.js'; import { TypedRequestController } from './controllers/controller.typedrequest.js'; import { BuiltInRoutesController } from './controllers/controller.builtin.js'; export interface IServerOptions { /** * serve a particular directory */ serveDir?: string; /** * inject a reload script that takes care of live reloading */ injectReload?: boolean; /** * watch the serve directory? */ watch?: boolean; cors: boolean; /** * a default answer given in case there is no other handler. */ defaultAnswer?: () => Promise; /** * will try to reroute traffic to an ssl connection using headers */ forceSsl?: boolean; /** * allows serving manifests */ manifest?: plugins.smartmanifest.ISmartManifestConstructorOptions; /** * the port to listen on */ port?: number | string; publicKey?: string; privateKey?: string; sitemap?: boolean; feed?: boolean; robots?: boolean; domain?: string; /** * convey information about the app being served */ appVersion?: string; feedMetadata?: plugins.smartfeed.IFeedOptions; articleGetterFunction?: () => Promise; blockWaybackMachine?: boolean; } export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'ALL'; export interface IRouteHandler { (request: Request): Promise; } export interface IRegisteredRoute { pattern: string; regex: RegExp; paramNames: string[]; method: THttpMethod; handler: IRouteHandler; } export class TypedServer { // instance public options: IServerOptions; public smartServe: plugins.smartserve.SmartServe; public smartwatchInstance: plugins.smartwatch.Smartwatch; public serveDirHashSubject = new plugins.smartrx.rxjs.ReplaySubject(1); public serveHash: string = '000000'; public typedsocket: plugins.typedsocket.TypedSocket; public typedrouter = new plugins.typedrequest.TypedRouter(); // Sitemap helper private sitemapHelper: SitemapHelper; private smartmanifestInstance: plugins.smartmanifest.SmartManifest; // Decorated controllers private devToolsController: DevToolsController; private typedRequestController: TypedRequestController; private builtInRoutesController: BuiltInRoutesController; // File server for static files private fileServer: plugins.smartserve.FileServer; // Custom route handlers (for addRoute API) private customRoutes: IRegisteredRoute[] = []; public lastReload: number = Date.now(); public ended = false; constructor(optionsArg: IServerOptions) { const standardOptions: IServerOptions = { port: 3000, injectReload: false, serveDir: null, watch: false, cors: true, }; this.options = { ...standardOptions, ...optionsArg, }; } /** * Access sitemap URLs (for adding/replacing) */ public get sitemap() { return this.sitemapHelper; } /** * Add a custom route handler * Supports Express-style path patterns like '/path/:param' and '/path/*splat' * @param path - The route path pattern * @param method - HTTP method (GET, POST, PUT, DELETE, PATCH, ALL) * @param handler - Async function that receives Request and returns Response or null */ public addRoute(path: string, method: THttpMethod, handler: IRouteHandler): void { // Convert Express-style path to regex const paramNames: string[] = []; let regexPattern = path // Handle named parameters :param .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => { paramNames.push(paramName); return '([^/]+)'; }) // Handle wildcard *splat (matches everything including slashes) .replace(/\*([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => { paramNames.push(paramName); return '(.*)'; }); // Ensure exact match regexPattern = `^${regexPattern}$`; this.customRoutes.push({ pattern: path, regex: new RegExp(regexPattern), paramNames, method, handler, }); } /** * Parse route parameters from a path using a registered route */ private parseRouteParams( route: IRegisteredRoute, pathname: string ): Record | null { const match = pathname.match(route.regex); if (!match) return null; const params: Record = {}; route.paramNames.forEach((name, index) => { params[name] = match[index + 1]; }); return params; } /** * inits and starts the server */ public async start() { // Validate essential configuration before starting if (this.options.injectReload && !this.options.serveDir) { throw new Error( 'You set to inject the reload script without a serve dir. This is not supported at the moment.' ); } const port = typeof this.options.port === 'string' ? parseInt(this.options.port, 10) : this.options.port || 3000; // Initialize optional helpers if (this.options.sitemap) { this.sitemapHelper = new SitemapHelper(this.options.domain); } if (this.options.manifest) { this.smartmanifestInstance = new plugins.smartmanifest.SmartManifest(this.options.manifest); } // Initialize file server for static files if (this.options.serveDir) { this.fileServer = new plugins.smartserve.FileServer({ root: this.options.serveDir, index: ['index.html'], etag: true, }); } // Initialize decorated controllers if (this.options.injectReload) { this.devToolsController = new DevToolsController({ getLastReload: () => this.lastReload, getEnded: () => this.ended, }); } this.typedRequestController = new TypedRequestController(this.typedrouter); this.builtInRoutesController = new BuiltInRoutesController({ domain: this.options.domain, robots: this.options.robots, manifest: this.smartmanifestInstance, sitemap: this.options.sitemap, feed: this.options.feed, appVersion: this.options.appVersion, feedMetadata: this.options.feedMetadata, articleGetterFunction: this.options.articleGetterFunction, blockWaybackMachine: this.options.blockWaybackMachine, getSitemapUrls: () => this.sitemapHelper?.urls || [], }); // Register controllers with SmartServe's ControllerRegistry if (this.options.injectReload) { plugins.smartserve.ControllerRegistry.registerInstance(this.devToolsController); } plugins.smartserve.ControllerRegistry.registerInstance(this.typedRequestController); plugins.smartserve.ControllerRegistry.registerInstance(this.builtInRoutesController); // Compile routes for fast matching plugins.smartserve.ControllerRegistry.compileRoutes(); // Build SmartServe options const smartServeOptions: plugins.smartserve.ISmartServeOptions = { port, hostname: '0.0.0.0', tls: this.options.privateKey && this.options.publicKey ? { key: this.options.privateKey, cert: this.options.publicKey, } : undefined, websocket: { typedRouter: this.typedrouter, onConnectionOpen: (peer) => { peer.tags.add('typedserver_frontend'); console.log(`WebSocket connected: ${peer.id}`); }, onConnectionClose: (peer) => { console.log(`WebSocket disconnected: ${peer.id}`); }, }, }; this.smartServe = new plugins.smartserve.SmartServe(smartServeOptions); // Set up custom request handler that integrates with ControllerRegistry this.smartServe.setHandler(async (request: Request): Promise => { return this.handleRequest(request); }); // Setup file watching if (this.options.watch && this.options.serveDir) { try { // Use glob pattern to match all files recursively in serveDir const watchGlob = this.options.serveDir.endsWith('/') ? `${this.options.serveDir}**/*` : `${this.options.serveDir}/**/*`; this.smartwatchInstance = new plugins.smartwatch.Smartwatch([watchGlob]); await this.smartwatchInstance.start(); (await this.smartwatchInstance.getObservableFor('change')).subscribe(async () => { await this.createServeDirHash(); this.reload(); }); await this.createServeDirHash(); } catch (error) { console.error('Failed to initialize file watching:', error); } } // Start the server await this.smartServe.start(); console.log(`TypedServer listening on port ${port}`); // Setup TypedSocket using SmartServe integration try { this.typedsocket = plugins.typedsocket.TypedSocket.fromSmartServe( this.smartServe, this.typedrouter ); // Setup typedrouter handlers this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler('getLatestServerChangeTime', async () => { return { time: this.lastReload, }; }) ); } catch (error) { console.error('Failed to initialize TypedSocket:', error); } } /** * Create an IRequestContext from a Request */ private async createContext( request: Request, params: Record ): Promise { const url = new URL(request.url); const method = request.method.toUpperCase() as THttpMethod; // Parse query params const query: Record = {}; url.searchParams.forEach((value, key) => { query[key] = value; }); // Parse body let body: unknown = undefined; const contentType = request.headers.get('content-type'); if (contentType?.includes('application/json')) { try { body = await request.clone().json(); } catch { body = {}; } } return { request, body, params, query, headers: request.headers, path: url.pathname, method, url, runtime: 'node' as const, state: {}, }; } /** * Main request handler - routes to appropriate sub-handlers */ private async handleRequest(request: Request): Promise { const url = new URL(request.url); const path = url.pathname; const method = request.method.toUpperCase() as THttpMethod; // First, try to match via ControllerRegistry (decorated routes) const match = plugins.smartserve.ControllerRegistry.matchRoute(path, method); if (match) { try { const context = await this.createContext(request, match.params); const result = await match.route.handler(context); // Handle Response or convert to Response if (result instanceof Response) { return result; } return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } catch (error) { if (error instanceof plugins.smartserve.RouteNotFoundError) { // Route explicitly threw "not found", continue to other handlers } else { console.error('Controller error:', error); return new Response('Internal Server Error', { status: 500 }); } } } // Custom routes (registered via addRoute) for (const route of this.customRoutes) { if (route.method === 'ALL' || route.method === method) { const params = this.parseRouteParams(route, path); if (params !== null) { (request as any).params = params; const response = await route.handler(request); if (response) return response; } } } // HTML injection for reload (if enabled) if (this.options.injectReload && this.options.serveDir) { const response = await this.handleHtmlWithInjection(request); if (response) return response; } // Try static file serving if (this.fileServer && (method === 'GET' || method === 'HEAD')) { try { const staticResponse = await this.fileServer.serve(request); if (staticResponse) { return staticResponse; } } catch (error) { // Fall through to 404 } } // Default answer for root if (path === '/' && method === 'GET' && this.options.defaultAnswer) { const html = await this.options.defaultAnswer(); return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' }, }); } // Not found return new Response('Not Found', { status: 404 }); } /** * Handle HTML files with reload script injection */ private async handleHtmlWithInjection(request: Request): Promise { const url = new URL(request.url); const requestPath = url.pathname; // Check if this is a request for an HTML file or root if (requestPath === '/' || requestPath.endsWith('.html') || !requestPath.includes('.')) { try { let filePath = requestPath === '/' ? 'index.html' : requestPath.slice(1); if (!filePath.endsWith('.html') && !filePath.includes('.')) { filePath = plugins.path.join(filePath, 'index.html'); } const fullPath = plugins.path.join(this.options.serveDir, filePath); // Security check if (!fullPath.startsWith(this.options.serveDir)) { return new Response('Forbidden', { status: 403 }); } let fileContent = (await plugins.fsInstance .file(fullPath) .encoding('utf8') .read()) as string; // Inject reload script if (fileContent.includes('')) { const injection = ` `; fileContent = fileContent.replace('', injection); console.log('injected typedserver script.'); } return new Response(fileContent, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate', Pragma: 'no-cache', Expires: '0', appHash: this.serveHash, }, }); } catch (error) { // Fall through to default handling } } return null; } /** * reloads the page */ public async reload() { this.lastReload = Date.now(); if (!this.typedsocket) { console.warn('TypedSocket not initialized, skipping client notifications'); return; } try { const connections = await this.typedsocket.findAllTargetConnectionsByTag( 'typedserver_frontend' ); for (const connection of connections) { const pushTime = this.typedsocket.createTypedRequest( 'pushLatestServerChangeTime', connection ); pushTime.fire({ time: this.lastReload, }); } } catch (error) { console.error('Failed to notify clients about reload:', error); } } /** * Stops the server and cleans up resources */ public async stop(): Promise { this.ended = true; const stopWithErrorHandling = async ( stopFn: () => Promise, componentName: string ): Promise => { try { await stopFn(); } catch (err) { console.error(`Error stopping ${componentName}:`, err); } }; const tasks: Promise[] = []; // Stop SmartServe if (this.smartServe) { tasks.push(stopWithErrorHandling(() => this.smartServe.stop(), 'SmartServe')); } // Stop TypedSocket (in SmartServe mode, this is a no-op but good for cleanup) if (this.typedsocket) { tasks.push(stopWithErrorHandling(() => this.typedsocket.stop(), 'TypedSocket')); } // Stop file watcher if (this.smartwatchInstance) { tasks.push(stopWithErrorHandling(() => this.smartwatchInstance.stop(), 'file watcher')); } await Promise.all(tasks); } /** * Calculates a hash of the served directory for cache busting */ public async createServeDirHash() { try { const serveDirHash = await plugins.fsInstance .directory(this.options.serveDir) .recursive() .treeHash(); this.serveHash = serveDirHash.slice(0, 12); console.log('Current ServeDir hash: ' + this.serveHash); this.serveDirHashSubject.next(this.serveHash); } catch (error) { console.error('Failed to create serve directory hash:', error); const fallbackHash = Date.now().toString(16).slice(-6); this.serveHash = fallbackHash; console.log('Using fallback hash: ' + fallbackHash); this.serveDirHashSubject.next(fallbackHash); } } } // ============================================================================ // Helper Classes // ============================================================================ /** * Sitemap helper class */ class SitemapHelper { private smartSitemap = new plugins.smartsitemap.SmartSitemap(); public urls: plugins.smartsitemap.IUrlInfo[] = []; constructor(domain?: string) { if (domain) { this.urls.push({ url: `https://${domain}/`, timestamp: Date.now(), frequency: 'daily', }); } } async createSitemap(): Promise { return this.smartSitemap.createSitemapFromUrlInfoArray(this.urls); } async createSitemapNews(articles: plugins.tsclass.content.IArticle[]): Promise { return this.smartSitemap.createSitemapNewsFromArticleArray(articles); } replaceUrls(urlsArg: plugins.smartsitemap.IUrlInfo[]) { this.urls = urlsArg; } addUrls(urlsArg: plugins.smartsitemap.IUrlInfo[]) { this.urls = this.urls.concat(urlsArg); } }