import * as plugins from './plugins.js'; import * as paths from './paths.js'; import * as interfaces from '../dist_ts_interfaces/index.js'; import { DevToolsHandler } from './controllers/controller.devtools.js'; import { TypedRequestHandler } from './controllers/controller.typedrequest.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' | '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 and Feed helpers private sitemapHelper: SitemapHelper; private feedHelper: FeedHelper; private smartmanifestInstance: plugins.smartmanifest.SmartManifest; // Request handlers private devToolsHandler: DevToolsHandler; private typedRequestHandler: TypedRequestHandler; // 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, }; // Initialize handlers this.devToolsHandler = new DevToolsHandler({ getLastReload: () => this.lastReload, getEnded: () => this.ended, }); this.typedRequestHandler = new TypedRequestHandler(this.typedrouter); } /** * 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.feed) { this.feedHelper = new FeedHelper(); } if (this.options.manifest) { this.smartmanifestInstance = new plugins.smartmanifest.SmartManifest(this.options.manifest); } // 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}`); }, }, static: this.options.serveDir ? { root: this.options.serveDir, index: ['index.html'], etag: true, } : undefined, }; this.smartServe = new plugins.smartserve.SmartServe(smartServeOptions); // Set up custom request handler for all custom routes this.smartServe.setHandler(async (request: Request): Promise => { return this.handleRequest(request); }); // Setup file watching if (this.options.watch && this.options.serveDir) { try { this.smartwatchInstance = new plugins.smartwatch.Smartwatch([this.options.serveDir]); 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); } } /** * 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; // DevTools handler let response = await this.devToolsHandler.handle(request); if (response) return response; // TypedRequest handler response = await this.typedRequestHandler.handle(request); if (response) return response; // 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) { // Attach params to request for handler to access (request as any).params = params; response = await route.handler(request); if (response) return response; } } } // Robots.txt if (this.options.robots && this.options.domain && path === '/robots.txt' && method === 'GET') { return this.handleRobots(); } // Manifest.json if (this.options.manifest && path === '/manifest.json' && method === 'GET') { return this.handleManifest(); } // Sitemap if (this.options.sitemap && path === '/sitemap' && method === 'GET') { return this.handleSitemap(); } // Sitemap News if (this.options.sitemap && path === '/sitemap-news' && method === 'GET') { return this.handleSitemapNews(); } // Feed if (this.options.feed && path === '/feed' && method === 'GET') { return this.handleFeed(); } // App version if (this.options.appVersion && path === '/appversion' && method === 'GET') { return new Response(this.options.appVersion, { status: 200, headers: { 'Content-Type': 'text/plain' }, }); } // HTML injection for reload (if enabled) if (this.options.injectReload && this.options.serveDir) { response = await this.handleHtmlWithInjection(request); if (response) return response; } // Not handled - let SmartServe handle (static files, etc.) return null; } /** * Handle robots.txt request */ private handleRobots(): Response { const waybackBlock = this.options.blockWaybackMachine ? ` User-Agent: ia_archiver Disallow: / ` : ''; const content = ` User-agent: Googlebot-News Disallow: /account Disallow: /login User-agent: * Disallow: /account Disallow: /login ${waybackBlock} Sitemap: https://${this.options.domain}/sitemap Sitemap: https://${this.options.domain}/sitemap-news `; return new Response(content.trim(), { status: 200, headers: { 'Content-Type': 'text/plain' }, }); } /** * Handle manifest.json request */ private handleManifest(): Response { return new Response(this.smartmanifestInstance.jsonString(), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } /** * Handle sitemap request */ private async handleSitemap(): Promise { const sitemapXmlString = await this.sitemapHelper.createSitemap(); return new Response(sitemapXmlString, { status: 200, headers: { 'Content-Type': 'application/xml' }, }); } /** * Handle sitemap-news request */ private async handleSitemapNews(): Promise { if (!this.options.articleGetterFunction) { return new Response('no article getter function defined.', { status: 500 }); } const sitemapNewsXml = await this.sitemapHelper.createSitemapNews( await this.options.articleGetterFunction() ); return new Response(sitemapNewsXml, { status: 200, headers: { 'Content-Type': 'application/xml' }, }); } /** * Handle feed request */ private async handleFeed(): Promise { if (!this.options.feedMetadata) { return new Response('feed metadata is missing', { status: 500 }); } if (!this.options.articleGetterFunction) { return new Response('no article getter function defined.', { status: 500 }); } const xmlString = await this.feedHelper.createFeed( this.options.feedMetadata, await this.options.articleGetterFunction() ); return new Response(xmlString, { status: 200, headers: { 'Content-Type': 'application/xml' }, }); } /** * 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); } } /** * Feed helper class */ class FeedHelper { private smartfeedInstance = new plugins.smartfeed.Smartfeed(); async createFeed( feedMetadata: plugins.smartfeed.IFeedOptions, articles: plugins.tsclass.content.IArticle[] ): Promise { return this.smartfeedInstance.createFeedFromArticleArray(feedMetadata, articles); } }