feat(typedserver): serve bundled in-memory content with caching and reload injection

This commit is contained in:
2026-01-23 18:58:23 +00:00
parent 75b4570742
commit 9641a00174
3 changed files with 198 additions and 22 deletions

View File

@@ -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<string, {
content: Uint8Array;
etag: string;
mimeType: string;
size: number;
}> = 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<void> {
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<string, string> = {
'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<Response | null> {
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('<head>')) {
html = html.replace('<head>', `<head>
<!-- injected by @apiglobal/typedserver -->
<script async defer type="module" src="/typedserver/devtools"></script>
<script>globalThis.typedserver = { lastReload: ${this.lastReload} }</script>`);
}
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('<head>')) {
const injection = `<head>
// 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('<head>')) {
const injection = `<head>
<!-- injected by @apiglobal/typedserver start -->
<script async defer type="module" src="/typedserver/devtools"></script>
<script>
@@ -673,19 +838,20 @@ export class TypedServer {
</script>
<!-- injected by @apiglobal/typedserver stop -->
`;
html = html.replace('<head>', injection);
}
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
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
}
}
}