From 27c96949a1847e2747042fd781bbe56e048d30db Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 2 Dec 2025 20:47:11 +0000 Subject: [PATCH] feat(TypedServer): Integrate SmartServe controller routing; add built-in routes controller and refactor TypedServer to use controllers and FileServer --- changelog.md | 13 + ts/00_commitinfo_data.ts | 2 +- ts/classes.typedserver.ts | 317 +++++++++----------- ts/controllers/controller.builtin.ts | 125 ++++++++ ts/controllers/controller.typedrequest.ts | 49 ++- ts/controllers/index.ts | 1 + ts_web_inject/typedserver_web.infoscreen.ts | 4 +- 7 files changed, 299 insertions(+), 212 deletions(-) create mode 100644 ts/controllers/controller.builtin.ts diff --git a/changelog.md b/changelog.md index 615c200..aaf6e29 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,18 @@ # Changelog +## 2025-12-02 - 4.1.0 - feat(TypedServer) +Integrate SmartServe controller routing; add built-in routes controller and refactor TypedServer to use controllers and FileServer + +- Add BuiltInRoutesController exposing /robots.txt, /manifest.json, /sitemap, /sitemap-news, /feed and /appversion +- Refactor TypedRequestHandler into a SmartServe-decorated TypedRequestController and register it with ControllerRegistry +- Refactor TypedServer to use SmartServe: register controller instances, use ControllerRegistry matching, and delegate WebSocket integration to SmartServe +- Introduce FileServer-based static serving with HTML reload script injection and improved default root handling +- Expand supported HTTP methods to include HEAD and OPTIONS +- Remove legacy FeedHelper and consolidate sitemap/feed handling into controllers and helpers +- Enhance servertools legacy Express utilities: improved HandlerProxy, HandlerStatic, Compressor with caching and preferred compression support +- Service worker subsystem improvements: CacheManager, NetworkManager, UpdateManager and backend enhancements for robust caching, revalidation and client reloads +- Web-inject LitElement properties switched from private fields to accessor syntax (typedserver_web.infoscreen) + ## 2025-12-02 - 4.0.0 - BREAKING CHANGE(typedserver) Migrate to new push.rocks packages and async smartfs API; replace smartchok with smartwatch; update deps and service worker handling diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index c4ab590..f1b79cd 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@api.global/typedserver', - version: '4.0.0', + version: '4.1.0', description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.' } diff --git a/ts/classes.typedserver.ts b/ts/classes.typedserver.ts index 327af91..beea564 100644 --- a/ts/classes.typedserver.ts +++ b/ts/classes.typedserver.ts @@ -1,8 +1,9 @@ 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'; +import { DevToolsController } from './controllers/controller.devtools.js'; +import { TypedRequestController } from './controllers/controller.typedrequest.js'; +import { BuiltInRoutesController } from './controllers/controller.builtin.js'; export interface IServerOptions { /** @@ -57,7 +58,7 @@ export interface IServerOptions { blockWaybackMachine?: boolean; } -export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'ALL'; +export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'ALL'; export interface IRouteHandler { (request: Request): Promise; @@ -81,14 +82,17 @@ export class TypedServer { public typedsocket: plugins.typedsocket.TypedSocket; public typedrouter = new plugins.typedrequest.TypedRouter(); - // Sitemap and Feed helpers + // Sitemap helper private sitemapHelper: SitemapHelper; - private feedHelper: FeedHelper; private smartmanifestInstance: plugins.smartmanifest.SmartManifest; - // Request handlers - private devToolsHandler: DevToolsHandler; - private typedRequestHandler: TypedRequestHandler; + // 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[] = []; @@ -108,13 +112,6 @@ export class TypedServer { ...standardOptions, ...optionsArg, }; - - // Initialize handlers - this.devToolsHandler = new DevToolsHandler({ - getLastReload: () => this.lastReload, - getEnded: () => this.ended, - }); - this.typedRequestHandler = new TypedRequestHandler(this.typedrouter); } /** @@ -131,11 +128,7 @@ export class TypedServer { * @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 { + public addRoute(path: string, method: THttpMethod, handler: IRouteHandler): void { // Convert Express-style path to regex const paramNames: string[] = []; let regexPattern = path @@ -199,13 +192,45 @@ export class TypedServer { 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); } + // 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 + 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 + plugins.smartserve.ControllerRegistry.registerInstance(this.devToolsController); + plugins.smartserve.ControllerRegistry.registerInstance(this.typedRequestController); + plugins.smartserve.ControllerRegistry.registerInstance(this.builtInRoutesController); + // Build SmartServe options const smartServeOptions: plugins.smartserve.ISmartServeOptions = { port, @@ -227,19 +252,12 @@ export class TypedServer { 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 => { + // Set up custom request handler that integrates with ControllerRegistry + this.smartServe.setHandler(async (request: Request): Promise => { return this.handleRequest(request); }); @@ -282,168 +300,121 @@ export class TypedServer { } } + /** + * 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 { + private async handleRequest(request: Request): Promise { const url = new URL(request.url); const path = url.pathname; - const method = request.method; + const method = request.method.toUpperCase() as THttpMethod; - // DevTools handler - let response = await this.devToolsHandler.handle(request); - if (response) return response; + // 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); - // TypedRequest handler - response = await this.typedRequestHandler.handle(request); - if (response) return response; + // 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) { - // Attach params to request for handler to access (request as any).params = params; - response = await route.handler(request); + const 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); + const 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 }); + // 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 + } } - 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 }); + // 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' }, + }); } - 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' }, - }); + // Not found + return new Response('Not Found', { status: 404 }); } /** @@ -633,17 +604,3 @@ class SitemapHelper { 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); - } -} diff --git a/ts/controllers/controller.builtin.ts b/ts/controllers/controller.builtin.ts new file mode 100644 index 0000000..084851d --- /dev/null +++ b/ts/controllers/controller.builtin.ts @@ -0,0 +1,125 @@ +import * as plugins from '../plugins.js'; + +/** + * Built-in routes controller for TypedServer + * Handles robots.txt, manifest.json, sitemap, feed, appversion + */ +@plugins.smartserve.Route('') +export class BuiltInRoutesController { + private options: { + domain?: string; + robots?: boolean; + manifest?: plugins.smartmanifest.SmartManifest; + sitemap?: boolean; + feed?: boolean; + appVersion?: string; + feedMetadata?: plugins.smartfeed.IFeedOptions; + articleGetterFunction?: () => Promise; + blockWaybackMachine?: boolean; + getSitemapUrls: () => plugins.smartsitemap.IUrlInfo[]; + }; + + constructor(options: typeof BuiltInRoutesController.prototype.options) { + this.options = options; + } + + @plugins.smartserve.Get('/robots.txt') + async getRobots(ctx: plugins.smartserve.IRequestContext): Promise { + if (!this.options.robots || !this.options.domain) { + throw new plugins.smartserve.RouteNotFoundError(ctx.path, ctx.method); + } + + const robotsContent = [ + 'User-agent: *', + 'Allow: /', + `Sitemap: https://${this.options.domain}/sitemap`, + ]; + + if (this.options.blockWaybackMachine) { + robotsContent.push('', 'User-agent: ia_archiver', 'Disallow: /'); + } + + return new Response(robotsContent.join('\n'), { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + } + + @plugins.smartserve.Get('/manifest.json') + async getManifest(ctx: plugins.smartserve.IRequestContext): Promise { + if (!this.options.manifest) { + throw new plugins.smartserve.RouteNotFoundError(ctx.path, ctx.method); + } + + return new Response(this.options.manifest.jsonString(), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + @plugins.smartserve.Get('/sitemap') + async getSitemap(ctx: plugins.smartserve.IRequestContext): Promise { + if (!this.options.sitemap || !this.options.domain) { + throw new plugins.smartserve.RouteNotFoundError(ctx.path, ctx.method); + } + + const smartsitemap = new plugins.smartsitemap.SmartSitemap(); + const urls = this.options.getSitemapUrls(); + const sitemapXml = await smartsitemap.createSitemapFromUrlInfoArray(urls); + + return new Response(sitemapXml, { + status: 200, + headers: { 'Content-Type': 'application/xml' }, + }); + } + + @plugins.smartserve.Get('/sitemap-news') + async getSitemapNews(ctx: plugins.smartserve.IRequestContext): Promise { + if (!this.options.sitemap || !this.options.domain || !this.options.articleGetterFunction) { + throw new plugins.smartserve.RouteNotFoundError(ctx.path, ctx.method); + } + + const smartsitemap = new plugins.smartsitemap.SmartSitemap(); + const articles = await this.options.articleGetterFunction(); + const sitemapNewsXml = await smartsitemap.createSitemapNewsFromArticleArray(articles); + + return new Response(sitemapNewsXml, { + status: 200, + headers: { 'Content-Type': 'application/xml' }, + }); + } + + @plugins.smartserve.Get('/feed') + async getFeed(ctx: plugins.smartserve.IRequestContext): Promise { + if (!this.options.feed || !this.options.feedMetadata) { + throw new plugins.smartserve.RouteNotFoundError(ctx.path, ctx.method); + } + + const smartfeed = new plugins.smartfeed.Smartfeed(); + const articles = this.options.articleGetterFunction + ? await this.options.articleGetterFunction() + : []; + + const feedXml = await smartfeed.createFeedFromArticleArray( + this.options.feedMetadata, + articles + ); + + return new Response(feedXml, { + status: 200, + headers: { 'Content-Type': 'application/atom+xml' }, + }); + } + + @plugins.smartserve.Get('/appversion') + async getAppVersion(ctx: plugins.smartserve.IRequestContext): Promise { + if (!this.options.appVersion) { + throw new plugins.smartserve.RouteNotFoundError(ctx.path, ctx.method); + } + + return new Response(this.options.appVersion, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + } +} diff --git a/ts/controllers/controller.typedrequest.ts b/ts/controllers/controller.typedrequest.ts index a145096..b73cdb5 100644 --- a/ts/controllers/controller.typedrequest.ts +++ b/ts/controllers/controller.typedrequest.ts @@ -1,43 +1,34 @@ import * as plugins from '../plugins.js'; /** - * TypedRequest handler for type-safe RPC endpoint + * TypedRequest controller for type-safe RPC endpoint */ -export class TypedRequestHandler { +@plugins.smartserve.Route('/typedrequest') +export class TypedRequestController { private typedRouter: plugins.typedrequest.TypedRouter; constructor(typedRouter: plugins.typedrequest.TypedRouter) { this.typedRouter = typedRouter; } - /** - * Handle a request - returns Response if handled, null otherwise - */ - public async handle(request: Request): Promise { - const url = new URL(request.url); - const path = url.pathname; + @plugins.smartserve.Post('/') + async handleTypedRequest(ctx: plugins.smartserve.IRequestContext): Promise { + try { + const response = await this.typedRouter.routeAndAddResponse(ctx.body as plugins.typedrequestInterfaces.ITypedRequest); - if (path === '/typedrequest' && request.method === 'POST') { - try { - const body = await request.json(); - const response = await this.typedRouter.routeAndAddResponse(body); - - return new Response(plugins.smartjson.stringify(response), { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (error) { - return new Response(JSON.stringify({ error: 'Invalid request' }), { - status: 400, - headers: { - 'Content-Type': 'application/json', - }, - }); - } + return new Response(plugins.smartjson.stringify(response), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + return new Response(JSON.stringify({ error: 'Invalid request' }), { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + }); } - - return null; } } diff --git a/ts/controllers/index.ts b/ts/controllers/index.ts index 696a0a8..d553af1 100644 --- a/ts/controllers/index.ts +++ b/ts/controllers/index.ts @@ -1,2 +1,3 @@ export * from './controller.devtools.js'; export * from './controller.typedrequest.js'; +export * from './controller.builtin.js'; diff --git a/ts_web_inject/typedserver_web.infoscreen.ts b/ts_web_inject/typedserver_web.infoscreen.ts index 934a475..5aa8a43 100644 --- a/ts_web_inject/typedserver_web.infoscreen.ts +++ b/ts_web_inject/typedserver_web.infoscreen.ts @@ -14,10 +14,10 @@ export class TypedserverInfoscreen extends LitElement { //INSTANCE @property() - private text = 'Hello'; + accessor text = 'Hello'; @property() - private success = false; + accessor success = false; public static styles = [ css`