import { FastifyReply, FastifyRequest } from 'fastify'; import fs from 'fs'; /** * Parsed domain information derived from a request hostname. * * Used both on the Fastify request object (`request.domainInfo`) and in the * Unirend React context (`useDomainInfo()`). */ interface DomainInfo { /** Bare hostname with port stripped (IPv6-safe, e.g. `'app.example.com'` or `'::1'`). */ hostname: string; /** * Apex domain without a leading dot (e.g. `'example.com'`). * Empty string for localhost and raw IP addresses where no root domain can be resolved. * * When empty, omit the `domain` attribute entirely — `domain=.localhost` is invalid * per RFC 6265 and browsers reject it. A cookie without a `domain` attribute becomes * a host-only cookie scoped to the exact hostname, which is correct for localhost and IPs. * * Prepend `.` when using as a cookie `domain` attribute to span subdomains: * ```ts * document.cookie = [ * 'theme=dark', * 'path=/', * 'max-age=31536000', * domainInfo.rootDomain ? `domain=.${domainInfo.rootDomain}` : null, * ].filter(Boolean).join('; '); * ``` */ rootDomain: string; } /** * Configuration for a folder in the static router folderMap */ interface FolderConfig { /** Path to the directory */ path: string; /** Whether to detect and use immutable caching for fingerprinted assets */ detectImmutableAssets?: boolean; } /** * Options for the static router middleware * Used to serve static files in production SSR mode */ interface StaticContentRouterOptions { /** Exact URL → absolute file path (optional) */ singleAssetMap?: Record; /** URL prefix → absolute directory path (as string) or folder config object */ folderMap?: Record; /** Response compression for buffered static responses; default true */ compression?: boolean | ResponseCompressionOptions; /** Maximum size (in bytes) for hashing & in-memory caching; default 5 MB */ smallFileMaxSize?: number; /** Maximum number of entries in ETag/content caches; default 100 */ cacheEntries?: number; /** Maximum total memory size (in bytes) for content cache; default 50 MB */ contentCacheMaxSize?: number; /** Maximum number of entries in the stat cache; default 250 */ statCacheEntries?: number; /** TTL in milliseconds for negative stat cache entries; default 30 seconds */ negativeCacheTtl?: number; /** TTL in milliseconds for positive stat cache entries; default 1 hour */ positiveCacheTtl?: number; /** Custom Cache-Control header; default 'public, max-age=0, must-revalidate' */ cacheControl?: string; /** Cache-Control header for immutable fingerprinted assets; default 'public, max-age=31536000, immutable' */ immutableCacheControl?: string; } interface ResponseCompressionOptions { /** Whether compression is enabled when using the object form */ enabled?: boolean; /** Minimum response size in bytes before compression is attempted */ threshold?: number; /** Prefer Brotli over gzip when the client gives both equal q-values */ preferBrotli?: boolean; /** Brotli quality level passed to Node.js zlib */ brotliQuality?: number; /** gzip compression level passed to Node.js zlib */ gzipLevel?: number; } declare module 'fastify' { interface FastifyRequest { /** * Active SSR app key for multi-app routing. * * Read-only request value. Defaults to `'__default__'`. * Use `request.setActiveSSRApp(appKey)` in SSR middleware to select a * registered app and refresh app-derived request values. */ readonly activeSSRApp: string; /** * Select the active SSR app for this request. * * Validates that the app exists, updates `request.activeSSRApp`, refreshes * `request.publicAppConfig`, and updates the app-level CDN default unless * middleware already overrode `request.CDNBaseURL`. */ setActiveSSRApp: (appKey: string) => void; /** * Resolved client IP address. * * Set once per request by the framework using `getClientIP` (if provided) * or falling back to `request.ip` (which reflects Fastify proxy handling * when `fastifyOptions.trustProxy` is configured). * * Available throughout the entire request lifecycle, including plugins, * hooks, page data loader handlers, API route handlers, and access log * templates/hooks. */ clientIP: string; /** * Server label for this instance (e.g. `'SSR'`, `'API'`). * * Set once per request by the framework from the `serverLabel` server option. * Available in access log templates as `{{serverLabel}}` and throughout * the request lifecycle via `request.serverLabel`. */ serverLabel: string; /** * Safe-to-share app configuration cloned and frozen for this request. * * Available to plugins, handlers, and helpers on SSR and API servers. * SSR also exposes it to React and injects it into HTML. API servers only * send it to clients if your response includes selected values. */ publicAppConfig?: Record; /** * Effective CDN base URL for this SSR request. * * SSR middleware can set this as a per-request override. If it remains * unset, the SSR server populates it from the active app's `CDNBaseURL` * before preHandler hooks, route handlers, SSR render, and custom 500 * pages run. * * API servers do not set this field. */ CDNBaseURL?: string; /** * Optional request-scoped helper installed by the built-in CORS plugin. * * Raw/hijacked response paths can call this before `writeHead(...)` to * apply the same actual-response CORS/security headers that normal * Fastify-managed responses receive. */ applyCORSHeaders?: (reply: FastifyReply) => void | Promise; /** * Internal request-start timestamp captured by the framework. * * Used by framework features that need a stable "request received" time, * including API/page envelope timestamps and fallback response-time * calculation when Fastify's built-in elapsedTime is unavailable. */ receivedAt?: number; /** * Per-request mutable key-value store initialized by both SSRServer and APIServer * before any plugins or hooks run. * * Use this to pass data between middleware, hooks, and handlers — for example, * seeding user session info, theme preferences, or CSRF tokens from an onRequest * hook so that page data loader handlers and API handlers can read them. * * During SSR, the server injects the final context into the rendered HTML so * client-side React can hydrate with the same values via `useRequestContext()`. * * In separated SSR/API deployments the SSR layer forwards this context to trusted * API page data requests and merges returned context back automatically. */ requestContext: Record; /** * Parsed domain information for this request, computed once per request from * `request.hostname` by both SSRServer and APIServer. * * - `hostname`: bare hostname with port stripped (IPv6-safe) * - `rootDomain`: apex domain without a leading dot (e.g. `'example.com'`); * empty string for localhost and raw IP addresses * * Use `rootDomain` to set subdomain-spanning cookies by prepending a dot: * `domain=.${request.domainInfo.rootDomain}` — the same value that the * client-side `cycleTheme()` helper uses. */ domainInfo: DomainInfo; /** * Set to `true` when the request is handled by the built-in static content * handler (fingerprinted assets, public files, etc.), regardless of which * server type is serving the file. * * Initialized to `false` for every request. The static content handler sets * it to `true` before calling `reply.hijack()`, so it is observable in * `onResponse` hooks and access log templates even though `onSend` is bypassed. * * Use this to skip work that is inappropriate for static asset responses — * for example, cookie renewal should only happen on document/API responses, * not on every `.js` or `.css` file request. */ isStaticAsset: boolean; } } /** * Minimal stat info interface with only the properties we actually use */ interface MinimalStatInfo { isFile: boolean; size: number; mtime: Date; mtimeMs: number; } /** * Options for getFile() method */ interface GetFileOptions { /** Whether to detect immutable assets for cache control decisions */ shouldDetectImmutable?: boolean; /** Optional ETag from client's If-None-Match header (for 304 optimization) */ clientETag?: string; /** Accepted content encodings from the request for representation selection */ acceptEncoding?: string | string[]; } /** * Options for creating a read stream (for range requests) */ interface CreateStreamOptions { /** Start byte position (inclusive) */ start?: number; /** End byte position (inclusive) */ end?: number; } /** * Result from serveFile() indicating what action was taken */ type ServeFileResult = { served: false; reason: 'not-found'; } | { served: false; reason: 'error'; error: Error; } | { served: true; statusCode: 200 | 206 | 304 | 400 | 416; }; /** * File content discriminated union - either buffered in memory or needs streaming */ type FileContent = { /** Content is buffered in memory (small files) */ shouldStream: false; /** The file content buffer */ data: Buffer; } | { /** Content needs to be streamed from disk (large files) */ shouldStream: true; /** Factory function to create a read stream with optional range support */ createStream: (options?: CreateStreamOptions) => fs.ReadStream; }; /** * Internal logger object used by static content helpers. */ type StaticContentWarnLoggerObject = { warn: (obj: object, msg: string) => void; }; /** * Result when file is not found (404) */ interface FileNotFoundResult { status: 'not-found'; } /** * Result when an unexpected error occurs (500) */ interface FileErrorResult { status: 'error'; error: Error; } /** * Result when client's ETag matches (304 Not Modified) */ interface FileNotModifiedResult { status: 'not-modified'; /** Generated ETag for the selected response representation */ etag: string; /** Last-Modified date as HTTP header string */ lastModified: string; /** Selected content encoding, if a compressed representation was chosen */ contentEncoding?: 'br' | 'gzip'; /** Whether the response should include `Vary: Accept-Encoding` */ varyByAcceptEncoding: boolean; } /** * Result when file is found and should be served (200) */ interface FileFoundResult { status: 'ok'; /** File stats (size, modification time, etc.) */ stat: MinimalStatInfo; /** Generated ETag for the selected response representation */ etag: string; /** Base file ETag before representation-specific encoding suffixes */ baseETag: string; /** Last-Modified date as HTTP header string */ lastModified: string; /** MIME type based on file extension */ mimeType: string; /** File content - either buffered or needs streaming */ content: FileContent; /** Selected content encoding, if a compressed representation was chosen */ contentEncoding?: 'br' | 'gzip'; /** Whether the response should include `Vary: Accept-Encoding` */ varyByAcceptEncoding: boolean; /** Whether this file appears to be fingerprinted/immutable (for aggressive caching) */ isImmutableAsset: boolean; } /** * Union type for all possible getFile() results */ type FileResult = FileNotFoundResult | FileErrorResult | FileNotModifiedResult | FileFoundResult; /** * Encapsulates caching and serving of static content files. * * This class manages: * - Multiple LRU caches (ETag, file content, and file stats) * - Configuration for single asset and folder mappings * - Optimized file serving with HTTP caching headers * - Content-based ETags for small files, weak ETags for large files * - Automatic detection of immutable assets (fingerprinted files) * * Each instance maintains its own independent caches, allowing * multiple instances with different configurations. */ declare class StaticContentCache { private singleAssetMap; private folderMap; private readonly smallFileMaxSize; private readonly cacheControl; private readonly immutableCacheControl; private readonly negativeCacheTtl; private readonly positiveCacheTtl; private readonly compression; private readonly etagCache; private readonly contentCache; private readonly compressedVariantCache; private readonly statCache; private readonly compressedContentIndex; private readonly logger?; /** * Creates a new StaticContentCache instance * * @param options Static content configuration (file mappings, cache settings, etc.) * @param logger Optional logger (e.g., fastify.log) for error logging */ constructor(options: StaticContentRouterOptions, logger?: StaticContentWarnLoggerObject); /** * Gets file metadata and content with optimized caching * * This method handles all the core file operations and caching: * - File stats caching to avoid repeated filesystem operations * - ETag generation and caching (content-based for small files, weak for large files) * - Small file content caching in memory for performance * - Proper MIME type detection * - Immutable asset detection for cache control decisions * - Optional short-circuit if client ETag matches (for 304 responses) * * Useful for both HTTP serving (via serveFile) and programmatic access * * @param resolvedPath The absolute path to the file * @param options Optional configuration for file retrieval * @returns Result with status: 'not-found', 'error', 'not-modified', or 'ok' */ getFile(resolvedPath: string, options?: GetFileOptions): Promise; /** * Serves a static file via HTTP with conditional responses * * This is a thin HTTP wrapper around getFile() that handles: * - HTTP 304 Not Modified responses when client cache is valid (If-None-Match) * - HTTP 206 Partial Content responses for range requests * - Proper HTTP headers (Cache-Control, ETag, Content-Type, Last-Modified, etc.) * - Streaming large files vs sending cached buffers for small files * * The heavy lifting (file I/O, caching, ETag generation) is done by getFile() * * @param req The Fastify request object * @param reply The Fastify reply object * @param resolvedPath The absolute path to the file to be served * @param options Optional configuration for file serving * @returns Information about whether the file was served and what status code */ serveFile(req: FastifyRequest, reply: FastifyReply, resolvedPath: string, options?: GetFileOptions): Promise; /** * Replaces routing maps and clears all file caches in one shot. * * Use this after a full build has completed. Unlike `updateConfig`, this method * makes no attempt at smart per-path invalidation — it simply replaces * whichever maps you provide and wipes the content, stat, and ETag caches * unconditionally, guaranteeing fresh reads for the next request. * * You may pass `singleAssetMap`, `folderMap`, or both. Omitted sections retain * their current routing configuration. Pass an empty object (`{}`) for a * section to clear all mappings in that section. All file caches are always * cleared, regardless of which sections are provided — even when only * `singleAssetMap` is passed, the rebuilt HTML pages likely reference JS/CSS * bundles served from `folderMap` directories that were also regenerated in * the same build step, so preserving folder caches would risk serving stale * assets alongside fresh pages. * * For targeted cache invalidation (when URL-to-path mappings changed but * file contents at those paths are unchanged), use `updateConfig` instead. * Note: `updateConfig` does not detect in-place file content changes — it * only tracks which filesystem paths entered or left the map. * * @param newConfig Sections to replace (at least one should be provided) * * @example * ```typescript * // After an SSG build completes (page map only): * cache.replaceConfig({ singleAssetMap: await loadPageMap() }); * * // After a build that changes both pages and asset folders: * cache.replaceConfig({ * singleAssetMap: await loadPageMap(), * folderMap: { '/assets/': { path: './dist/assets', detectImmutableAssets: true } }, * }); * ``` */ replaceConfig(newConfig: { singleAssetMap?: Record; folderMap?: Record; }): void; /** * Evicts a single file's cached content, stat, and ETag without touching * any URL-to-path mappings. * * Use this when you know a specific file changed on disk and want to force * a fresh read on the next request — without flushing the entire cache. * Works for files served via `singleAssetMap` or `folderMap`. * * The parameter is the **filesystem path** (as it appears in the cache key), * not a URL. * * For `singleAssetMap` entries these are the absolute paths you * provided. * * For folder-served files the cache key is the absolute path * resolved at request time. * * @param fsPath Absolute filesystem path of the file to evict * * @example * ```typescript * // A file watcher detected /dist/about.html was rewritten: * cache.invalidateFile('/dist/about.html'); * ``` */ invalidateFile(fsPath: string): void; /** * Clears all caches (useful for testing or cache invalidation) */ clearCaches(): void; /** * Gets statistics about cache usage */ getCacheStats(): { etag: { items: number; byteSize: number; }; content: { items: number; byteSize: number; }; compressedVariants: { items: number; byteSize: number; }; stat: { items: number; byteSize: number; }; }; /** * Updates the static content configuration at runtime with targeted cache * invalidation — only evicting entries whose URL-to-path mapping changed. * * Use this when URL routing is changing but file contents at existing paths * are unchanged (e.g., adding or removing pages without rebuilding assets). * For post-build reloads where file contents may have changed, use * `replaceConfig` instead. * * **Important:** When providing a section, you must provide the COMPLETE mapping for that section. * - If you provide `singleAssetMap`, it replaces the entire single asset map * - If you provide `folderMap`, it replaces the entire folder map * - You can update one section, the other, or both * - Omitted sections remain unchanged * * **Cache invalidation strategy:** * - `singleAssetMap` changes: Only invalidates filesystem paths whose URL-to-path * *mapping* changed (added, removed, or pointed to a different file). Paths whose * mapping is unchanged are not evicted — this method has no visibility into whether * the file content on disk changed. If files were rebuilt in-place, use * `replaceConfig` instead. * - `folderMap` changes: Clears all caches (folder changes are structural) * * @param newConfig Complete mapping(s) for the section(s) you want to update * * @example Update only single asset mappings * ```typescript * cache.updateConfig({ * singleAssetMap: { * '/': './dist/index.html', * '/blog/new-post': './dist/blog/new-post.html' * } * }); * ``` * * @example Update only folder mappings * ```typescript * cache.updateConfig({ * folderMap: { * '/assets': { path: './dist/assets', detectImmutableAssets: true } * } * }); * ``` * * @example Update both sections * ```typescript * cache.updateConfig({ * singleAssetMap: { '/': './dist/index.html' }, * folderMap: { '/assets': './dist/assets' } * }); * ``` */ updateConfig(newConfig: { singleAssetMap?: Record; folderMap?: Record; }): void; /** * Handles an HTTP request by resolving the URL to a file path and serving it * * This is a convenience method that combines URL resolution with file serving. * If no file matches the URL, it returns without sending a response (lets the hook fall through). * * @param rawURL The raw request URL (may include query string or hash) * @param req The Fastify request object * @param reply The Fastify reply object * @returns Information about whether a file was served */ handleRequest(rawURL: string, req: FastifyRequest, reply: FastifyReply): Promise; /** * Normalizes single asset map keys to ensure leading slash * Also validates against null bytes to prevent path injection */ private normalizeSingleAssetMap; /** * Normalizes folder map with proper prefix formatting * Also validates against null bytes to prevent path injection * * Handles two config formats: * 1. String shorthand: { "/assets/": "/path/to/assets" } * 2. Full config object: { "/assets/": { path: "/path/to/assets", detectImmutableAssets: true } } */ private normalizeFolderMap; /** * Normalizes URL prefix: ensures leading and trailing slash, collapses multiple slashes */ private normalizePrefix; /** * Gets the MIME type for a file based on its extension */ private getMimeType; /** * Compares two FolderConfig objects for equality * Dynamically checks all properties so we don't need to update this if FolderConfig changes */ private isSameFolderConfig; /** * Checks if a file appears to be fingerprinted/immutable based on filename * * Detects common build tool fingerprinting patterns: * - .{hash}.{ext} format (e.g., main.a1b2c3d4.js, styles.CTpDmzGw.css) * - -{hash}.{ext} format (e.g., chunk-a1b2c3d4.js, vendor-5f8e9a2b.js) * * Hash must be at least 6 alphanumeric characters * * @param filePath The file path to check * @returns True if the file appears to be fingerprinted */ private isImmutableAsset; private getCompressedCacheKey; private invalidateCompressedVariants; private handleCompressedVariantCacheChange; private untrackCompressedVariantByKey; private untrackCompressedVariant; } /** * Escapes HTML special characters to prevent XSS attacks * * Converts the following characters to HTML entities: * - & → & * - < → < * - > → > * - " → " * - ' → ' * * @param str - The string to escape * @returns The escaped string safe for insertion into HTML * * @example * ```ts * escapeHTML(''); * // Returns: '<script>alert("xss")</script>' * ``` */ declare function escapeHTML(str: string): string; /** * Escapes a string for safe insertion into double-quoted HTML attributes. * * Converts the following characters to HTML entities: * - & → & * - " → " * - < → < * - > → > * * @param str - The string to escape * @returns The escaped string safe for insertion into HTML attributes */ declare function escapeHTMLAttr(str: string): string; declare const MINIMUM_SUPPORTED_NODE_MAJOR = 25; type RuntimeName = 'bun' | 'node' | 'unknown'; interface RuntimeSupportInfo { runtime: RuntimeName; isSupported: boolean; minimumNodeMajor: number; nodeVersion?: string; bunVersion?: string; } interface RuntimeEnvironmentLike { Bun?: unknown; process?: { versions?: Partial>; }; } /** * Detect the current JavaScript runtime and whether it satisfies Unirend's * runtime requirement. Bun is treated as supported even if it reports an older * Node compatibility version via `process.versions.node`. */ declare function getRuntimeSupportInfo(minimumNodeMajor?: number, environment?: RuntimeEnvironmentLike): RuntimeSupportInfo; /** * Convenience boolean check for Unirend's runtime requirement. */ declare function isSupportedRuntime(minimumNodeMajor?: number, environment?: RuntimeEnvironmentLike): boolean; /** * Throw a descriptive error when the current runtime does not satisfy * Unirend's runtime requirement. */ declare function assertSupportedRuntime(minimumNodeMajor?: number, environment?: RuntimeEnvironmentLike): void; export { type CreateStreamOptions, type FileContent, type FileErrorResult, type FileFoundResult, type FileNotFoundResult, type FileNotModifiedResult, type FileResult, type FolderConfig, type GetFileOptions, MINIMUM_SUPPORTED_NODE_MAJOR, type RuntimeName, type RuntimeSupportInfo, type ServeFileResult, StaticContentCache, assertSupportedRuntime, escapeHTML, escapeHTMLAttr, getRuntimeSupportInfo, isSupportedRuntime };