430 lines
13 KiB
TypeScript
430 lines
13 KiB
TypeScript
import path from "node:path";
|
|
|
|
// Configuration
|
|
const SERVER_PORT = Number(process.env.PORT ?? 3000);
|
|
const CLIENT_DIRECTORY = "./dist/client";
|
|
const SERVER_ENTRY_POINT = "./dist/server/server.js";
|
|
|
|
// Logging utilities for professional output
|
|
const log = {
|
|
info: (message: string) => {
|
|
console.log(`[INFO] ${message}`);
|
|
},
|
|
success: (message: string) => {
|
|
console.log(`[SUCCESS] ${message}`);
|
|
},
|
|
warning: (message: string) => {
|
|
console.log(`[WARNING] ${message}`);
|
|
},
|
|
error: (message: string) => {
|
|
console.log(`[ERROR] ${message}`);
|
|
},
|
|
header: (message: string) => {
|
|
console.log(`\n${message}\n`);
|
|
},
|
|
};
|
|
|
|
// Preloading configuration from environment variables
|
|
const MAX_PRELOAD_BYTES = Number(
|
|
process.env.ASSET_PRELOAD_MAX_SIZE ?? 5 * 1024 * 1024, // 5MB default
|
|
);
|
|
|
|
// Parse comma-separated include patterns (no defaults)
|
|
const INCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? "")
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
.map((pattern: string) => convertGlobToRegExp(pattern));
|
|
|
|
// Parse comma-separated exclude patterns (no defaults)
|
|
const EXCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? "")
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
.map((pattern: string) => convertGlobToRegExp(pattern));
|
|
|
|
// Verbose logging flag
|
|
const VERBOSE = process.env.ASSET_PRELOAD_VERBOSE_LOGGING === "true";
|
|
|
|
// Optional ETag feature
|
|
const ENABLE_ETAG = (process.env.ASSET_PRELOAD_ENABLE_ETAG ?? "true") === "true";
|
|
|
|
// Optional Gzip feature
|
|
const ENABLE_GZIP = (process.env.ASSET_PRELOAD_ENABLE_GZIP ?? "true") === "true";
|
|
const GZIP_MIN_BYTES = Number(process.env.ASSET_PRELOAD_GZIP_MIN_SIZE ?? 1024); // 1KB
|
|
const GZIP_TYPES = (
|
|
process.env.ASSET_PRELOAD_GZIP_MIME_TYPES ??
|
|
"text/,application/javascript,application/json,application/xml,image/svg+xml"
|
|
)
|
|
.split(",")
|
|
.map((v) => v.trim())
|
|
.filter(Boolean);
|
|
|
|
/**
|
|
* Convert a simple glob pattern to a regular expression
|
|
* Supports * wildcard for matching any characters
|
|
*/
|
|
function convertGlobToRegExp(globPattern: string): RegExp {
|
|
// Escape regex special chars except *, then replace * with .*
|
|
const escapedPattern = globPattern.replace(/[-/\\^$+?.()|[\]{}]/g, "\\$&").replace(/\*/g, ".*");
|
|
return new RegExp(`^${escapedPattern}$`, "i");
|
|
}
|
|
|
|
/**
|
|
* Compute ETag for a given data buffer
|
|
*/
|
|
function computeEtag(data: Uint8Array): string {
|
|
const hash = Bun.hash(data);
|
|
return `W/"${hash.toString(16)}-${data.byteLength.toString()}"`;
|
|
}
|
|
|
|
/**
|
|
* Metadata for preloaded static assets
|
|
*/
|
|
interface AssetMetadata {
|
|
route: string;
|
|
size: number;
|
|
type: string;
|
|
}
|
|
|
|
/**
|
|
* In-memory asset with ETag and Gzip support
|
|
*/
|
|
interface InMemoryAsset {
|
|
raw: Uint8Array;
|
|
gz?: Uint8Array;
|
|
etag?: string;
|
|
type: string;
|
|
immutable: boolean;
|
|
size: number;
|
|
}
|
|
|
|
/**
|
|
* Result of static asset preloading process
|
|
*/
|
|
interface PreloadResult {
|
|
routes: Record<string, (req: Request) => Response | Promise<Response>>;
|
|
loaded: AssetMetadata[];
|
|
skipped: AssetMetadata[];
|
|
}
|
|
|
|
/**
|
|
* Check if a file is eligible for preloading based on configured patterns
|
|
*/
|
|
function isFileEligibleForPreloading(relativePath: string): boolean {
|
|
return false;
|
|
const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath;
|
|
|
|
// If include patterns are specified, file must match at least one
|
|
if (INCLUDE_PATTERNS.length > 0) {
|
|
if (!INCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// If exclude patterns are specified, file must not match any
|
|
if (EXCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check if a MIME type is compressible
|
|
*/
|
|
function isMimeTypeCompressible(mimeType: string): boolean {
|
|
return GZIP_TYPES.some((type) => (type.endsWith("/") ? mimeType.startsWith(type) : mimeType === type));
|
|
}
|
|
|
|
/**
|
|
* Conditionally compress data based on size and MIME type
|
|
*/
|
|
function compressDataIfAppropriate(data: Uint8Array, mimeType: string): Uint8Array | undefined {
|
|
if (!ENABLE_GZIP) return undefined;
|
|
if (data.byteLength < GZIP_MIN_BYTES) return undefined;
|
|
if (!isMimeTypeCompressible(mimeType)) return undefined;
|
|
try {
|
|
return Bun.gzipSync(data.buffer as ArrayBuffer);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create response handler function with ETag and Gzip support
|
|
*/
|
|
function createResponseHandler(asset: InMemoryAsset): (req: Request) => Response {
|
|
return (req: Request) => {
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": asset.type,
|
|
"Cache-Control": asset.immutable ? "public, max-age=31536000, immutable" : "public, max-age=3600",
|
|
};
|
|
|
|
if (ENABLE_ETAG && asset.etag) {
|
|
const ifNone = req.headers.get("if-none-match");
|
|
if (ifNone && ifNone === asset.etag) {
|
|
return new Response(null, {
|
|
status: 304,
|
|
headers: { ETag: asset.etag },
|
|
});
|
|
}
|
|
headers.ETag = asset.etag;
|
|
}
|
|
|
|
if (ENABLE_GZIP && asset.gz && req.headers.get("accept-encoding")?.includes("gzip")) {
|
|
headers["Content-Encoding"] = "gzip";
|
|
headers["Content-Length"] = String(asset.gz.byteLength);
|
|
const gzCopy = new Uint8Array(asset.gz);
|
|
return new Response(gzCopy, { status: 200, headers });
|
|
}
|
|
|
|
headers["Content-Length"] = String(asset.raw.byteLength);
|
|
const rawCopy = new Uint8Array(asset.raw);
|
|
return new Response(rawCopy, { status: 200, headers });
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create composite glob pattern from include patterns
|
|
*/
|
|
function createCompositeGlobPattern(): Bun.Glob {
|
|
const raw = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? "")
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
if (raw.length === 0) return new Bun.Glob("**/*");
|
|
if (raw.length === 1) return new Bun.Glob(raw[0]);
|
|
return new Bun.Glob(`{${raw.join(",")}}`);
|
|
}
|
|
|
|
/**
|
|
* Initialize static routes with intelligent preloading strategy
|
|
* Small files are loaded into memory, large files are served on-demand
|
|
*/
|
|
async function initializeStaticRoutes(clientDirectory: string): Promise<PreloadResult> {
|
|
const routes: Record<string, (req: Request) => Response | Promise<Response>> = {};
|
|
const loaded: AssetMetadata[] = [];
|
|
const skipped: AssetMetadata[] = [];
|
|
|
|
log.info(`Loading static assets from ${clientDirectory}...`);
|
|
|
|
let totalPreloadedBytes = 0;
|
|
|
|
try {
|
|
const glob = createCompositeGlobPattern();
|
|
for await (const relativePath of glob.scan({ cwd: clientDirectory })) {
|
|
const filepath = path.join(clientDirectory, relativePath);
|
|
const route = `/${relativePath.split(path.sep).join(path.posix.sep)}`;
|
|
|
|
try {
|
|
// Get file metadata
|
|
const file = Bun.file(filepath);
|
|
|
|
// Skip if file doesn't exist or is empty
|
|
if (!(await file.exists()) || file.size === 0) {
|
|
continue;
|
|
}
|
|
|
|
const metadata: AssetMetadata = {
|
|
route,
|
|
size: file.size,
|
|
type: file.type || "application/octet-stream",
|
|
};
|
|
|
|
// Determine if file should be preloaded
|
|
const matchesPattern = isFileEligibleForPreloading(relativePath);
|
|
const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES;
|
|
|
|
if (matchesPattern && withinSizeLimit) {
|
|
// Preload small files into memory with ETag and Gzip support
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const gz = compressDataIfAppropriate(bytes, metadata.type);
|
|
const etag = ENABLE_ETAG ? computeEtag(bytes) : undefined;
|
|
const asset: InMemoryAsset = {
|
|
raw: bytes,
|
|
gz,
|
|
etag,
|
|
type: metadata.type,
|
|
immutable: true,
|
|
size: bytes.byteLength,
|
|
};
|
|
routes[route] = createResponseHandler(asset);
|
|
|
|
loaded.push({ ...metadata, size: bytes.byteLength });
|
|
totalPreloadedBytes += bytes.byteLength;
|
|
} else {
|
|
// Serve large or filtered files on-demand
|
|
routes[route] = () => {
|
|
const fileOnDemand = Bun.file(filepath);
|
|
return new Response(fileOnDemand, {
|
|
headers: {
|
|
"Content-Type": metadata.type,
|
|
"Cache-Control": "public, max-age=3600",
|
|
},
|
|
});
|
|
};
|
|
|
|
skipped.push(metadata);
|
|
}
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error && error.name !== "EISDIR") {
|
|
log.error(`Failed to load ${filepath}: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show detailed file overview only when verbose mode is enabled
|
|
if (VERBOSE && (loaded.length > 0 || skipped.length > 0)) {
|
|
const allFiles = [...loaded, ...skipped].sort((a, b) => a.route.localeCompare(b.route));
|
|
|
|
// Calculate max path length for alignment
|
|
const maxPathLength = Math.min(Math.max(...allFiles.map((f) => f.route.length)), 60);
|
|
|
|
// Format file size with KB and actual gzip size
|
|
const formatFileSize = (bytes: number, gzBytes?: number) => {
|
|
const kb = bytes / 1024;
|
|
const sizeStr = kb < 100 ? kb.toFixed(2) : kb.toFixed(1);
|
|
|
|
if (gzBytes !== undefined) {
|
|
const gzKb = gzBytes / 1024;
|
|
const gzStr = gzKb < 100 ? gzKb.toFixed(2) : gzKb.toFixed(1);
|
|
return {
|
|
size: sizeStr,
|
|
gzip: gzStr,
|
|
};
|
|
}
|
|
|
|
// Rough gzip estimation (typically 30-70% compression) if no actual gzip data
|
|
const gzipKb = kb * 0.35;
|
|
return {
|
|
size: sizeStr,
|
|
gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1),
|
|
};
|
|
};
|
|
|
|
if (loaded.length > 0) {
|
|
console.log("\n📁 Preloaded into memory:");
|
|
console.log("Path │ Size │ Gzip Size");
|
|
loaded
|
|
.sort((a, b) => a.route.localeCompare(b.route))
|
|
.forEach((file) => {
|
|
const { size, gzip } = formatFileSize(file.size);
|
|
const paddedPath = file.route.padEnd(maxPathLength);
|
|
const sizeStr = `${size.padStart(7)} kB`;
|
|
const gzipStr = `${gzip.padStart(7)} kB`;
|
|
console.log(`${paddedPath} │ ${sizeStr} │ ${gzipStr}`);
|
|
});
|
|
}
|
|
|
|
if (skipped.length > 0) {
|
|
console.log("\n💾 Served on-demand:");
|
|
console.log("Path │ Size │ Gzip Size");
|
|
skipped
|
|
.sort((a, b) => a.route.localeCompare(b.route))
|
|
.forEach((file) => {
|
|
const { size, gzip } = formatFileSize(file.size);
|
|
const paddedPath = file.route.padEnd(maxPathLength);
|
|
const sizeStr = `${size.padStart(7)} kB`;
|
|
const gzipStr = `${gzip.padStart(7)} kB`;
|
|
console.log(`${paddedPath} │ ${sizeStr} │ ${gzipStr}`);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Show detailed verbose info if enabled
|
|
if (VERBOSE) {
|
|
if (loaded.length > 0 || skipped.length > 0) {
|
|
const allFiles = [...loaded, ...skipped].sort((a, b) => a.route.localeCompare(b.route));
|
|
console.log("\n📊 Detailed file information:");
|
|
console.log("Status │ Path │ MIME Type │ Reason");
|
|
allFiles.forEach((file) => {
|
|
const isPreloaded = loaded.includes(file);
|
|
const status = isPreloaded ? "MEMORY" : "ON-DEMAND";
|
|
const reason =
|
|
!isPreloaded && file.size > MAX_PRELOAD_BYTES ? "too large" : !isPreloaded ? "filtered" : "preloaded";
|
|
const route = file.route.length > 30 ? file.route.substring(0, 27) + "..." : file.route;
|
|
console.log(`${status.padEnd(12)} │ ${route.padEnd(30)} │ ${file.type.padEnd(28)} │ ${reason.padEnd(10)}`);
|
|
});
|
|
} else {
|
|
console.log("\n📊 No files found to display");
|
|
}
|
|
}
|
|
|
|
// Log summary after the file list
|
|
console.log(); // Empty line for separation
|
|
if (loaded.length > 0) {
|
|
log.success(
|
|
`Preloaded ${String(loaded.length)} files (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) into memory`,
|
|
);
|
|
} else {
|
|
log.info("No files preloaded into memory");
|
|
}
|
|
|
|
if (skipped.length > 0) {
|
|
const tooLarge = skipped.filter((f) => f.size > MAX_PRELOAD_BYTES).length;
|
|
const filtered = skipped.length - tooLarge;
|
|
log.info(
|
|
`${String(skipped.length)} files will be served on-demand (${String(tooLarge)} too large, ${String(filtered)} filtered)`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
log.error(`Failed to load static files from ${clientDirectory}: ${String(error)}`);
|
|
}
|
|
|
|
return { routes, loaded, skipped };
|
|
}
|
|
|
|
async function initializeServer() {
|
|
log.header("Starting Production Server");
|
|
|
|
// Load TanStack Start server handler
|
|
let handler: { fetch: (request: Request) => Response | Promise<Response> };
|
|
try {
|
|
const serverModule = (await import(SERVER_ENTRY_POINT)) as {
|
|
default: { fetch: (request: Request) => Response | Promise<Response> };
|
|
};
|
|
handler = serverModule.default;
|
|
log.success("TanStack Start application handler initialized");
|
|
} catch (error) {
|
|
log.error(`Failed to load server handler: ${String(error)}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Build static routes with intelligent preloading
|
|
const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY);
|
|
|
|
// Create Bun server
|
|
const server = Bun.serve({
|
|
port: SERVER_PORT,
|
|
|
|
routes: {
|
|
// Serve static assets (preloaded or on-demand)
|
|
...routes,
|
|
|
|
// Fallback to TanStack Start handler for all other routes
|
|
"/*": (req: Request) => {
|
|
try {
|
|
return handler.fetch(req);
|
|
} catch (error) {
|
|
log.error(`Server handler error: ${String(error)}`);
|
|
return new Response("Internal Server Error", { status: 500 });
|
|
}
|
|
},
|
|
},
|
|
|
|
// Global error handler
|
|
error(error) {
|
|
log.error(`Uncaught server error: ${error instanceof Error ? error.message : String(error)}`);
|
|
return new Response("Internal Server Error", { status: 500 });
|
|
},
|
|
});
|
|
|
|
log.success(`Server listening on http://localhost:${String(server.port)}`);
|
|
}
|
|
|
|
// Initialize the server
|
|
initializeServer().catch((error: unknown) => {
|
|
log.error(`Failed to start server: ${String(error)}`);
|
|
process.exit(1);
|
|
});
|