feat(TypedServer): Integrate SmartServe controller routing; add built-in routes controller and refactor TypedServer to use controllers and FileServer

This commit is contained in:
2025-12-02 20:47:11 +00:00
parent c17d6dac35
commit 27c96949a1
7 changed files with 299 additions and 212 deletions

View File

@@ -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

View File

@@ -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.'
}

View File

@@ -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<Response | null>;
@@ -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<Response | null> => {
// Set up custom request handler that integrates with ControllerRegistry
this.smartServe.setHandler(async (request: Request): Promise<Response> => {
return this.handleRequest(request);
});
@@ -282,168 +300,121 @@ export class TypedServer {
}
}
/**
* Create an IRequestContext from a Request
*/
private async createContext(
request: Request,
params: Record<string, string>
): Promise<plugins.smartserve.IRequestContext> {
const url = new URL(request.url);
const method = request.method.toUpperCase() as THttpMethod;
// Parse query params
const query: Record<string, string> = {};
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<Response | null> {
private async handleRequest(request: Request): Promise<Response> {
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<Response> {
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<Response> {
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<Response> {
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<string> {
return this.smartfeedInstance.createFeedFromArticleArray(feedMetadata, articles);
}
}

View File

@@ -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<plugins.tsclass.content.IArticle[]>;
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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' },
});
}
}

View File

@@ -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<Response | null> {
const url = new URL(request.url);
const path = url.pathname;
@plugins.smartserve.Post('/')
async handleTypedRequest(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
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;
}
}

View File

@@ -1,2 +1,3 @@
export * from './controller.devtools.js';
export * from './controller.typedrequest.js';
export * from './controller.builtin.js';

View File

@@ -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`