diff --git a/changelog.md b/changelog.md index a5c17e0..ef788ef 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-01-23 - 8.2.0 - feat(typedserver) +serve bundled in-memory content with caching and reload injection + +- Add IBundledContentItem and IServerOptions.bundledContent to accept tsbundle output (path + contentBase64). +- Initialize in-memory bundled content map with MIME type detection, SHA-256-based ETag, size and combined app hash. +- Serve bundled files with proper Content-Type, ETag, Cache-Control, support HEAD and conditional 304 responses. +- Inject live-reload script into bundled HTML responses when injectReload is enabled; modify SPA fallback to prefer bundled index.html. +- Require serveDir or bundledContent when injectReload is enabled; prefer in-memory bundled content over filesystem requests. +- Add helper methods: initializeBundledContent, getMimeType, serveBundledContent and serveBundledHtmlWithInjection; log initialization. + ## 2025-12-22 - 8.1.0 - feat(types) export IRequestContext type from @push.rocks/smartserve for consumers to use in route handlers diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 5a15901..f3663cb 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@api.global/typedserver', - version: '8.1.0', + version: '8.2.0', description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.' } diff --git a/ts/classes.typedserver.ts b/ts/classes.typedserver.ts index 05e0ef4..06f0358 100644 --- a/ts/classes.typedserver.ts +++ b/ts/classes.typedserver.ts @@ -75,12 +75,26 @@ export interface ISecurityHeaders { crossOriginResourcePolicy?: 'same-site' | 'same-origin' | 'cross-origin'; } +/** + * Bundled content item - matches tsbundle output format + */ +export interface IBundledContentItem { + path: string; + contentBase64: string; +} + export interface IServerOptions { /** * serve a particular directory */ serveDir?: string; + /** + * Bundled content to serve from memory (higher priority than filesystem) + * Accepts array format from tsbundle: { path: string; contentBase64: string }[] + */ + bundledContent?: IBundledContentItem[]; + /** * inject a reload script that takes care of live reloading */ @@ -173,6 +187,15 @@ export class TypedServer { // File server for static files private fileServer: plugins.smartserve.FileServer; + // Bundled content map for O(1) lookup + private bundledContentMap: Map = new Map(); + private bundledContentHash: string = ''; + public lastReload: number = Date.now(); public ended = false; @@ -209,14 +232,139 @@ export class TypedServer { plugins.smartserve.ControllerRegistry.addRoute(path, method, handler); } + /** + * Initialize bundled content from base64-encoded files + */ + private async initializeBundledContent(): Promise { + if (!this.options.bundledContent?.length) return; + + const hashParts: string[] = []; + + for (const item of this.options.bundledContent) { + let path = item.path.replace(/^\/+/, '') || 'index.html'; + + // Decode base64 to Uint8Array + const binary = atob(item.contentBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + // Generate ETag from content hash + const hashBuffer = await crypto.subtle.digest('SHA-256', bytes); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const etag = '"' + hashArray.slice(0, 16).map(b => b.toString(16).padStart(2, '0')).join('') + '"'; + + // Get MIME type + const mimeType = this.getMimeType(path); + + this.bundledContentMap.set(path, { content: bytes, etag, mimeType, size: bytes.length }); + hashParts.push(etag); + } + + // Combined hash for cache busting + this.bundledContentHash = hashParts.join('').slice(0, 12); + if (!this.options.serveDir) this.serveHash = this.bundledContentHash; + + console.log(`Initialized ${this.bundledContentMap.size} bundled files (hash: ${this.bundledContentHash})`); + } + + /** + * Get MIME type for a file path + */ + private getMimeType(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() || ''; + const mimeTypes: Record = { + 'html': 'text/html; charset=utf-8', + 'js': 'application/javascript; charset=utf-8', + 'mjs': 'application/javascript; charset=utf-8', + 'css': 'text/css; charset=utf-8', + 'json': 'application/json; charset=utf-8', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'svg': 'image/svg+xml', + 'ico': 'image/x-icon', + 'woff': 'font/woff', + 'woff2': 'font/woff2', + 'ttf': 'font/ttf', + 'eot': 'application/vnd.ms-fontobject', + }; + return mimeTypes[ext] || 'application/octet-stream'; + } + + /** + * Serve bundled content from memory + */ + private async serveBundledContent(request: Request, pathname: string): Promise { + if (this.bundledContentMap.size === 0) return null; + + let path = pathname.replace(/^\/+/, ''); + if (!path || path.endsWith('/')) path = (path || '') + 'index.html'; + + const entry = this.bundledContentMap.get(path); + if (!entry) return null; + + const headers = new Headers({ + 'Content-Type': entry.mimeType, + 'ETag': entry.etag, + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + if (this.bundledContentHash) headers.set('appHash', this.bundledContentHash); + + // Conditional request + const ifNoneMatch = request.headers.get('If-None-Match'); + if (ifNoneMatch === entry.etag) { + return new Response(null, { status: 304, headers }); + } + + if (request.method === 'HEAD') { + headers.set('Content-Length', entry.size.toString()); + return new Response(null, { status: 200, headers }); + } + + // HTML injection for reload + if (this.options.injectReload && entry.mimeType.includes('text/html')) { + return this.serveBundledHtmlWithInjection(entry, headers); + } + + headers.set('Content-Length', entry.size.toString()); + return new Response(Buffer.from(entry.content), { status: 200, headers }); + } + + /** + * Serve bundled HTML with reload script injection + */ + private serveBundledHtmlWithInjection( + entry: { content: Uint8Array; mimeType: string }, + headers: Headers + ): Response { + let html = new TextDecoder().decode(entry.content); + + if (html.includes('')) { + html = html.replace('', ` + + + `); + } + + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.delete('ETag'); + + const content = new TextEncoder().encode(html); + headers.set('Content-Length', content.length.toString()); + return new Response(content, { status: 200, headers }); + } + /** * inits and starts the server */ public async start() { // Validate essential configuration before starting - if (this.options.injectReload && !this.options.serveDir) { + if (this.options.injectReload && !this.options.serveDir && !this.options.bundledContent) { throw new Error( - 'You set to inject the reload script without a serve dir. This is not supported at the moment.' + 'injectReload requires serveDir or bundledContent' ); } @@ -242,6 +390,9 @@ export class TypedServer { }); } + // Initialize bundled content from memory + await this.initializeBundledContent(); + // Initialize decorated controllers if (this.options.injectReload) { this.devToolsController = new DevToolsController({ @@ -627,6 +778,12 @@ export class TypedServer { } } + // Try bundled content first (in-memory, faster) + if (this.bundledContentMap.size > 0 && (method === 'GET' || method === 'HEAD')) { + const bundledResponse = await this.serveBundledContent(request, path); + if (bundledResponse) return bundledResponse; + } + // HTML injection for reload (if enabled) if (this.options.injectReload && this.options.serveDir) { const response = await this.handleHtmlWithInjection(request); @@ -655,14 +812,22 @@ export class TypedServer { } // 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; + if (this.options.spaFallback && method === 'GET' && !path.includes('.')) { + // Try bundled index.html first + if (this.bundledContentMap.has('index.html')) { + const response = await this.serveBundledContent(request, '/index.html'); + if (response) return response; + } - // Inject reload script if enabled - if (this.options.injectReload && html.includes('')) { - const injection = ` + // Fall back to filesystem + if (this.options.serveDir) { + 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('')) { + const injection = ` `; - html = html.replace('', injection); - } + html = html.replace('', 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 + 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 + } } }