Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e80619bac6 | |||
| e290744451 | |||
| c5c45f668f | |||
| aa748e0d82 | |||
| 14c8d83ab5 | |||
| d66b7648a8 | |||
| 657bdfb403 | |||
| e1b2a13395 | |||
| c753206456 | |||
| 4cc1efb7cc | |||
| 9641a00174 |
37
changelog.md
37
changelog.md
@@ -1,5 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-03 - 8.4.1 - fix(statuspill)
|
||||
wait for document.body before appending status pill when script loads before <body> is parsed; defer via DOMContentLoaded or requestAnimationFrame
|
||||
|
||||
- Guard against missing document.body to avoid errors when the script runs before the <body> is parsed
|
||||
- Retry showing on DOMContentLoaded if the document is still loading
|
||||
- Fallback to requestAnimationFrame to schedule the show on the next frame if DOM is already parsed
|
||||
|
||||
## 2026-02-24 - 8.4.0 - feat(utilityservers)
|
||||
add injectReload and noCache options and enable dev features by default
|
||||
|
||||
- Adds optional configuration properties 'injectReload' and 'noCache' to the website server options interface.
|
||||
- Dev features (injectReload and noCache) are no longer only enabled when serveDir is set; they now default to true when not explicitly provided.
|
||||
- This changes default runtime behavior: live-reload injection and disabled browser caching may be enabled for servers that previously did not have them — consumers should set options explicitly to preserve previous behavior.
|
||||
|
||||
## 2026-02-24 - 8.3.1 - fix(typedserver)
|
||||
no changes detected — no version bump needed
|
||||
|
||||
- No files changed in the diff
|
||||
- Current package version: 8.3.0
|
||||
|
||||
## 2026-01-23 - 8.3.0 - feat(typedserver)
|
||||
add noCache option to disable client-side caching and set no-cache headers on responses
|
||||
|
||||
- Introduces an optional noCache?: boolean in the server options interface
|
||||
- applyResponseHeaders now sets Cache-Control, Pragma, and Expires headers when noCache is true
|
||||
- Existing CORS and security header behavior unchanged when noCache is not set or false
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@api.global/typedserver",
|
||||
"version": "8.1.0",
|
||||
"version": "8.4.1",
|
||||
"description": "A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@api.global/typedserver',
|
||||
version: '8.1.0',
|
||||
version: '8.4.1',
|
||||
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -143,6 +157,12 @@ export interface IServerOptions {
|
||||
* Set to true for defaults (brotli + gzip), false to disable, or provide detailed config
|
||||
*/
|
||||
compression?: plugins.smartserve.ICompressionConfig | boolean;
|
||||
|
||||
/**
|
||||
* Disable all client-side caching by setting appropriate headers
|
||||
* Useful for development or when content changes frequently
|
||||
*/
|
||||
noCache?: boolean;
|
||||
}
|
||||
|
||||
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'ALL';
|
||||
@@ -173,6 +193,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 +238,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 +396,9 @@ export class TypedServer {
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize bundled content from memory
|
||||
await this.initializeBundledContent();
|
||||
|
||||
// Initialize decorated controllers
|
||||
if (this.options.injectReload) {
|
||||
this.devToolsController = new DevToolsController({
|
||||
@@ -484,11 +641,18 @@ export class TypedServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all configured headers (CORS, security) to a response
|
||||
* Apply all configured headers (CORS, security, cache control) to a response
|
||||
*/
|
||||
private applyResponseHeaders(response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
|
||||
// No-cache headers
|
||||
if (this.options.noCache) {
|
||||
headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0');
|
||||
headers.set('Pragma', 'no-cache');
|
||||
headers.set('Expires', '0');
|
||||
}
|
||||
|
||||
// CORS headers
|
||||
if (this.options.cors) {
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
@@ -627,6 +791,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 +825,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 +851,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { type IServerOptions, type ISecurityHeaders, TypedServer } from '../classes.typedserver.js';
|
||||
import { type IServerOptions, type ISecurityHeaders, type IBundledContentItem, TypedServer } from '../classes.typedserver.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export interface IUtilityWebsiteServerConstructorOptions {
|
||||
@@ -10,7 +10,9 @@ export interface IUtilityWebsiteServerConstructorOptions {
|
||||
/** Domain name for the website */
|
||||
domain: string;
|
||||
/** Directory to serve static files from */
|
||||
serveDir: string;
|
||||
serveDir?: string;
|
||||
/** Bundled content to serve from memory (base64-encoded files from tsbundle) */
|
||||
bundledContent?: IBundledContentItem[];
|
||||
/** RSS feed metadata */
|
||||
feedMetadata?: IServerOptions['feedMetadata'];
|
||||
/** Enable/disable CORS (default: true) */
|
||||
@@ -27,6 +29,10 @@ export interface IUtilityWebsiteServerConstructorOptions {
|
||||
adsTxt?: string[];
|
||||
/** Response compression configuration (default: enabled with brotli + gzip) */
|
||||
compression?: plugins.smartserve.ICompressionConfig | boolean;
|
||||
/** Disable browser caching (default: true when serveDir is set) */
|
||||
noCache?: boolean;
|
||||
/** Inject live-reload devtools script into HTML (default: true when serveDir is set) */
|
||||
injectReload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,12 +61,14 @@ export class UtilityWebsiteServer {
|
||||
// Core settings
|
||||
cors: this.options.cors ?? true,
|
||||
serveDir: this.options.serveDir,
|
||||
bundledContent: this.options.bundledContent,
|
||||
domain: this.options.domain,
|
||||
port,
|
||||
|
||||
// Development features
|
||||
injectReload: true,
|
||||
watch: true,
|
||||
injectReload: this.options.injectReload ?? true,
|
||||
watch: !!this.options.serveDir,
|
||||
noCache: this.options.noCache ?? true,
|
||||
|
||||
// SPA support (enabled by default for modern web apps)
|
||||
spaFallback: this.options.spaFallback ?? true,
|
||||
|
||||
@@ -311,6 +311,15 @@ export class TypedserverStatusPill extends LitElement {
|
||||
*/
|
||||
public show(): void {
|
||||
if (!this.appended) {
|
||||
if (!document.body) {
|
||||
// Script loaded before <body> was parsed (async module) — wait for DOM
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => this.show(), { once: true });
|
||||
} else {
|
||||
requestAnimationFrame(() => this.show());
|
||||
}
|
||||
return;
|
||||
}
|
||||
document.body.appendChild(this);
|
||||
this.appended = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user