Introduce structured security headers support (CSP, HSTS, X-Frame-Options, COOP/COEP/CORP, Permissions-Policy, Referrer-Policy, X-XSS-Protection, etc.) and apply them to responses and OPTIONS preflight. Expose configuration via the server API and document usage. Also update UtilityWebsiteServer defaults (SPA fallback enabled by default) and related docs.
944 lines
29 KiB
TypeScript
944 lines
29 KiB
TypeScript
import * as plugins from './plugins.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';
|
|
|
|
/**
|
|
* Content Security Policy configuration
|
|
* Each directive can be a string or array of sources
|
|
*/
|
|
export interface IContentSecurityPolicy {
|
|
/** Fallback for other directives */
|
|
defaultSrc?: string | string[];
|
|
/** Valid sources for scripts */
|
|
scriptSrc?: string | string[];
|
|
/** Valid sources for stylesheets */
|
|
styleSrc?: string | string[];
|
|
/** Valid sources for images */
|
|
imgSrc?: string | string[];
|
|
/** Valid sources for fonts */
|
|
fontSrc?: string | string[];
|
|
/** Valid sources for AJAX, WebSockets, etc. */
|
|
connectSrc?: string | string[];
|
|
/** Valid sources for media (audio/video) */
|
|
mediaSrc?: string | string[];
|
|
/** Valid sources for frames */
|
|
frameSrc?: string | string[];
|
|
/** Valid sources for <object>, <embed>, <applet> */
|
|
objectSrc?: string | string[];
|
|
/** Valid sources for web workers */
|
|
workerSrc?: string | string[];
|
|
/** Valid sources for form actions */
|
|
formAction?: string | string[];
|
|
/** Controls which URLs can embed the page */
|
|
frameAncestors?: string | string[];
|
|
/** Restricts URLs for <base> element */
|
|
baseUri?: string | string[];
|
|
/** Report violations to this URL */
|
|
reportUri?: string;
|
|
/** Report violations to this endpoint */
|
|
reportTo?: string;
|
|
/** Upgrade insecure requests to HTTPS */
|
|
upgradeInsecureRequests?: boolean;
|
|
/** Block all mixed content */
|
|
blockAllMixedContent?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Security headers configuration
|
|
*/
|
|
export interface ISecurityHeaders {
|
|
/** Content Security Policy */
|
|
csp?: IContentSecurityPolicy;
|
|
/** X-Frame-Options: DENY, SAMEORIGIN, or ALLOW-FROM uri */
|
|
xFrameOptions?: 'DENY' | 'SAMEORIGIN' | string;
|
|
/** X-Content-Type-Options: nosniff */
|
|
xContentTypeOptions?: boolean;
|
|
/** X-XSS-Protection header (legacy, but still useful) */
|
|
xXssProtection?: boolean | string;
|
|
/** Referrer-Policy header */
|
|
referrerPolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url';
|
|
/** Strict-Transport-Security (HSTS) max-age in seconds */
|
|
hstsMaxAge?: number;
|
|
/** Include subdomains in HSTS */
|
|
hstsIncludeSubDomains?: boolean;
|
|
/** HSTS preload flag */
|
|
hstsPreload?: boolean;
|
|
/** Permissions-Policy (formerly Feature-Policy) */
|
|
permissionsPolicy?: Record<string, string[]>;
|
|
/** Cross-Origin-Opener-Policy */
|
|
crossOriginOpenerPolicy?: 'unsafe-none' | 'same-origin-allow-popups' | 'same-origin';
|
|
/** Cross-Origin-Embedder-Policy */
|
|
crossOriginEmbedderPolicy?: 'unsafe-none' | 'require-corp' | 'credentialless';
|
|
/** Cross-Origin-Resource-Policy */
|
|
crossOriginResourcePolicy?: 'same-site' | 'same-origin' | 'cross-origin';
|
|
}
|
|
|
|
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;
|
|
|
|
/**
|
|
* SPA fallback - serve index.html for non-file routes (e.g., /login, /dashboard)
|
|
* Useful for single-page applications with client-side routing
|
|
*/
|
|
spaFallback?: boolean;
|
|
|
|
/**
|
|
* Security headers configuration (CSP, HSTS, X-Frame-Options, etc.)
|
|
*/
|
|
securityHeaders?: ISecurityHeaders;
|
|
}
|
|
|
|
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | '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 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<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.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('allClients');
|
|
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<Response> => {
|
|
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<interfaces.IReq_GetLatestServerChangeTime>(
|
|
new plugins.typedrequest.TypedHandler('getLatestServerChangeTime', async () => {
|
|
return {
|
|
time: this.lastReload,
|
|
};
|
|
})
|
|
);
|
|
|
|
// Speedtest handler for service worker dashboard
|
|
// Client calls this in a loop for the test duration to get accurate time-based measurements
|
|
this.typedrouter.addTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_Speedtest>(
|
|
new plugins.typedrequest.TypedHandler('serviceworker_speedtest', async (reqArg) => {
|
|
const chunkSizeKB = reqArg.chunkSizeKB || 64;
|
|
const sizeBytes = chunkSizeKB * 1024;
|
|
let payload: string | undefined;
|
|
let bytesTransferred = 0;
|
|
|
|
switch (reqArg.type) {
|
|
case 'download_chunk':
|
|
// Generate chunk data for download test
|
|
payload = 'x'.repeat(sizeBytes);
|
|
bytesTransferred = sizeBytes;
|
|
break;
|
|
case 'upload_chunk':
|
|
// Acknowledge received upload data
|
|
bytesTransferred = reqArg.payload?.length || 0;
|
|
break;
|
|
case 'latency':
|
|
// Simple ping - minimal data
|
|
bytesTransferred = 0;
|
|
break;
|
|
}
|
|
|
|
return { bytesTransferred, timestamp: Date.now(), payload };
|
|
})
|
|
);
|
|
} catch (error) {
|
|
console.error('Failed to initialize TypedSocket:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: {},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build CSP header string from configuration
|
|
*/
|
|
private buildCspHeader(csp: IContentSecurityPolicy): string {
|
|
const directives: string[] = [];
|
|
|
|
const addDirective = (name: string, value: string | string[] | undefined) => {
|
|
if (value) {
|
|
const sources = Array.isArray(value) ? value.join(' ') : value;
|
|
directives.push(`${name} ${sources}`);
|
|
}
|
|
};
|
|
|
|
addDirective('default-src', csp.defaultSrc);
|
|
addDirective('script-src', csp.scriptSrc);
|
|
addDirective('style-src', csp.styleSrc);
|
|
addDirective('img-src', csp.imgSrc);
|
|
addDirective('font-src', csp.fontSrc);
|
|
addDirective('connect-src', csp.connectSrc);
|
|
addDirective('media-src', csp.mediaSrc);
|
|
addDirective('frame-src', csp.frameSrc);
|
|
addDirective('object-src', csp.objectSrc);
|
|
addDirective('worker-src', csp.workerSrc);
|
|
addDirective('form-action', csp.formAction);
|
|
addDirective('frame-ancestors', csp.frameAncestors);
|
|
addDirective('base-uri', csp.baseUri);
|
|
|
|
if (csp.reportUri) {
|
|
directives.push(`report-uri ${csp.reportUri}`);
|
|
}
|
|
if (csp.reportTo) {
|
|
directives.push(`report-to ${csp.reportTo}`);
|
|
}
|
|
if (csp.upgradeInsecureRequests) {
|
|
directives.push('upgrade-insecure-requests');
|
|
}
|
|
if (csp.blockAllMixedContent) {
|
|
directives.push('block-all-mixed-content');
|
|
}
|
|
|
|
return directives.join('; ');
|
|
}
|
|
|
|
/**
|
|
* Apply all configured headers (CORS, security) to a response
|
|
*/
|
|
private applyResponseHeaders(response: Response): Response {
|
|
const headers = new Headers(response.headers);
|
|
|
|
// CORS headers
|
|
if (this.options.cors) {
|
|
headers.set('Access-Control-Allow-Origin', '*');
|
|
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
|
|
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
|
|
headers.set('Access-Control-Max-Age', '86400');
|
|
}
|
|
|
|
// Security headers
|
|
const security = this.options.securityHeaders;
|
|
if (security) {
|
|
// Content Security Policy
|
|
if (security.csp) {
|
|
const cspHeader = this.buildCspHeader(security.csp);
|
|
if (cspHeader) {
|
|
headers.set('Content-Security-Policy', cspHeader);
|
|
}
|
|
}
|
|
|
|
// X-Frame-Options
|
|
if (security.xFrameOptions) {
|
|
headers.set('X-Frame-Options', security.xFrameOptions);
|
|
}
|
|
|
|
// X-Content-Type-Options
|
|
if (security.xContentTypeOptions) {
|
|
headers.set('X-Content-Type-Options', 'nosniff');
|
|
}
|
|
|
|
// X-XSS-Protection
|
|
if (security.xXssProtection) {
|
|
const value = typeof security.xXssProtection === 'string'
|
|
? security.xXssProtection
|
|
: '1; mode=block';
|
|
headers.set('X-XSS-Protection', value);
|
|
}
|
|
|
|
// Referrer-Policy
|
|
if (security.referrerPolicy) {
|
|
headers.set('Referrer-Policy', security.referrerPolicy);
|
|
}
|
|
|
|
// Strict-Transport-Security (HSTS)
|
|
if (security.hstsMaxAge !== undefined) {
|
|
let hsts = `max-age=${security.hstsMaxAge}`;
|
|
if (security.hstsIncludeSubDomains) {
|
|
hsts += '; includeSubDomains';
|
|
}
|
|
if (security.hstsPreload) {
|
|
hsts += '; preload';
|
|
}
|
|
headers.set('Strict-Transport-Security', hsts);
|
|
}
|
|
|
|
// Permissions-Policy
|
|
if (security.permissionsPolicy) {
|
|
const policies = Object.entries(security.permissionsPolicy)
|
|
.map(([feature, allowlist]) => `${feature}=(${allowlist.join(' ')})`)
|
|
.join(', ');
|
|
if (policies) {
|
|
headers.set('Permissions-Policy', policies);
|
|
}
|
|
}
|
|
|
|
// Cross-Origin-Opener-Policy
|
|
if (security.crossOriginOpenerPolicy) {
|
|
headers.set('Cross-Origin-Opener-Policy', security.crossOriginOpenerPolicy);
|
|
}
|
|
|
|
// Cross-Origin-Embedder-Policy
|
|
if (security.crossOriginEmbedderPolicy) {
|
|
headers.set('Cross-Origin-Embedder-Policy', security.crossOriginEmbedderPolicy);
|
|
}
|
|
|
|
// Cross-Origin-Resource-Policy
|
|
if (security.crossOriginResourcePolicy) {
|
|
headers.set('Cross-Origin-Resource-Policy', security.crossOriginResourcePolicy);
|
|
}
|
|
}
|
|
|
|
return new Response(response.body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Main request handler - routes to appropriate sub-handlers
|
|
*/
|
|
private async handleRequest(request: Request): Promise<Response> {
|
|
const url = new URL(request.url);
|
|
const path = url.pathname;
|
|
const method = request.method.toUpperCase() as THttpMethod;
|
|
|
|
// Handle OPTIONS preflight for CORS
|
|
if (method === 'OPTIONS' && this.options.cors) {
|
|
return this.applyResponseHeaders(new Response(null, { status: 204 }));
|
|
}
|
|
|
|
// Process the request and wrap response with all configured headers
|
|
const response = await this.handleRequestInternal(request, url, path, method);
|
|
return this.applyResponseHeaders(response);
|
|
}
|
|
|
|
/**
|
|
* Internal request handler - routes to appropriate sub-handlers
|
|
*/
|
|
private async handleRequestInternal(
|
|
request: Request,
|
|
url: URL,
|
|
path: string,
|
|
method: THttpMethod
|
|
): Promise<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);
|
|
|
|
// 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' },
|
|
});
|
|
}
|
|
|
|
// SPA fallback - serve index.html for non-file routes
|
|
if (this.options.spaFallback && this.options.serveDir && method === 'GET' && !path.includes('.')) {
|
|
try {
|
|
const indexPath = plugins.path.join(this.options.serveDir, 'index.html');
|
|
let html = await plugins.fsInstance.file(indexPath).encoding('utf8').read() as string;
|
|
|
|
// Inject reload script if enabled
|
|
if (this.options.injectReload && html.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 -->
|
|
`;
|
|
html = html.replace('<head>', injection);
|
|
}
|
|
|
|
return new Response(html, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
appHash: this.serveHash,
|
|
},
|
|
});
|
|
} catch {
|
|
// Fall through to 404
|
|
}
|
|
}
|
|
|
|
// Not found
|
|
return new Response('Not Found', { status: 404 });
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// Push cache invalidation to service workers first
|
|
try {
|
|
const swConnections = await this.typedsocket.findAllTargetConnectionsByTag('serviceworker');
|
|
for (const connection of swConnections) {
|
|
const pushCacheInvalidate =
|
|
this.typedsocket.createTypedRequest<interfaces.serviceworker.IRequest_Serviceworker_CacheInvalidate>(
|
|
'serviceworker_cacheInvalidate',
|
|
connection
|
|
);
|
|
pushCacheInvalidate.fire({
|
|
reason: 'File change detected',
|
|
timestamp: this.lastReload,
|
|
}).catch(err => {
|
|
console.warn('Failed to push cache invalidation to service worker:', err);
|
|
});
|
|
}
|
|
if (swConnections.length > 0) {
|
|
console.log(`Pushed cache invalidation to ${swConnections.length} service worker(s)`);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to notify service workers:', error);
|
|
}
|
|
|
|
// Notify frontend clients
|
|
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(err => {
|
|
console.warn('Failed to push latest server change time to client:', err);
|
|
});
|
|
}
|
|
} 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);
|
|
}
|
|
}
|