Files
typedserver/ts/classes.typedserver.ts
Juergen Kunz c17d6dac35 feat: Refactor TypedServer to use SmartServe and introduce new request handlers
- Removed legacy servertools and Express dependencies in favor of SmartServe.
- Introduced DevToolsHandler and TypedRequestHandler for handling specific routes.
- Added support for custom route registration with regex parsing.
- Implemented sitemap and feed handling with dedicated helper classes.
- Enhanced HTML response handling with reload script injection.
- Updated UtilityServiceServer and UtilityWebsiteServer to utilize new TypedServer API.
- Removed deprecated compression options and Express-based route handling.
- Added comprehensive request handling for various endpoints including robots.txt, manifest.json, and sitemap.
- Improved error handling and response formatting across the server.
2025-12-02 20:26:34 +00:00

650 lines
18 KiB
TypeScript

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<string>;
/**
* 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<plugins.tsclass.content.IArticle[]>;
blockWaybackMachine?: boolean;
}
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'ALL';
export interface IRouteHandler {
(request: Request): Promise<Response | null>;
}
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<string>(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<string, string> | null {
const match = pathname.match(route.regex);
if (!match) return null;
const params: Record<string, string> = {};
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<Response | null> => {
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<interfaces.IReq_GetLatestServerChangeTime>(
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<Response | null> {
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<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 });
}
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 });
}
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<Response | null> {
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('<head>')) {
const injection = `<head>
<!-- injected by @apiglobal/typedserver start -->
<script async defer type="module" src="/typedserver/devtools"></script>
<script>
globalThis.typedserver = {
lastReload: ${this.lastReload},
versionInfo: ${JSON.stringify({}, null, 2)},
}
</script>
<!-- injected by @apiglobal/typedserver stop -->
`;
fileContent = fileContent.replace('<head>', 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<interfaces.IReq_PushLatestServerChangeTime>(
'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<void> {
this.ended = true;
const stopWithErrorHandling = async (
stopFn: () => Promise<unknown>,
componentName: string
): Promise<void> => {
try {
await stopFn();
} catch (err) {
console.error(`Error stopping ${componentName}:`, err);
}
};
const tasks: Promise<void>[] = [];
// 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<string> {
return this.smartSitemap.createSitemapFromUrlInfoArray(this.urls);
}
async createSitemapNews(articles: plugins.tsclass.content.IArticle[]): Promise<string> {
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<string> {
return this.smartfeedInstance.createFeedFromArticleArray(feedMetadata, articles);
}
}