897 lines
28 KiB
TypeScript
897 lines
28 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;
|
|
|
|
/**
|
|
* Response compression configuration
|
|
* Set to true for defaults (brotli + gzip), false to disable, or provide detailed config
|
|
*/
|
|
compression?: plugins.smartserve.ICompressionConfig | boolean;
|
|
}
|
|
|
|
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'ALL';
|
|
|
|
export interface IRouteHandler {
|
|
(request: Request): Promise<Response | null>;
|
|
}
|
|
|
|
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;
|
|
|
|
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 {
|
|
// Delegate to smartserve's ControllerRegistry
|
|
plugins.smartserve.ControllerRegistry.addRoute(path, method, async (ctx: plugins.smartserve.IRequestContext) => {
|
|
// Convert context to Request for backwards compatibility
|
|
const request = new Request(ctx.url.toString(), {
|
|
method: ctx.method,
|
|
headers: ctx.headers,
|
|
});
|
|
(request as any).params = ctx.params;
|
|
return handler(request);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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',
|
|
compression: this.options.compression,
|
|
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 });
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|