{"version":3,"sources":["../../src/plugins.ts","../../src/lib/built-in-plugins/cors.ts","../../src/lib/internal/http-header-utils.ts","../../src/lib/built-in-plugins/domain-validation.ts","../../src/lib/internal/consts.ts","../../src/lib/internal/server-utils.ts","../../src/lib/built-in-plugins/client-info.ts","../../src/lib/built-in-plugins/cookies.ts","../../src/lib/internal/static-content-cache.ts","../../src/lib/internal/response-compression.ts","../../src/lib/internal/static-content-hook.ts","../../src/lib/built-in-plugins/static-content.ts"],"sourcesContent":["// Import cookie utilities from @fastify/cookie for re-export\nimport {\n  fastifyCookie as fastifyCookieModule,\n  sign as signCookieValue,\n  unsign as unsignCookieValue,\n  Signer as CookieSigner,\n  signerFactory as createCookieSigner,\n} from '@fastify/cookie';\n\n// Re-export CORS plugin for cross-origin request handling\nexport {\n  type CORSConfig,\n  type CORSOrigin,\n  cors,\n} from './lib/built-in-plugins/cors';\n\n// Re-export domain validation plugin for enforcing canonical domains\nexport {\n  type InvalidDomainResponse,\n  type DomainValidationConfig,\n  type ValidProductionDomains,\n  domainValidation,\n} from './lib/built-in-plugins/domain-validation';\n\n// Re-export client info plugin for request metadata extraction\nexport {\n  type ClientInfoConfig,\n  clientInfo,\n} from './lib/built-in-plugins/client-info';\n\n// Re-export cookies plugin for cookie parsing and signing\nexport { type CookiesConfig, cookies } from './lib/built-in-plugins/cookies';\n\n// Re-export static content plugin for serving static files\nexport {\n  type StaticContentRouterOptions,\n  type FolderConfig,\n  staticContent,\n} from './lib/built-in-plugins/static-content';\n\n// Re-export manual cookie utilities from @fastify/cookie for convenience\nexport const cookieUtils = {\n  parse: fastifyCookieModule.parse,\n  serialize: fastifyCookieModule.serialize,\n  signerFactory: createCookieSigner,\n  Signer: CookieSigner,\n  sign: signCookieValue,\n  unsign: unsignCookieValue,\n} as const;\n\n// Re-export common types so consumers don't need to depend on @fastify/cookie directly\nexport type {\n  CookieSerializeOptions,\n  UnsignResult as CookieUnsignResult,\n} from '@fastify/cookie';\n","import type { FastifyRequest, FastifyReply } from 'fastify';\nimport type { ServerPlugin, PluginHostInstance } from '../types';\nimport {\n  matchesOriginList,\n  matchesCORSCredentialsList,\n  validateConfigEntry,\n} from 'lifecycleion/domain-utils';\nimport { addToVaryHeader } from '../internal/http-header-utils';\n\n/**\n * CORS origin configuration - can be a string, array, or function\n */\nexport type CORSOrigin =\n  | string\n  | string[]\n  | ((\n      origin: string | undefined,\n      request: FastifyRequest,\n    ) => boolean | Promise<boolean>);\n\n/**\n * Configuration for dynamic CORS handling\n */\nexport interface CORSConfig {\n  /**\n   * Allowed origins for CORS requests\n   * - string: Single origin (e.g., \"https://example.com\")\n   * - string[]: Multiple origins with wildcard support\n   * - function: Dynamic origin validation\n   * - \"*\": Allow all origins (not recommended with credentials)\n   *\n   * Wildcard patterns supported:\n   * - \"*.example.com\": Direct subdomains only (api.example.com ✅, app.api.example.com ❌)\n   * - \"**.example.com\": All subdomains including nested (api.example.com ✅, app.api.example.com ✅)\n   * - \"https://*\": Any domain with HTTPS protocol\n   * - \"http://*\": Any domain with HTTP protocol\n   * - \"https://*.example.com\": HTTPS subdomains only\n   * - \"http://**.example.com\": HTTP subdomains including nested\n   *\n   * Note: \"null\" origins (from sandboxed documents, file:// URLs) are treated as regular string values.\n   * Include \"null\" in your origin array or handle it in your validation function if needed.\n   *\n   * @default \"*\"\n   */\n  origin?: CORSOrigin;\n\n  /**\n   * Origins that are allowed to send credentials (cookies, auth headers)\n   * This enables more granular control than standard CORS libraries\n   *\n   * - string[]: List of trusted origins that can send credentials\n   * - function: Dynamic credential validation based on origin\n   * - true: Allow credentials for all allowed origins (same as @fastify/cors)\n   * - false: Never allow credentials\n   *\n   * @default false\n   */\n  credentials?:\n    | boolean\n    | string[]\n    | ((\n        origin: string | undefined,\n        request: FastifyRequest,\n      ) => boolean | Promise<boolean>);\n\n  /**\n   * Allowed HTTP methods\n   * @default [\"GET\", \"POST\", \"PUT\", \"DELETE\", \"OPTIONS\", \"PATCH\"]\n   */\n  methods?: string[];\n\n  /**\n   * Allowed request headers\n   * - string[]: List of specific headers (e.g., [\"Content-Type\", \"Authorization\"])\n   * - [\"*\"]: Reflect exactly what the browser requests (useful for public APIs)\n   * @default [\"Content-Type\", \"Authorization\", \"X-Requested-With\"]\n   */\n  allowedHeaders?: string[];\n\n  /**\n   * Headers exposed to the client\n   * @default []\n   */\n  exposedHeaders?: string[];\n\n  /**\n   * Max age for preflight cache (in seconds)\n   * @default 86400 (24 hours)\n   */\n  maxAge?: number;\n\n  /**\n   * Whether to pass control to next handler on preflight OPTIONS requests\n   * @default false\n   */\n  preflightContinue?: boolean;\n\n  /**\n   * Status code for successful preflight responses\n   * @default 204\n   */\n  optionsSuccessStatus?: number;\n\n  /**\n   * Whether to allow private network requests (Chrome feature)\n   * When true, responds to Access-Control-Request-Private-Network with Access-Control-Allow-Private-Network\n   * @default false\n   */\n  allowPrivateNetwork?: boolean;\n\n  /**\n   * Opt-in: allow wildcard subdomain patterns (e.g., \"*.example.com\") in `credentials` array\n   * When true, patterns like \"*.example.com\", \"**.example.com\", \"*.*.example.com\" are permitted.\n   * Apex domains are NOT matched by wildcard patterns; include the apex explicitly if needed.\n   * Invalid patterns (bare \"*\", protocol wildcards like \"https://*\") are rejected.\n   *\n   * @default false\n   */\n  credentialsAllowWildcardSubdomains?: boolean;\n\n  /**\n   * Opt-in: allow credentials: true when origin includes a protocol wildcard (e.g., \"https://*\")\n   * By default this is disallowed for safety because it enables credentials for any origin\n   * on that protocol.\n   *\n   * @default false\n   */\n  allowCredentialsWithProtocolWildcard?: boolean;\n\n  /**\n   * Controls the X-Frame-Options response header.\n   * - false: do not send the header (default)\n   * - \"DENY\" | \"SAMEORIGIN\": header value to send\n   *\n   * @default false\n   */\n  xFrameOptions?: false | 'DENY' | 'SAMEORIGIN';\n\n  /**\n   * Controls the Strict-Transport-Security (HSTS) response header.\n   * - false: do not send the header (default)\n   * - { maxAge, includeSubDomains?, preload? }: header parameters\n   *\n   * Note: HSTS is typically only appropriate over HTTPS in production.\n   * This plugin does not inspect the connection security; enable with care.\n   *\n   * @default false\n   */\n  hsts?:\n    | false\n    | {\n        maxAge: number; // seconds\n        includeSubDomains?: boolean;\n        preload?: boolean;\n      };\n}\n\n/**\n * Default CORS configuration\n */\nconst DEFAULT_CONFIG: Required<Omit<CORSConfig, 'credentials' | 'origin'>> & {\n  origin: CORSOrigin;\n  credentials: boolean;\n} = {\n  origin: '*',\n  credentials: false,\n  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],\n  allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],\n  exposedHeaders: [],\n  maxAge: 86400,\n  preflightContinue: false,\n  optionsSuccessStatus: 204,\n  allowPrivateNetwork: false,\n  credentialsAllowWildcardSubdomains: false,\n  allowCredentialsWithProtocolWildcard: false,\n  xFrameOptions: false,\n  hsts: false,\n};\n\n// Limit how many headers we reflect/allow on preflight to avoid abuse\nconst MAX_ALLOWED_HEADERS = 100;\n\n// Limit the length of each reflected header name to avoid pathological values\nconst MAX_HEADER_LEN = 256;\n\ntype ResolvedCORSConfig = Required<\n  Omit<CORSConfig, 'credentials' | 'origin'>\n> & {\n  origin: CORSOrigin;\n  credentials:\n    | boolean\n    | string[]\n    | ((\n        origin: string | undefined,\n        request: FastifyRequest,\n      ) => boolean | Promise<boolean>);\n};\n\n/**\n * Validate credentials origins using centralized validateConfigEntry\n */\nfunction validateCredentialsOrigins(\n  credentials: string[],\n  allowWildcard: boolean,\n): void {\n  for (const o of credentials) {\n    // Never allow credentials for the special \"null\" origin\n    if (o === 'null') {\n      throw new Error(\n        \"Invalid CORS config: credentials cannot be enabled for the 'null' origin. Remove 'null' from the credentials list.\",\n      );\n    }\n\n    // Use validateConfigEntry to get comprehensive validation\n    const verdict = validateConfigEntry(o, 'origin', {\n      allowGlobalWildcard: false, // Never allow global wildcard in credentials\n      allowProtocolWildcard: false, // Never allow protocol wildcards in credentials\n    });\n\n    if (!verdict.valid) {\n      throw new Error(\n        `Invalid CORS credentials origin \"${o}\"${verdict.info ? ': ' + verdict.info : ''}`,\n      );\n    }\n\n    // Use wildcardKind from validateConfigEntry to determine policy\n    if (verdict.wildcardKind === 'global') {\n      throw new Error(\n        `Global wildcard \"${o}\" is not allowed in credentials. Use specific origins or subdomain patterns like \"*.example.com\".`,\n      );\n    }\n\n    if (verdict.wildcardKind === 'protocol') {\n      throw new Error(\n        `Protocol wildcard \"${o}\" is not allowed in credentials. Use domain patterns like \"*.example.com\" or \"**.example.com\".`,\n      );\n    }\n\n    if (verdict.wildcardKind === 'subdomain' && !allowWildcard) {\n      throw new Error(\n        `Wildcard pattern \"${o}\" in credentials requires credentialsAllowWildcardSubdomains: true or use explicit origins.`,\n      );\n    }\n  }\n}\n\n/**\n * Check if an origin is allowed based on the origin configuration\n */\nasync function isOriginAllowed(\n  origin: string | undefined,\n  originConfig: CORSOrigin,\n  request: FastifyRequest,\n): Promise<boolean> {\n  if (typeof originConfig === 'string') {\n    // Delegate to list matcher for uniform handling (exact, wildcard, protocol wildcard, and \"*\")\n    return matchesOriginList(origin, [originConfig]);\n  }\n\n  if (Array.isArray(originConfig)) {\n    return matchesOriginList(origin, originConfig);\n  }\n\n  if (typeof originConfig === 'function') {\n    return await originConfig(origin, request);\n  }\n\n  return false;\n}\n\n/**\n * Check if credentials are allowed for an origin\n */\nasync function areCredentialsAllowed(\n  origin: string | undefined,\n  credentialsConfig: CORSConfig['credentials'],\n  request: FastifyRequest,\n  allowWildcardSubdomains: boolean,\n): Promise<boolean> {\n  if (credentialsConfig === false || credentialsConfig === undefined) {\n    return false;\n  }\n\n  if (credentialsConfig === true) {\n    return true;\n  }\n\n  if (Array.isArray(credentialsConfig)) {\n    return matchesCORSCredentialsList(origin, credentialsConfig, {\n      allowWildcardSubdomains: allowWildcardSubdomains,\n    });\n  }\n\n  if (typeof credentialsConfig === 'function') {\n    return await credentialsConfig(origin, request);\n  }\n\n  return false;\n}\n\nfunction applyCORSSecurityHeaders(\n  reply: FastifyReply,\n  resolvedConfig: ResolvedCORSConfig,\n): void {\n  // These headers are not negotiated per-origin. They are safe to apply even\n  // on requests that will ultimately receive no Access-Control-Allow-Origin\n  // header, so we keep them separate from the origin-dependent CORS logic.\n\n  // Set Vary: Origin unconditionally so CDN caches don't serve a cached\n  // non-CORS response (which lacks Access-Control-Allow-Origin) to a\n  // later CORS request for the same URL.\n  addToVaryHeader(reply, 'Origin');\n\n  // Security headers (applied for all requests early in lifecycle)\n  if (resolvedConfig.xFrameOptions) {\n    reply.header('X-Frame-Options', resolvedConfig.xFrameOptions);\n  }\n\n  if (resolvedConfig.hsts) {\n    const parts = [`max-age=${Math.floor(resolvedConfig.hsts.maxAge)}`];\n\n    if (resolvedConfig.hsts.includeSubDomains) {\n      parts.push('includeSubDomains');\n    }\n\n    if (resolvedConfig.hsts.preload) {\n      parts.push('preload');\n    }\n\n    reply.header('Strict-Transport-Security', parts.join('; '));\n  }\n}\n\nasync function applyCORSActualResponseHeaders(\n  request: FastifyRequest,\n  reply: FastifyReply,\n  resolvedConfig: ResolvedCORSConfig,\n  isOriginAllowedResult?: boolean,\n): Promise<void> {\n  const origin = request.headers.origin;\n  const isAllowed =\n    isOriginAllowedResult ??\n    (await isOriginAllowed(origin, resolvedConfig.origin, request));\n\n  // Apply the unconditional security/Vary headers first, then layer the\n  // origin-negotiated CORS headers on top if this request is allowed.\n  applyCORSSecurityHeaders(reply, resolvedConfig);\n\n  // For non-preflight requests, let them proceed without CORS headers if the\n  // origin is not allowed. Same-origin requests still work; browsers enforce\n  // the cross-origin failure client-side.\n  if (!isAllowed && origin) {\n    return;\n  }\n\n  if (origin && isAllowed) {\n    // For allowed cross-origin requests we echo the specific origin rather than\n    // using '*' so credentials/exposed-headers semantics stay correct.\n    reply.header('Access-Control-Allow-Origin', origin);\n\n    const isCredentialsAllowed = await areCredentialsAllowed(\n      origin,\n      resolvedConfig.credentials,\n      request,\n      resolvedConfig.credentialsAllowWildcardSubdomains,\n    );\n\n    // Never send credentials for the special 'null' origin\n    if (isCredentialsAllowed && origin !== 'null') {\n      reply.header('Access-Control-Allow-Credentials', 'true');\n    }\n\n    if (resolvedConfig.exposedHeaders.length > 0) {\n      reply.header(\n        'Access-Control-Expose-Headers',\n        resolvedConfig.exposedHeaders.join(', '),\n      );\n    }\n  } else if (!origin && resolvedConfig.origin === '*') {\n    // Requests without an Origin header are non-browser/same-origin style\n    // traffic. When policy is fully wildcard, keep the public wildcard signal.\n    reply.header('Access-Control-Allow-Origin', '*');\n  }\n}\n\n/**\n * Dynamic CORS plugin for Unirend\n *\n * Provides more flexible CORS handling than @fastify/cors, specifically:\n * - Dynamic credentials based on origin\n * - Function-based origin validation\n * - Separate credential and origin policies\n *\n * @example\n * ```typescript\n * // Allow public API access but only credentials for trusted origins\n * cors({\n *   origin: \"*\", // Allow any origin for public API\n *   credentials: [\"https://myapp.com\", \"https://admin.myapp.com\"], // Only these can send cookies\n *   methods: [\"GET\", \"POST\"],\n * })\n *\n * // Handle \"null\" origins from sandboxed documents or file:// URLs\n * cors({\n *   origin: [\"https://app.com\", \"null\"], // Explicitly allow null origins\n *   credentials: [\"https://app.com\"], // Credentials not allowed for null origins\n * })\n *\n * // Dynamic validation based on request\n * cors({\n *   origin: (origin, request) => {\n *     // Allow any origin for public endpoints\n *     if (request.url?.startsWith('/api/public/')) return true;\n *     // Restrict private endpoints\n *     return origin === 'https://myapp.com';\n *   },\n *   credentials: (origin, request) => {\n *     // Only allow credentials for authenticated endpoints from trusted origins\n *     return request.url?.startsWith('/api/auth/') && origin === 'https://myapp.com';\n *   }\n * })\n * ```\n */\nexport function cors(config: CORSConfig = {}): ServerPlugin {\n  const resolvedConfig = { ...DEFAULT_CONFIG, ...config };\n\n  // Config-time validations:\n  // - Origin '*' special handling:\n  //   - Disallow credentials: true (spec prohibits ACA-C: true with ACA-O: *)\n  //   - Disallow dynamic credentials function (avoid reflect+credentials footgun)\n  //   - If credentials is a string[] allowlist, validate and upgrade origin to that list\n  // - Origin arrays are validated using validateConfigEntry (domain-utils) plus policy:\n  //   - Allow at most one wildcard token ('*' or a protocol wildcard)\n  //   - If a wildcard token is present, the only other allowed entry is 'null' string literal\n  // Credentials policy highlights:\n  //   - Never allow credentials for the literal 'null' origin\n  //   - Disallow global/protocol wildcards in credentials allowlists\n  //   - Allow subdomain wildcards in credentials only when credentialsAllowWildcardSubdomains: true\n\n  if (resolvedConfig.origin === '*' && resolvedConfig.credentials === true) {\n    throw new Error(\n      \"Cannot use credentials: true with origin: '*'. The CORS specification prohibits Access-Control-Allow-Credentials: true with Access-Control-Allow-Origin: *. Use specific origins instead.\",\n    );\n  }\n\n  // Guard: credentials: true with protocol wildcard (e.g., https://*) is high risk.\n  // Require explicit opt-in via allowCredentialsWithProtocolWildcard: true\n  if (resolvedConfig.credentials === true) {\n    const hasProtocolWildcard = (value: CORSOrigin): boolean => {\n      if (typeof value === 'string') {\n        return value === 'https://*' || value === 'http://*';\n      }\n\n      if (Array.isArray(value)) {\n        return value.some((v) => v === 'https://*' || v === 'http://*');\n      }\n\n      return false; // functions are evaluated per-request; not considered a blanket wildcard here\n    };\n\n    if (\n      hasProtocolWildcard(resolvedConfig.origin) &&\n      !resolvedConfig.allowCredentialsWithProtocolWildcard\n    ) {\n      throw new Error(\n        'Cannot use credentials: true with protocol wildcard origins unless allowCredentialsWithProtocolWildcard: true. Use specific origins instead.',\n      );\n    }\n  }\n\n  // Additional guard: prevent reflect+credentials when origin is '*'\n  if (resolvedConfig.origin === '*') {\n    // Dynamic function with '*' would enable reflecting arbitrary origins with credentials\n    if (typeof resolvedConfig.credentials === 'function') {\n      throw new TypeError(\n        \"Unsafe CORS: cannot combine origin '*' with dynamic credentials. Use a concrete origin list when enabling credentials.\",\n      );\n    }\n\n    // If credentials is an allowlist, validate and upgrade origin to that list\n    if (Array.isArray(resolvedConfig.credentials)) {\n      validateCredentialsOrigins(\n        resolvedConfig.credentials,\n        resolvedConfig.credentialsAllowWildcardSubdomains,\n      );\n\n      const allowlist = Array.from(new Set(resolvedConfig.credentials));\n      if (allowlist.length === 0) {\n        throw new Error(\n          \"Invalid CORS config: credentials list is empty; cannot combine origin '*' with credentials.\",\n        );\n      }\n      // Upgrade: stop using '*' and switch to a concrete allowlist for origin\n      resolvedConfig.origin = allowlist;\n      // Keep origin and credentials aligned to reduce misconfiguration\n      resolvedConfig.credentials = allowlist;\n    }\n  }\n\n  // Validate credentials wildcard patterns\n  if (Array.isArray(resolvedConfig.credentials)) {\n    validateCredentialsOrigins(\n      resolvedConfig.credentials,\n      resolvedConfig.credentialsAllowWildcardSubdomains,\n    );\n  }\n\n  // Validate origin entries using centralized validator with appropriate wildcard policies\n  if (typeof resolvedConfig.origin === 'string') {\n    if (resolvedConfig.origin !== '*') {\n      const verdict = validateConfigEntry(resolvedConfig.origin, 'origin', {\n        allowGlobalWildcard: false, // Global wildcard handled separately above\n        allowProtocolWildcard: true, // Allow protocol wildcards in origin\n      });\n\n      if (!verdict.valid) {\n        throw new Error(\n          `Invalid CORS origin \"${resolvedConfig.origin}\"${verdict.info ? ': ' + verdict.info : ''}`,\n        );\n      }\n    }\n  } else if (Array.isArray(resolvedConfig.origin)) {\n    const entries = resolvedConfig.origin;\n    // Normalize [\"*\"] to \"*\"\n    const unique = Array.from(new Set(entries));\n    if (unique.length === 1 && unique[0] === '*') {\n      resolvedConfig.origin = '*';\n    } else {\n      // Special policy: '*' inside an array is only allowed when paired solely with 'null'\n      if (entries.includes('*')) {\n        const isOnlyStarAndNull = entries.every(\n          (e) => e === '*' || e === 'null',\n        );\n\n        if (!isOnlyStarAndNull) {\n          throw new Error(\n            \"Invalid CORS config: Do not include '*' inside an origin array. Use origin: '*' (string) to allow all, or list specific origins.\",\n          );\n        }\n      }\n\n      let wildcardKindSeen: 'none' | 'global' | 'protocol' = 'none';\n      const wildcardTokensSeen: string[] = [];\n\n      for (const o of entries) {\n        // Use centralized validator to classify\n        const verdict = validateConfigEntry(o, 'origin', {\n          allowGlobalWildcard: true,\n          allowProtocolWildcard: true,\n        });\n        if (!verdict.valid) {\n          throw new Error(\n            `Invalid CORS origin \"${o}\"${verdict.info ? ': ' + verdict.info : ''}`,\n          );\n        }\n        if (\n          verdict.wildcardKind === 'global' ||\n          verdict.wildcardKind === 'protocol'\n        ) {\n          const token = verdict.wildcardKind === 'global' ? '*' : o;\n          if (wildcardTokensSeen.length > 0) {\n            if (wildcardTokensSeen.includes(token)) {\n              // Duplicate of the same wildcard token\n              throw new Error(\n                \"Invalid CORS config: only one of '*', 'https://*', or 'http://*' may be specified in origin.\",\n              );\n            }\n            // Multiple distinct wildcard tokens – include exact list in error\n            const foundList = wildcardTokensSeen.concat(token).join(', ');\n            throw new Error(\n              `Invalid CORS config: only one of '*', 'https://*', or 'http://*' may be specified in origin. Found: ${foundList}`,\n            );\n          }\n\n          wildcardTokensSeen.push(token);\n          wildcardKindSeen = verdict.wildcardKind;\n          continue;\n        }\n\n        if (o === 'null') {\n          continue;\n        }\n\n        // Non-wildcard, non-null entries\n        if (wildcardKindSeen !== 'none') {\n          throw new Error(\n            \"Invalid CORS config: when a wildcard token is present, the only other allowed entry is the literal 'null'.\",\n          );\n        }\n      }\n\n      // Additional safety: if a global '*' token is present inside the origin array,\n      // disallow credentials: true and dynamic credentials function to avoid\n      // reflecting arbitrary origins with credentials.\n      if (entries.includes('*')) {\n        if (resolvedConfig.credentials === true) {\n          throw new Error(\n            \"Cannot use credentials: true when origin array contains '*'. Use specific origins instead or remove credentials: true.\",\n          );\n        }\n        if (typeof resolvedConfig.credentials === 'function') {\n          throw new TypeError(\n            \"Unsafe CORS: cannot combine an origin array containing '*' with dynamic credentials. Use a concrete origin list when enabling credentials.\",\n          );\n        }\n      }\n\n      // Validation complete; configuration is acceptable at this point\n    }\n  }\n\n  // Auto-merge credentials origins into origin list for safety\n  // This prevents common configuration mistakes where credentials origins aren't included in the origin list\n  // Note: credentials controls Access-Control-Allow-Credentials header, which tells browsers\n  // whether to include cookies/auth headers in requests - it doesn't automatically allow cookies\n  if (\n    Array.isArray(resolvedConfig.credentials) &&\n    Array.isArray(resolvedConfig.origin)\n  ) {\n    // Merge credentials origins into origin list to ensure they're allowed for CORS\n    const credentialsOrigins = resolvedConfig.credentials;\n    const existingOrigins = resolvedConfig.origin;\n    const mergedOrigins = [\n      ...new Set([...existingOrigins, ...credentialsOrigins]),\n    ];\n    resolvedConfig.origin = mergedOrigins;\n  } else if (\n    Array.isArray(resolvedConfig.credentials) &&\n    typeof resolvedConfig.origin === 'string' &&\n    resolvedConfig.origin !== '*'\n  ) {\n    // Convert single origin to array and merge with credentials origins\n    const credentialsOrigins = resolvedConfig.credentials;\n    const mergedOrigins = [\n      ...new Set([resolvedConfig.origin, ...credentialsOrigins]),\n    ];\n    resolvedConfig.origin = mergedOrigins;\n  }\n\n  // Validate security header options at config-time\n  if (resolvedConfig.hsts) {\n    const cfg = resolvedConfig.hsts;\n\n    if (\n      typeof cfg.maxAge !== 'number' ||\n      !Number.isFinite(cfg.maxAge) ||\n      cfg.maxAge < 0\n    ) {\n      throw new Error(\n        'Invalid CORS config: hsts.maxAge must be a non-negative number (seconds)',\n      );\n    }\n\n    // When requesting HSTS preload, enforce Chrome preload list requirements:\n    // - max-age must be at least 31536000 (1 year)\n    // - includeSubDomains must be present\n    if (cfg.preload) {\n      if (cfg.maxAge < 31536000) {\n        throw new Error(\n          'Invalid CORS config: HSTS preload requires maxAge >= 31536000 (1 year)',\n        );\n      }\n\n      if (!cfg.includeSubDomains) {\n        throw new Error(\n          'Invalid CORS config: HSTS preload requires includeSubDomains: true',\n        );\n      }\n    }\n  }\n\n  return async (fastify: PluginHostInstance) => {\n    fastify.decorateRequest(\n      'applyCORSHeaders',\n      async function applyCORSHeaders(\n        this: FastifyRequest,\n        reply: FastifyReply,\n      ) {\n        const isOriginAllowedCached = (\n          this as FastifyRequest & { corsOriginAllowed?: boolean }\n        ).corsOriginAllowed;\n\n        await applyCORSActualResponseHeaders(\n          this,\n          reply,\n          resolvedConfig,\n          isOriginAllowedCached,\n        );\n      },\n    );\n\n    // Handle preflight OPTIONS requests\n    fastify.addHook(\n      'onRequest',\n      async (request: FastifyRequest, reply: FastifyReply) => {\n        // origin is undefined for same-origin and non-browser requests; all\n        // branches below guard with `origin &&` or `!origin` checks accordingly.\n        const origin = request.headers.origin;\n        const method = request.method;\n\n        applyCORSSecurityHeaders(reply, resolvedConfig);\n\n        // Check if origin is allowed and cache result on request\n        const isOriginAllowedResult = await isOriginAllowed(\n          origin,\n          resolvedConfig.origin,\n          request,\n        );\n\n        // Cache the result to avoid recomputing in onSend hook\n        (\n          request as FastifyRequest & { corsOriginAllowed?: boolean }\n        ).corsOriginAllowed = isOriginAllowedResult;\n\n        // Handle preflight OPTIONS requests\n        if (method === 'OPTIONS') {\n          // Add Vary headers for preflight caching\n          addToVaryHeader(\n            reply,\n            'Access-Control-Request-Headers',\n            'Access-Control-Request-Method',\n            'Access-Control-Request-Private-Network',\n          );\n\n          // Return 403 for disallowed origins on preflight\n          if (!isOriginAllowedResult && origin) {\n            reply.code(403).header('Cache-Control', 'no-store');\n            return reply.send({ error: 'Origin not allowed by CORS policy' });\n          }\n\n          // Get requested headers from preflight\n          const requestedHeaders = request.headers[\n            'access-control-request-headers'\n          ] as string;\n\n          // Build allowed methods using Set for deduplication and normalize to uppercase\n          const methodSet = new Set(\n            resolvedConfig.methods.map((m) => m.toUpperCase()),\n          );\n\n          const allowedMethods = Array.from(methodSet);\n\n          // Build allowed headers (merge requested headers with configured ones)\n          let allowedHeaders: string[];\n\n          if (resolvedConfig.allowedHeaders.includes('*')) {\n            if (requestedHeaders) {\n              // Reflect exactly what was requested (case-insensitive dedupe + cap)\n              const requested = requestedHeaders\n                .split(',')\n                .map((h) => h.trim())\n                .filter(Boolean);\n\n              const seen = new Set<string>();\n              const reflected: string[] = [];\n              // RFC 7230 token validation for header names\n              const token = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;\n\n              for (const h of requested) {\n                // Enforce a maximum header token length to prevent abuse\n                if (h.length > MAX_HEADER_LEN) {\n                  continue;\n                }\n\n                // Only reflect syntactically valid header names\n                if (!token.test(h)) {\n                  continue;\n                }\n\n                const key = h.toLowerCase();\n\n                if (!seen.has(key)) {\n                  seen.add(key);\n                  reflected.push(h);\n                  if (reflected.length >= MAX_ALLOWED_HEADERS) {\n                    break;\n                  }\n                }\n              }\n\n              allowedHeaders = reflected;\n            } else {\n              // Fallback to configured list without the '*'\n              allowedHeaders = resolvedConfig.allowedHeaders.filter(\n                (h) => h !== '*',\n              );\n            }\n          } else {\n            // Start with configured headers\n            allowedHeaders = [...resolvedConfig.allowedHeaders];\n\n            if (requestedHeaders) {\n              // Merge requested headers that are in our allowed list\n              const requested = requestedHeaders\n                .split(',')\n                .map((h) => h.trim())\n                .filter(Boolean);\n\n              const configuredLower = resolvedConfig.allowedHeaders.map((h) =>\n                h.toLowerCase(),\n              );\n\n              const token = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;\n\n              for (const requestedHeader of requested) {\n                // Skip invalid header names up front\n                if (\n                  requestedHeader.length > MAX_HEADER_LEN ||\n                  !token.test(requestedHeader)\n                ) {\n                  continue;\n                }\n\n                const requestedLower = requestedHeader.toLowerCase();\n                if (\n                  configuredLower.includes(requestedLower) &&\n                  !allowedHeaders.some(\n                    (h) => h.toLowerCase() === requestedLower,\n                  )\n                ) {\n                  // Find the original configured header to preserve casing\n                  const configuredHeader = resolvedConfig.allowedHeaders.find(\n                    (h) => h.toLowerCase() === requestedLower,\n                  );\n\n                  allowedHeaders.push(configuredHeader || requestedHeader);\n                }\n              }\n            }\n          }\n\n          // Cap to avoid sending excessive header lists\n          if (allowedHeaders.length > MAX_ALLOWED_HEADERS) {\n            allowedHeaders = allowedHeaders.slice(0, MAX_ALLOWED_HEADERS);\n          }\n\n          // Set preflight response headers\n          reply.header(\n            'Access-Control-Allow-Methods',\n            allowedMethods.join(', '),\n          );\n\n          // Only set Access-Control-Allow-Headers if we have headers to send\n          if (allowedHeaders.length > 0) {\n            reply.header(\n              'Access-Control-Allow-Headers',\n              allowedHeaders.join(', '),\n            );\n          }\n\n          reply.header(\n            'Access-Control-Max-Age',\n            resolvedConfig.maxAge.toString(),\n          );\n\n          // Handle private network requests (Chrome feature)\n          const requestPrivateNetwork =\n            request.headers['access-control-request-private-network'];\n\n          if (\n            requestPrivateNetwork === 'true' &&\n            resolvedConfig.allowPrivateNetwork\n          ) {\n            reply.header('Access-Control-Allow-Private-Network', 'true');\n          }\n\n          if (resolvedConfig.preflightContinue) {\n            // Continue to route handler but set CORS headers first\n            await applyCORSActualResponseHeaders(\n              request,\n              reply,\n              resolvedConfig,\n              isOriginAllowedResult,\n            );\n\n            return;\n          } else {\n            // Handle preflight completely here\n            await applyCORSActualResponseHeaders(\n              request,\n              reply,\n              resolvedConfig,\n              isOriginAllowedResult,\n            );\n\n            reply.code(resolvedConfig.optionsSuccessStatus);\n            return reply.send();\n          }\n        }\n\n        await applyCORSActualResponseHeaders(\n          request,\n          reply,\n          resolvedConfig,\n          isOriginAllowedResult,\n        );\n      },\n    );\n\n    return Promise.resolve();\n  };\n}\n","import type { FastifyReply } from 'fastify';\n\n/**\n * Add one or more values to the Vary header without duplicates.\n */\nexport function addToVaryHeader(\n  reply: FastifyReply,\n  ...values: string[]\n): void {\n  const existing = reply.getHeader('Vary');\n  const current = Array.isArray(existing)\n    ? existing.join(', ')\n    : ((existing ?? '') as string);\n\n  const vary = new Set(\n    current\n      .split(',')\n      .map((header) => header.trim())\n      .filter(Boolean),\n  );\n\n  for (const value of values) {\n    vary.add(value);\n  }\n\n  reply.header('Vary', Array.from(vary).join(', '));\n}\n","import type { PluginHostInstance, PluginOptions, ServerPlugin } from '../types';\nimport type { FastifyRequest } from 'fastify';\nimport {\n  normalizeDomain,\n  matchesDomainList,\n  isApexDomain,\n  validateConfigEntry,\n  parseHostHeader,\n} from 'lifecycleion/domain-utils';\nimport {\n  classifyRequest,\n  normalizeAPIPrefix,\n  normalizePageDataEndpoint,\n} from '../internal/server-utils';\n\n/**\n * Response configuration for invalid domain handler\n */\nexport interface InvalidDomainResponse {\n  contentType: 'json' | 'text' | 'html';\n  content: string | object;\n}\n\n/**\n * Domain validation configuration - can be a string, array, or function\n */\nexport type ValidProductionDomains =\n  | string\n  | string[]\n  | ((domain: string, request: FastifyRequest) => boolean | Promise<boolean>);\n\n/**\n * Configuration options for the domainValidation plugin\n */\nexport interface DomainValidationConfig {\n  /**\n   * Valid production domains that are allowed to access this server\n   *\n   * Can be a single domain string, array of domain strings (without protocol),\n   * or a function for request-aware domain validation.\n   * Wildcard patterns supported:\n   * - \"example.com\" - allows exact match only\n   * - \"*.example.com\" - allows direct subdomains only (api.example.com ✅, app.api.example.com ❌)\n   * - \"**.example.com\" - allows all subdomains including nested (api.example.com ✅, app.api.example.com ✅)\n   *\n   * Examples:\n   * - [\"example.com\", \"www.example.com\", \"api.example.com\"] - specific domains\n   * - [\"**.example.com\", \"example.com\"] - apex + all subdomains (including nested)\n   * - [\"*.example.com\", \"example.com\"] - apex + direct subdomains only\n   *\n   * Note: Domain validation is protocol-agnostic (ignores http/https)\n   * If not specified, domain validation is skipped\n   */\n  validProductionDomains?: ValidProductionDomains;\n\n  /**\n   * Optional canonical domain to redirect to if the request domain doesn't match\n   * Should be defined without www prefix or protocol (use wwwHandling to control www)\n   * If specified, requests to valid domains will be redirected to this canonical domain\n   * If not specified, valid domains are allowed without redirection\n   * Example: \"example.com\"\n   */\n  canonicalDomain?: string;\n\n  /**\n   * Whether to enforce HTTPS by redirecting HTTP requests\n   * @default true\n   */\n  enforceHTTPS?: boolean;\n\n  /**\n   * How to handle www prefix normalization for apex domains only\n   * - \"remove\": Strip www prefix (www.example.com → example.com)\n   * - \"add\": Add www prefix (example.com → www.example.com)\n   * - \"preserve\": Don't modify www, only validate canonical domain matches\n   * Note: Only applies to apex domains, not subdomains (api.example.com stays unchanged)\n   * @default \"preserve\"\n   */\n  wwwHandling?: 'remove' | 'add' | 'preserve';\n\n  /**\n   * HTTP status code to use for redirects\n   * @default 301 (permanent redirect)\n   */\n  redirectStatusCode?: 301 | 302 | 307 | 308;\n\n  /**\n   * Whether to preserve port numbers in canonical domain redirects\n   * - true: example.com:3000 → canonical.com:3000\n   * - false: example.com:3000 → canonical.com (strip port)\n   * @default false\n   */\n  preservePort?: boolean;\n\n  /**\n   * Whether to skip all checks in development mode\n   * @default true\n   */\n  skipInDevelopment?: boolean;\n\n  /**\n   * Whether to trust proxy headers (x-forwarded-host/proto) when determining\n   * the original host and protocol. Only enable this when running behind a\n   * trusted proxy/load balancer that sets these headers.\n   * @default false\n   */\n  trustProxyHeaders?: boolean;\n\n  /**\n   * Optional custom handler for invalid domain responses\n   * If not provided, returns a default 403 plain text or JSON error response\n   * based on if detected as an API endpoint\n   */\n  invalidDomainHandler?: (\n    request: FastifyRequest,\n    domain: string,\n    isDevelopment: boolean,\n    isAPI: boolean,\n  ) => InvalidDomainResponse;\n}\n\n/**\n * Helper function to determine if a request URL is for an API endpoint.\n * Uses the same classifyRequest logic as the servers for consistency.\n */\nfunction checkIfAPIEndpoint(url: string, options: PluginOptions): boolean {\n  // Normalize the API prefix (handles null/undefined/empty → default, false → false)\n  const apiPrefix = normalizeAPIPrefix(options.apiEndpoints?.apiEndpointPrefix);\n\n  // If API is disabled (prefix is false), nothing is an API endpoint\n  if (apiPrefix === false) {\n    return false;\n  }\n\n  // Normalize the page data endpoint (for completeness, though we only need isAPI here)\n  const pageDataEndpoint = normalizePageDataEndpoint(\n    options.apiEndpoints?.pageDataEndpoint,\n  );\n\n  // Use the shared classifier - it handles all cases including \"/\" prefix\n  // and strips query strings internally\n  const { isAPI } = classifyRequest(url, apiPrefix, pageDataEndpoint);\n  return isAPI;\n}\n\n/**\n * Helper function to safely extract protocol from headers\n */\nfunction getProtocol(\n  request: FastifyRequest,\n  shouldTrustProxyHeaders: boolean,\n): string {\n  if (shouldTrustProxyHeaders) {\n    const forwardedProto = request.headers['x-forwarded-proto'];\n\n    if (forwardedProto) {\n      // Handle comma-separated list, take first value\n      const proto = Array.isArray(forwardedProto)\n        ? forwardedProto[0]\n        : forwardedProto.split(',')[0].trim();\n\n      return proto.toLowerCase();\n    }\n  }\n\n  // Fallback to request.protocol (accurate when Fastify trustProxy is enabled)\n  return (request.protocol || 'http').toLowerCase();\n}\n\n/**\n * Helper function to safely extract host from headers (proxy-aware)\n */\nfunction getHost(\n  request: FastifyRequest,\n  shouldTrustProxyHeaders: boolean,\n): string {\n  // Prefer x-forwarded-host only when explicitly trusted\n  if (shouldTrustProxyHeaders) {\n    const forwardedHost = request.headers['x-forwarded-host'];\n\n    if (forwardedHost) {\n      // Handle comma-separated list, take first value\n      const host = Array.isArray(forwardedHost)\n        ? forwardedHost[0]\n        : forwardedHost.split(',')[0].trim();\n      return host;\n    }\n  }\n\n  // Fallback to standard host header\n  return request.headers.host || '';\n}\n\n/**\n * Domain security plugin that handles:\n * - Domain validation and canonical domain redirects\n * - HTTPS enforcement (HTTP to HTTPS redirects)\n * - WWW prefix normalization (add or remove www)\n *\n * This plugin is a no-op in development mode by default.\n */\nexport function domainValidation(config: DomainValidationConfig): ServerPlugin {\n  return async (pluginHost: PluginHostInstance, options: PluginOptions) => {\n    // Early config validation for validProductionDomains using centralized validator\n    if (\n      config.validProductionDomains &&\n      typeof config.validProductionDomains !== 'function'\n    ) {\n      const entries = Array.isArray(config.validProductionDomains)\n        ? config.validProductionDomains\n        : [config.validProductionDomains];\n\n      for (const entry of entries) {\n        const verdict = validateConfigEntry(entry, 'domain');\n\n        if (!verdict.valid) {\n          throw new Error(\n            `Invalid domainValidation validProductionDomains entry \"${entry}\"${verdict.info ? ': ' + verdict.info : ''}`,\n          );\n        }\n      }\n    }\n\n    // Register onRequest hook for domain security checks\n    pluginHost.addHook('onRequest', async (request, reply) => {\n      // Normalize config defaults\n      const shouldSkipInDev = config.skipInDevelopment ?? true;\n      const shouldEnforceHTTPS = config.enforceHTTPS ?? true;\n\n      if (options.isDevelopment && shouldSkipInDev) {\n        return; // Skip in development mode, continue to next handler\n      }\n\n      const isAPIEndpoint = checkIfAPIEndpoint(request.url, options);\n      const shouldTrustProxyHeaders = !!config.trustProxyHeaders;\n\n      const host = getHost(request, shouldTrustProxyHeaders);\n      const parsed = parseHostHeader(host);\n      const originalDomain = parsed.domain; // Keep original for error messages\n      const domain = normalizeDomain(originalDomain);\n      const port = parsed.port;\n      const protocol = getProtocol(request, shouldTrustProxyHeaders);\n\n      // Reject requests with a missing or unparseable Host header before any\n      // redirect logic runs — an empty domain would otherwise produce a\n      // malformed redirect URL (e.g. \"https:///path\").\n      if (!domain) {\n        if (isAPIEndpoint) {\n          reply\n            .code(400)\n            .header('Cache-Control', 'no-store')\n            .type('application/json')\n            .send({\n              error: 'bad_request',\n              message: 'Missing or invalid Host header',\n            });\n        } else {\n          reply\n            .code(400)\n            .header('Cache-Control', 'no-store')\n            .type('text/plain')\n            .send('Bad Request: Missing or invalid Host header');\n        }\n\n        return;\n      }\n\n      // Skip all validation and redirects for localhost (including IPv4/IPv6)\n      if (\n        domain === 'localhost' ||\n        domain === '127.0.0.1' ||\n        domain === '::1'\n      ) {\n        return;\n      }\n\n      // Domain validation check (only if validProductionDomains is configured)\n      if (config.validProductionDomains) {\n        let isAllowedDomain: boolean;\n\n        if (typeof config.validProductionDomains === 'function') {\n          // Let callers make request-aware validation decisions, matching the\n          // function-based CORS API style.\n          isAllowedDomain = await config.validProductionDomains(\n            domain,\n            request,\n          );\n        } else {\n          // Normalize validProductionDomains to array\n          const validDomains = Array.isArray(config.validProductionDomains)\n            ? config.validProductionDomains\n            : [config.validProductionDomains];\n\n          // Validate domain using secure check\n          isAllowedDomain = matchesDomainList(domain, validDomains);\n        }\n\n        if (!isAllowedDomain) {\n          // Use custom handler if provided, otherwise use default response\n          const response = config.invalidDomainHandler\n            ? config.invalidDomainHandler(\n                request,\n                originalDomain, // Pass original domain for human-friendly messages\n                options.isDevelopment,\n                isAPIEndpoint,\n              )\n            : isAPIEndpoint\n              ? {\n                  contentType: 'json' as const,\n                  content: {\n                    error: 'invalid_domain',\n                    message: `Domain \"${originalDomain}\" is not authorized to access this server`,\n                  },\n                }\n              : {\n                  contentType: 'text' as const,\n                  content: `Access denied: Domain \"${originalDomain}\" is not authorized`,\n                };\n\n          // Set appropriate content type and send response (do not cache)\n          if (response.contentType === 'json') {\n            reply\n              .code(403)\n              .header('Cache-Control', 'no-store')\n              .type('application/json')\n              .send(response.content);\n          } else if (response.contentType === 'html') {\n            reply\n              .code(403)\n              .header('Cache-Control', 'no-store')\n              .type('text/html')\n              .send(response.content);\n          } else if (response.contentType === 'text') {\n            reply\n              .code(403)\n              .header('Cache-Control', 'no-store')\n              .type('text/plain')\n              .send(response.content);\n          }\n          return;\n        }\n      }\n\n      // Single redirect logic - construct final target URL once\n      let shouldRedirect = false;\n      let finalProtocol = protocol;\n      // Build redirect host from normalized domain by default (avoid reflecting raw headers)\n      let finalHost = domain; // For URL construction (may add port below)\n      let finalDomain = domain; // For logic decisions (never includes port)\n      let hasProtocolChanged = false;\n      // Track a port part to append at assembly time (avoid mixing IPv6 colons)\n      let finalPortPart = '';\n\n      // Note: We maintain both finalHost and finalDomain separately because:\n      // - finalHost: Used for final URL construction, may include port\n      // - finalDomain: Used for logic decisions (apex detection), never has port\n      // Memory is cheap compared to CPU - avoiding repeated string splitting/parsing\n\n      // 1. Check if we need canonical domain redirect\n      const normalizedCanonical = config.canonicalDomain\n        ? normalizeDomain(config.canonicalDomain)\n        : undefined;\n\n      if (normalizedCanonical && domain !== normalizedCanonical) {\n        finalDomain = normalizedCanonical;\n        finalHost = normalizedCanonical;\n        shouldRedirect = true;\n      }\n\n      // 2. Apply HTTPS enforcement\n      if (shouldEnforceHTTPS && protocol === 'http') {\n        finalProtocol = 'https';\n        hasProtocolChanged = true;\n        shouldRedirect = true;\n      }\n\n      // 3. Apply WWW handling (only for apex domains)\n      const wwwMode = config.wwwHandling || 'preserve';\n\n      if (wwwMode !== 'preserve' && isApexDomain(finalDomain)) {\n        const hasWww = finalHost.startsWith('www.');\n        if (wwwMode === 'add' && !hasWww) {\n          finalHost = `www.${finalHost}`;\n          finalDomain = `www.${finalDomain}`; // keep in sync\n          shouldRedirect = true;\n        } else if (wwwMode === 'remove' && hasWww) {\n          finalHost = finalHost.substring(4);\n          finalDomain = finalDomain.substring(4); // keep in sync\n          shouldRedirect = true;\n        }\n      }\n\n      // 4. Handle port preservation/stripping\n      if (shouldRedirect) {\n        // Always strip port if protocol changed (HTTP->HTTPS)\n        // Otherwise, only preserve port if explicitly configured\n        const shouldPreservePort =\n          !hasProtocolChanged && config.preservePort && port;\n\n        finalPortPart = shouldPreservePort ? `:${port}` : '';\n      }\n\n      // Perform single redirect if needed\n      if (shouldRedirect) {\n        // Bracket IPv6 literals in the host component; append preserved port if any\n        let hostForURL = finalHost;\n\n        if (hostForURL.includes(':') && !hostForURL.startsWith('[')) {\n          hostForURL = `[${hostForURL}]`;\n        }\n\n        const redirectURL = `${finalProtocol}://${hostForURL}${finalPortPart}${request.url}`;\n        const statusCode = config.redirectStatusCode || 301;\n\n        reply.code(statusCode).redirect(redirectURL);\n        return;\n      }\n\n      // Continue to next handler - no redirects needed\n      return;\n    });\n\n    return Promise.resolve();\n  };\n}\n","export const TAB_SPACES = ' '.repeat(4);\n\n/**\n * Default API endpoint prefix (e.g., \"/api\")\n * Used when apiEndpoints.apiEndpointPrefix is not configured\n */\nexport const DEFAULT_API_PREFIX = '/api';\n\n/**\n * Default page data endpoint name (e.g., \"page_data\")\n * Used when apiEndpoints.pageDataEndpoint is not configured\n */\nexport const DEFAULT_PAGE_DATA_ENDPOINT = 'page_data';\n","import type {\n  FastifyInstance,\n  FastifyPluginAsync,\n  FastifyPluginCallback,\n  FastifyRequest,\n  FastifyReply,\n  RouteHandler,\n} from 'fastify';\nimport type {\n  PluginMetadata,\n  PluginHostInstance,\n  FastifyHookName,\n  SafeRouteOptions,\n  ControlledReply,\n  APIResponseHelpersClass,\n  HTTPSOptions,\n  WebResponse,\n  APIClosingHandlerFn,\n  WebClosingHandlerFn,\n  SplitClosingHandler,\n} from '../types';\nimport type { BaseMeta } from '../api-envelope/api-envelope-types';\nimport type { CookieSerializeOptions } from '@fastify/cookie';\nimport { DEFAULT_API_PREFIX, DEFAULT_PAGE_DATA_ENDPOINT } from './consts';\nimport { generateDefault503ClosingPage } from './error-page-utils';\nimport { parseHostHeader, getDomain } from 'lifecycleion/domain-utils';\nimport { sendRawErrorEnvelopeResponse } from './error-envelope-send';\nimport type { DomainInfo } from './domain-info';\n\n/**\n * Normalize an API prefix to ensure it has a leading slash and no trailing slash.\n *\n * Handles: \"api\", \"/api\", \"/api/\", \"api/\", \"//api//\" → \"/api\"\n *\n * Special handling:\n * - `false` returns `false` (API disabled)\n * - `null`, `undefined`, or empty/whitespace-only string returns the default prefix\n *\n * @param prefix - The prefix to normalize, or false to disable API handling\n * @param defaultPrefix - Default prefix to use when input is null/undefined/empty (defaults to DEFAULT_API_PREFIX)\n * @returns Normalized prefix string, or false if API is disabled\n */\n\nexport function normalizeAPIPrefix(\n  prefix: string | false | null | undefined,\n  defaultPrefix: string = DEFAULT_API_PREFIX,\n): string | false {\n  // Explicit false means API is disabled\n  if (prefix === false) {\n    return false;\n  }\n\n  // null, undefined, or empty/whitespace-only string → use default\n  const trimmed = (prefix ?? '').trim();\n  let normalized = trimmed.length === 0 ? defaultPrefix : trimmed;\n\n  // Add leading slash if missing\n  if (!normalized.startsWith('/')) {\n    normalized = '/' + normalized;\n  }\n\n  // Collapse multiple consecutive slashes to a single slash\n  normalized = normalized.replace(/\\/+/g, '/');\n\n  // Remove trailing slash if present (but keep root \"/\" as-is)\n  if (normalized.endsWith('/') && normalized.length > 1) {\n    normalized = normalized.slice(0, -1);\n  }\n\n  return normalized;\n}\n\n/**\n * Normalize a page data endpoint name to have no leading or trailing slashes.\n *\n * Handles: \"/page_data\", \"page_data/\", \"/page_data/\" → \"page_data\"\n *\n * Special handling:\n * - `null`, `undefined`, or empty/whitespace-only string returns the default endpoint\n *\n * @param endpoint - The endpoint name to normalize\n * @param defaultEndpoint - Default endpoint to use when input is null/undefined/empty (defaults to DEFAULT_PAGE_DATA_ENDPOINT)\n * @returns Normalized endpoint string (never false, page data is always needed)\n */\nexport function normalizePageDataEndpoint(\n  endpoint: string | null | undefined,\n  defaultEndpoint: string = DEFAULT_PAGE_DATA_ENDPOINT,\n): string {\n  // null, undefined, or empty/whitespace-only string → use default\n  const trimmed = (endpoint ?? '').trim();\n  let normalized = trimmed.length === 0 ? defaultEndpoint : trimmed;\n\n  // Collapse multiple consecutive slashes to a single slash\n  normalized = normalized.replace(/\\/+/g, '/');\n\n  // Remove leading slash if present\n  if (normalized.startsWith('/')) {\n    normalized = normalized.slice(1);\n  }\n\n  // Remove trailing slash if present\n  if (normalized.endsWith('/')) {\n    normalized = normalized.slice(0, -1);\n  }\n\n  return normalized;\n}\n\n/**\n * Result of classifying a request path for API/page-data handling\n */\nexport interface RequestClassification {\n  /** True if path starts with the API prefix (e.g., /api/...) */\n  isAPI: boolean;\n  /** True if path is a page-data endpoint (e.g., /api/v1/page_data/home) */\n  isPageData: boolean;\n}\n\n/**\n * Classify a request URL to determine if it's an API request and/or a page-data request.\n *\n * Page data endpoints are always registered under the API prefix, so isPageData will\n * only be true when isAPI is also true.\n *\n * @param url - Request URL (may include query string, which will be stripped internally)\n * @param apiPrefix - The API prefix to match against (e.g., \"/api\"), or false if API is disabled\n * @param pageDataEndpoint - The page data endpoint name (e.g., \"page_data\")\n * @returns Object with isAPI and isPageData booleans\n *\n * @example\n * classifyRequest('/api/v1/page_data/home', '/api', 'page_data')\n * // => { isAPI: true, isPageData: true }\n *\n * classifyRequest('/api/users?id=123', '/api', 'page_data')\n * // => { isAPI: true, isPageData: false }\n *\n * classifyRequest('/about', '/api', 'page_data')\n * // => { isAPI: false, isPageData: false }\n *\n * classifyRequest('/api/users', false, 'page_data')\n * // => { isAPI: false, isPageData: false } (API disabled)\n */\nexport function classifyRequest(\n  url: string,\n  apiPrefix: string | false,\n  pageDataEndpoint: string,\n): RequestClassification {\n  // IMPORTANT: apiPrefix should be pre-normalized (e.g., \"/api\" with leading slash, no trailing)\n  // or false if API is disabled\n\n  // IMPORTANT: pageDataEndpoint should be pre-normalized (e.g., \"page_data\" with no slashes)\n  // Callers are responsible for normalizing these values once at startup\n\n  // Extract pathname (strip query string if present)\n  const rawPath = url.split('?')[0];\n\n  // If API is disabled (prefix is false), nothing is an API request\n  if (apiPrefix === false) {\n    return { isAPI: false, isPageData: false };\n  }\n\n  // Check if this is an API request (path starts with prefix)\n  // Special case: \"/\" prefix means ALL paths are API paths\n  const isRootPrefix = apiPrefix === '/';\n  const isAPI = isRootPrefix\n    ? rawPath.startsWith('/')\n    : !!apiPrefix &&\n      (rawPath.startsWith(apiPrefix + '/') || rawPath === apiPrefix);\n\n  // Page data is always under API prefix, so if not API, can't be page data\n  if (!isAPI) {\n    return { isAPI: false, isPageData: false };\n  }\n\n  // Strip API prefix and check for page data endpoint pattern\n  // Matches: /{pageDataEndpoint} or /v{n}/{pageDataEndpoint}\n  // For root prefix, we don't strip anything (pathAfterPrefix starts with /)\n  const pathAfterPrefix = isRootPrefix\n    ? rawPath\n    : rawPath.slice(apiPrefix.length);\n\n  // Page data path pattern: /{pageDataEndpoint} (e.g., \"/page_data\")\n  const pageDataPath = '/' + pageDataEndpoint;\n\n  // Check direct match: /{pageDataEndpoint} or /{pageDataEndpoint}/...\n  let isPageData =\n    pathAfterPrefix === pageDataPath ||\n    pathAfterPrefix.startsWith(pageDataPath + '/');\n\n  // If not matched, check versioned pattern: /v{digits}/{pageDataEndpoint}...\n  if (!isPageData && pathAfterPrefix.startsWith('/v')) {\n    // Scan for digits after /v (e.g., /v1 → i=3, /v100 → i=5)\n    // Using manual charCodeAt parsing instead of regex for better performance\n    // since this runs on every request in hot path\n    let i = 2; // start after '/v'\n\n    while (\n      i < pathAfterPrefix.length &&\n      pathAfterPrefix.charCodeAt(i) >= 48 && // '0'\n      pathAfterPrefix.charCodeAt(i) <= 57 // '9'\n    ) {\n      i++;\n    }\n\n    // Valid version needs at least one digit (/v1, /v100 — not just /v)\n    if (i > 2) {\n      const pathAfterVersion = pathAfterPrefix.slice(i);\n      isPageData =\n        pathAfterVersion === pageDataPath ||\n        pathAfterVersion.startsWith(pageDataPath + '/');\n    }\n  }\n\n  return { isAPI, isPageData };\n}\n\n/**\n * Creates a default JSON error response using the envelope pattern.\n * Used by both APIServer and SSRServer for consistent error handling.\n * @param request - The Fastify request object\n * @param error - The error that occurred\n * @param isDevelopment - Whether running in development mode\n * @param apiPrefix - API prefix for request classification (e.g., \"/api\"), or false if API is disabled\n * @param pageDataEndpoint - Page data endpoint name (e.g., \"page_data\")\n * @returns JSON error response object\n */\n\nexport function createDefaultAPIErrorResponse(\n  HelpersClass: APIResponseHelpersClass,\n  request: FastifyRequest,\n  error: Error,\n  isDevelopment: boolean,\n  apiPrefix: string | false,\n  pageDataEndpoint: string,\n): unknown {\n  const { isPageData } = classifyRequest(\n    request.url,\n    apiPrefix,\n    pageDataEndpoint,\n  );\n\n  const statusCode =\n    (error as Error & { statusCode?: number }).statusCode || 500;\n  const errorCode =\n    statusCode === 500 ? 'internal_server_error' : 'request_error';\n  const errorMessage = isDevelopment ? error.message : 'Internal Server Error';\n  const errorDetails = isDevelopment ? { stack: error.stack } : undefined;\n\n  if (isPageData) {\n    return HelpersClass.createPageErrorResponse({\n      request,\n      statusCode,\n      errorCode,\n      errorMessage,\n      errorDetails,\n      pageMetadata: {\n        title: 'Error',\n        description: 'An error occurred while processing your request',\n      },\n    });\n  }\n\n  return HelpersClass.createAPIErrorResponse({\n    request,\n    statusCode,\n    errorCode,\n    errorMessage,\n    errorDetails,\n  });\n}\n\n/**\n * Creates a default JSON 404 not-found response using the envelope pattern.\n * Used by both APIServer and SSRServer for consistent 404 handling.\n * @param request - The Fastify request object\n * @param apiPrefix - API prefix for request classification (e.g., \"/api\"), or false if API is disabled\n * @param pageDataEndpoint - Page data endpoint name (e.g., \"page_data\")\n * @returns JSON 404 response object\n */\nexport function createDefaultAPINotFoundResponse(\n  HelpersClass: APIResponseHelpersClass,\n  request: FastifyRequest,\n  apiPrefix: string | false,\n  pageDataEndpoint: string,\n): unknown {\n  const { isPageData } = classifyRequest(\n    request.url,\n    apiPrefix,\n    pageDataEndpoint,\n  );\n\n  const statusCode = 404;\n\n  if (isPageData) {\n    return HelpersClass.createPageErrorResponse({\n      request,\n      statusCode,\n      errorCode: 'not_found',\n      errorMessage: 'Page Not Found',\n      pageMetadata: {\n        title: 'Not Found',\n        description: 'The requested page could not be found',\n      },\n    });\n  }\n\n  return HelpersClass.createAPIErrorResponse({\n    request,\n    statusCode,\n    errorCode: 'not_found',\n    errorMessage: 'Resource Not Found',\n  });\n}\n\n/**\n * Creates a default JSON 503 shutdown response using the envelope pattern.\n * Used by both APIServer and SSRServer for requests that arrive while closing.\n * @param request - The Fastify request object\n * @param isPageData - Whether the request targets the page-data endpoint\n * @returns JSON 503 response object\n */\nexport function createDefaultAPIClosingResponse(\n  HelpersClass: APIResponseHelpersClass,\n  request: FastifyRequest,\n  isPageData: boolean,\n): unknown {\n  const statusCode = 503;\n  const errorCode = 'service_unavailable';\n  const errorMessage = 'Server is shutting down';\n\n  if (isPageData) {\n    return HelpersClass.createPageErrorResponse({\n      request,\n      statusCode,\n      errorCode,\n      errorMessage,\n      pageMetadata: {\n        title: 'Service Unavailable',\n        description: 'The server is shutting down. Please try again shortly.',\n      },\n    });\n  }\n\n  return HelpersClass.createAPIErrorResponse({\n    request,\n    statusCode,\n    errorCode,\n    errorMessage,\n  });\n}\n\n/**\n * Creates the default web 503 shutdown response.\n * Used by API, SSR, static, and redirect servers for web requests while closing.\n */\nexport function createDefaultWebClosingResponse(): WebResponse {\n  return {\n    contentType: 'html',\n    content: generateDefault503ClosingPage(),\n    statusCode: 503,\n  };\n}\n\ntype ClosingFunctionHandlerType = 'api' | 'web';\n\ntype ClosingHandler<M extends BaseMeta = BaseMeta> =\n  | APIClosingHandlerFn<M>\n  | WebClosingHandlerFn\n  | SplitClosingHandler<M>;\n\ninterface ClosingResponseConfig<M extends BaseMeta = BaseMeta> {\n  handler?: ClosingHandler<M>;\n  functionHandlerType: ClosingFunctionHandlerType;\n  serverLabel: string;\n  HelpersClass: APIResponseHelpersClass;\n  apiPrefix: string | false;\n  pageDataEndpoint: string;\n}\n\ninterface ClosingResponseContext<\n  M extends BaseMeta = BaseMeta,\n> extends ClosingResponseConfig<M> {\n  request: FastifyRequest;\n  reply: FastifyReply;\n}\n\n/**\n * Resolves the payload sent by registerClosingResponseHook when the server is\n * stopping. The resolver sets status/cache/content headers on the reply and\n * returns the body that the hook will pass to sendClosingPayload().\n */\nexport async function resolveClosingResponse<M extends BaseMeta = BaseMeta>({\n  request,\n  reply,\n  handler,\n  functionHandlerType,\n  serverLabel,\n  HelpersClass,\n  apiPrefix,\n  pageDataEndpoint,\n}: ClosingResponseContext<M>): Promise<unknown> {\n  // Closing responses need the same API/page-data classification as normal\n  // errors so defaults and split handlers return the expected response shape.\n  const { isAPI, isPageData } = classifyRequest(\n    request.url,\n    apiPrefix,\n    pageDataEndpoint,\n  );\n\n  if (handler) {\n    try {\n      if (isSplitHandler<Partial<SplitClosingHandler<M>>>(handler)) {\n        // Split form lets mixed API + web servers customize each handler\n        // independently. Missing handlers fall through to Unirend defaults.\n        if (isAPI && handler.api) {\n          const apiResponse = await Promise.resolve(\n            handler.api(request, isPageData),\n          );\n\n          const statusCode = apiResponse.status_code || 503;\n          reply.code(statusCode).header('Cache-Control', 'no-store');\n          return apiResponse;\n        }\n\n        if (!isAPI && handler.web) {\n          const webResponse = await Promise.resolve(handler.web(request));\n\n          return prepareWebResponse(reply, webResponse, 503);\n        }\n      } else if (functionHandlerType === 'api' && isAPI) {\n        // Function form follows the server's primary response type. APIServer\n        // uses API envelopes, while non-API web requests fall through to the\n        // default web response unless split form provides a web handler.\n        const apiHandler = handler as APIClosingHandlerFn<M>;\n        const apiResponse = await Promise.resolve(\n          apiHandler(request, isPageData),\n        );\n\n        const statusCode = apiResponse.status_code || 503;\n        reply.code(statusCode).header('Cache-Control', 'no-store');\n        return apiResponse;\n      } else if (functionHandlerType === 'web' && !isAPI) {\n        // SSR/static/redirect servers use web responses for function form.\n        // API/page-data requests fall through to the default API envelope unless\n        // split form provides an API handler.\n        const webHandler = handler as WebClosingHandlerFn;\n        const webResponse = await Promise.resolve(webHandler(request));\n\n        return prepareWebResponse(reply, webResponse, 503);\n      }\n    } catch (handlerError) {\n      request.log.error(\n        { err: handlerError, method: request.method, url: request.url },\n        `[${serverLabel}] Custom closing handler failed`,\n      );\n    }\n  }\n\n  // No custom handler matched, or the matched handler failed. API and page-data\n  // requests fall back to the standard error envelope so clients see the same\n  // shape as other API failures.\n  if (isAPI && apiPrefix) {\n    const response = createDefaultAPIClosingResponse(\n      HelpersClass,\n      request,\n      isPageData,\n    );\n\n    const statusCode =\n      (response as { status_code?: number }).status_code || 503;\n\n    reply.code(statusCode).header('Cache-Control', 'no-store');\n\n    return response;\n  }\n\n  // Web requests fall back to the built-in HTML 503 page. This also covers\n  // servers with API handling disabled because classifyRequest reports them as\n  // non-API requests.\n  return prepareWebResponse(reply, createDefaultWebClosingResponse(), 503);\n}\n\nexport function sendClosingPayload(\n  reply: FastifyReply,\n  payload: unknown,\n): FastifyReply {\n  if (\n    payload !== null &&\n    typeof payload === 'object' &&\n    !Buffer.isBuffer(payload)\n  ) {\n    return reply.type('application/json').send(JSON.stringify(payload));\n  }\n\n  return reply.send(payload);\n}\n\nexport function registerClosingResponseHook(\n  fastify: FastifyInstance,\n  isStopping: () => boolean,\n  responseConfig: ClosingResponseConfig,\n): void {\n  fastify.addHook('onRequest', (request, reply, done) => {\n    if (!isStopping()) {\n      done();\n      return;\n    }\n\n    Promise.resolve(\n      resolveClosingResponse({ ...responseConfig, request, reply }),\n    )\n      .then((payload) => {\n        sendClosingPayload(reply, payload);\n      })\n      .catch(done);\n  });\n}\n\n/**\n * Check if a handler is the split form (object with api and/or web).\n * Either handler can be optional - missing handlers fall through to defaults.\n */\nexport function isSplitHandler<T extends { api?: unknown; web?: unknown }>(\n  handler: unknown,\n): handler is T {\n  if (handler === null || typeof handler !== 'object') {\n    return false;\n  }\n\n  // It's split form if it has at least one of api/web as a function\n  const obj = handler as Record<string, unknown>;\n  const hasAPIHandler = 'api' in obj && typeof obj.api === 'function';\n  const hasWebHandler = 'web' in obj && typeof obj.web === 'function';\n\n  return hasAPIHandler || hasWebHandler;\n}\n\nexport function prepareWebResponse(\n  reply: FastifyReply,\n  response: WebResponse,\n  defaultStatusCode: number,\n): unknown {\n  const statusCode = response.statusCode ?? defaultStatusCode;\n  reply.code(statusCode).header('Cache-Control', 'no-store');\n\n  // Set Content-Type but do NOT call reply.send() here.\n  // The callers returns the content so wrapThenable makes exactly one reply.send() call.\n  if (response.contentType === 'json') {\n    reply.type('application/json');\n  } else if (response.contentType === 'html') {\n    reply.type('text/html');\n  } else {\n    reply.type('text/plain');\n  }\n\n  return response.content;\n}\n\nconst DEFERRED_REPLY_ACTION_SENTINEL = Symbol('unirend.deferred-reply-action');\n\n/**\n * Wrap a route handler to throw a helpful error if reply.send() is called.\n *\n * Async route handlers must return the payload directly instead of calling\n * reply.send(). In Fastify 5, returning a value from an async handler causes\n * wrapThenable to call reply.send(payload) exactly once. If the handler also\n * calls reply.send() manually, wrapThenable fires a second send while the\n * async onSend pipeline is still pending — causing an ERR_HTTP_HEADERS_SENT\n * crash or silent response corruption.\n *\n * Correct pattern:\n *   reply.code(201).header('X-Foo', 'bar');\n *   return { your: 'data' };  // ✓\n *\n * Forbidden pattern:\n *   return reply.send({ your: 'data' });  // ✗ — double-send race\n *\n * Special case:\n *   return reply.redirect('/login');  // ✓ — redirect is normalized to headers\n *   and status only so Fastify still performs the single final send itself\n *\n *   return reply.callNotFound();  // ✓ — delegates the remainder of the request\n *   to Fastify's not-found pipeline, which owns the final send\n */\nfunction guardRouteHandler(handler: RouteHandler): RouteHandler {\n  return async function guardedHandler(\n    request: FastifyRequest,\n    reply: FastifyReply,\n  ): Promise<unknown> {\n    // Temporarily replace reply.send so any call from inside the handler body\n    // throws immediately with a helpful message. We restore it in `finally` so\n    // that wrapThenable can still call reply.send(returnValue) after the handler\n    // resolves — that single wrapThenable-driven send is the correct path.\n    const originalSend = (\n      reply as unknown as { send: (...args: unknown[]) => unknown }\n    ).send.bind(reply);\n    const originalRedirect = reply.redirect.bind(reply);\n    const originalCallNotFound = reply.callNotFound.bind(reply);\n    let deferredActionKind: 'redirect' | 'callNotFound' | null = null;\n    let deferredRedirectURL: string | undefined;\n    let deferredRedirectCode: number | undefined;\n    let handlerResult: unknown;\n\n    (reply as unknown as { send: unknown }).send = function (\n      // eslint-disable-next-line @typescript-eslint/no-unused-vars\n      ...args: unknown[]\n    ) {\n      throw new Error(\n        'Do not call reply.send() inside a unirend plugin route handler.\\n' +\n          'Set status and headers with reply.code() / reply.header(), then return the payload:\\n' +\n          '  ✓  reply.code(201); return { ok: true };\\n' +\n          '  ✗  return reply.send({ ok: true });  // causes double-send race in Fastify 5\\n\\n' +\n          'reply.send() is only safe inside Fastify lifecycle hooks (addHook), not in route handlers.',\n      );\n    };\n\n    reply.redirect = ((url: string, code?: number) => {\n      // Record the redirect intent but defer the real Fastify redirect call\n      // until after this wrapper restores the original reply methods.\n      deferredActionKind = 'redirect';\n      deferredRedirectURL = url;\n      deferredRedirectCode = code;\n      return DEFERRED_REPLY_ACTION_SENTINEL as unknown as FastifyReply;\n    }) as typeof reply.redirect;\n\n    reply.callNotFound = (() => {\n      // Record the delegation intent but defer the real Fastify helper until\n      // after this wrapper restores the original reply methods.\n      deferredActionKind = 'callNotFound';\n      return DEFERRED_REPLY_ACTION_SENTINEL as unknown as FastifyReply;\n    }) as typeof reply.callNotFound;\n\n    try {\n      handlerResult = await (\n        handler as (\n          this: unknown,\n          req: FastifyRequest,\n          reply: FastifyReply,\n        ) => unknown\n      ).call(this, request, reply);\n    } finally {\n      // Restore so wrapThenable's reply.send(returnedPayload) works normally.\n      (reply as unknown as { send: unknown }).send = originalSend;\n      reply.redirect = originalRedirect;\n      reply.callNotFound = originalCallNotFound;\n    }\n\n    const actionKind = deferredActionKind;\n\n    if (actionKind) {\n      if (handlerResult !== DEFERRED_REPLY_ACTION_SENTINEL) {\n        const delegatedHelper =\n          actionKind === 'redirect'\n            ? 'reply.redirect()'\n            : 'reply.callNotFound()';\n\n        throw new Error(\n          `When using ${delegatedHelper} inside a unirend plugin route handler, return it immediately.\\n` +\n            'Do not continue execution or return a payload after delegating the response.',\n        );\n      }\n\n      switch (actionKind) {\n        case 'redirect':\n          return originalRedirect(\n            deferredRedirectURL as string,\n            deferredRedirectCode,\n          );\n        case 'callNotFound':\n          return originalCallNotFound();\n      }\n    }\n\n    return handlerResult;\n  };\n}\n\n/**\n * Creates a controlled wrapper around the Fastify instance\n * This prevents plugins from accessing dangerous methods\n * @param fastifyInstance The real Fastify instance\n * @param shouldDisableRootWildcard Whether to disable root wildcard routes (e.g., \"*\" or \"/*\")\n * @returns Controlled interface for plugins\n */\n\nexport function createControlledInstance(\n  fastifyInstance: FastifyInstance,\n  shouldDisableRootWildcard: boolean,\n  apiShortcuts: unknown,\n  pageDataHandlerShortcuts: unknown,\n  apiResponseHelpersClass: APIResponseHelpersClass,\n): PluginHostInstance {\n  const earlyResponseHooks = new Set<FastifyHookName>([\n    'onRequest',\n    'preValidation',\n    'preHandler',\n  ]);\n\n  return {\n    register: <Options extends Record<string, unknown> = Record<string, never>>(\n      plugin: FastifyPluginAsync<Options> | FastifyPluginCallback<Options>,\n      opts?: Options,\n    ) => {\n      // Note: Fastify's register method has complex overloads that don't align perfectly\n      // with our simplified generic constraints. These casts are necessary for compatibility.\n      return fastifyInstance.register(\n        plugin as Parameters<typeof fastifyInstance.register>[0],\n        opts as Parameters<typeof fastifyInstance.register>[1],\n      ) as unknown as Promise<void>;\n    },\n    addHook: (\n      hookName: FastifyHookName,\n      handler: (\n        request: FastifyRequest,\n        reply: FastifyReply,\n        ...args: unknown[]\n      ) => unknown,\n    ) => {\n      // Prevent plugins from overriding critical hooks\n      if (hookName === 'onRoute' || hookName.includes('*')) {\n        throw new Error(\n          'Plugins cannot register catch-all route hooks that would conflict with SSR',\n        );\n      }\n      // Fastify has two incompatible hook completion styles:\n      // - async/promise hooks finish when the promise resolves\n      // - callback hooks finish when done() is called\n      //\n      // We wrap plugin hooks so plain sync handlers don't hang. For early\n      // request hooks, use callback-style wrapping because these hooks may\n      // intentionally terminate the request with reply.send()/reply.redirect().\n      // Calling done() after that would continue the lifecycle and can trigger\n      // double-send/header-sent errors, so the wrapper only calls done() when\n      // the hook did not already send a response.\n      const wrappedHandler = earlyResponseHooks.has(hookName)\n        ? (\n            request: FastifyRequest,\n            reply: FastifyReply,\n            done: (error?: Error) => void,\n          ) => {\n            // Fastify's sent/header flags can lag behind an in-progress\n            // reply.send()/reply.redirect() on the live server, so track calls\n            // made inside the hook body directly.\n            const replyWithSend = reply as unknown as {\n              send: (...args: unknown[]) => unknown;\n            };\n            const originalSend = replyWithSend.send;\n            let didSend = false;\n\n            replyWithSend.send = function (\n              this: FastifyReply,\n              ...args: unknown[]\n            ) {\n              didSend = true;\n              return originalSend.apply(this, args);\n            };\n\n            const restoreSend = () => {\n              replyWithSend.send = originalSend;\n            };\n\n            const didAlreadySend = () =>\n              didSend || reply.sent || reply.raw.headersSent;\n\n            try {\n              const result = handler(request, reply);\n\n              // Async early hooks are allowed. Wait for them and then advance\n              // only if they did not send the response while awaiting.\n              if (\n                result &&\n                typeof (result as Promise<unknown>).then === 'function'\n              ) {\n                void (result as Promise<unknown>).then(\n                  () => {\n                    restoreSend();\n                    if (!didAlreadySend()) {\n                      done();\n                    }\n                  },\n                  (error: unknown) => {\n                    restoreSend();\n                    done(\n                      error instanceof Error ? error : new Error(String(error)),\n                    );\n                  },\n                );\n                return;\n              }\n\n              // Sync early hooks that sent a response are complete. Sync early\n              // hooks that only mutated request/reply still need done().\n              restoreSend();\n              if (!didAlreadySend()) {\n                done();\n              }\n            } catch (error) {\n              restoreSend();\n              done(error instanceof Error ? error : new Error(String(error)));\n            }\n          }\n        : async (\n            request: FastifyRequest,\n            reply: FastifyReply,\n            ...args: unknown[]\n          ) => {\n            // Later hooks do not control routing continuation in the same way,\n            // so the simple async wrapper is enough to support sync handlers.\n            return handler(request, reply, ...args);\n          };\n      return fastifyInstance.addHook(\n        hookName as Parameters<typeof fastifyInstance.addHook>[0],\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument\n        wrappedHandler as any,\n      );\n    },\n    decorate: (property: string, value: unknown) =>\n      fastifyInstance.decorate(property, value),\n    decorateRequest: (property: string, value: unknown) =>\n      fastifyInstance.decorateRequest(property, value),\n    decorateReply: (property: string, value: unknown) =>\n      fastifyInstance.decorateReply(property, value),\n    hasDecoration: (property: string) =>\n      Object.prototype.hasOwnProperty.call(\n        fastifyInstance as unknown as Record<string, unknown>,\n        property,\n      ),\n    getDecoration: <T = unknown>(property: string): T | undefined =>\n      (fastifyInstance as unknown as Record<string, unknown>)[property] as\n        | T\n        | undefined,\n    route: (opts: SafeRouteOptions) => {\n      // Prevent catch-all routes that would conflict with SSR\n      if (opts.url === '*' || opts.url.includes('*')) {\n        throw new Error(\n          'Plugins cannot register catch-all routes that would conflict with SSR rendering',\n        );\n      }\n      // Note: SafeRouteOptions may not perfectly match Fastify's RouteOptions interface.\n      // This cast ensures compatibility with Fastify's internal route registration.\n      return fastifyInstance.route({\n        ...(opts as Parameters<typeof fastifyInstance.route>[0]),\n        handler: guardRouteHandler(opts.handler),\n      });\n    },\n    get: (path: string, handler: RouteHandler) => {\n      if (shouldDisableRootWildcard && (path === '*' || path === '/*')) {\n        throw new Error(\n          'Plugins cannot register root wildcard GET routes that would conflict with SSR rendering',\n        );\n      }\n\n      return fastifyInstance.get(path, guardRouteHandler(handler));\n    },\n    post: (path: string, handler: RouteHandler) =>\n      fastifyInstance.post(path, guardRouteHandler(handler)),\n    put: (path: string, handler: RouteHandler) =>\n      fastifyInstance.put(path, guardRouteHandler(handler)),\n    delete: (path: string, handler: RouteHandler) =>\n      fastifyInstance.delete(path, guardRouteHandler(handler)),\n    patch: (path: string, handler: RouteHandler) =>\n      fastifyInstance.patch(path, guardRouteHandler(handler)),\n    log: fastifyInstance.log,\n    api: apiShortcuts,\n    pageDataHandler: pageDataHandlerShortcuts,\n    APIResponseHelpers: apiResponseHelpersClass,\n  };\n}\n\n/**\n * Wrap Fastify's reply object with a constrained, safe surface for handlers.\n */\nexport function createControlledReply(\n  request: FastifyRequest,\n  reply: FastifyReply,\n): ControlledReply {\n  return {\n    header: (name: string, value: string) => {\n      reply.header(name, value);\n    },\n    getHeader: (name: string) =>\n      reply.getHeader(name) as unknown as\n        | string\n        | number\n        | string[]\n        | undefined,\n    getHeaders: () => reply.getHeaders() as unknown as Record<string, unknown>,\n    removeHeader: (name: string) => {\n      reply.removeHeader(name);\n    },\n    hasHeader: (name: string) => reply.hasHeader(name),\n    get sent() {\n      return reply.sent;\n    },\n    raw: {\n      get destroyed() {\n        return reply.raw.destroyed;\n      },\n    },\n    _sendErrorEnvelope: async (statusCode, errorEnvelope) => {\n      // ControlledReply does not expose reply.send()/raw writes to handlers,\n      // but framework-owned helpers still need one sanctioned way to send an\n      // early error envelope. Keep that capability internal here so handlers\n      // cannot treat ControlledReply like a full FastifyReply.\n      await sendRawErrorEnvelopeResponse(\n        request,\n        reply,\n        statusCode,\n        errorEnvelope,\n      );\n    },\n    setCookie:\n      typeof (reply as unknown as { setCookie?: unknown }).setCookie ===\n      'function'\n        ? (\n            reply as unknown as {\n              setCookie: (\n                name: string,\n                value: string,\n                options?: CookieSerializeOptions,\n              ) => void;\n            }\n          ).setCookie\n        : undefined,\n    cookie:\n      typeof (reply as unknown as { cookie?: unknown }).cookie === 'function'\n        ? (\n            reply as unknown as {\n              cookie: (\n                name: string,\n                value: string,\n                options?: CookieSerializeOptions,\n              ) => void;\n            }\n          ).cookie\n        : undefined,\n    clearCookie:\n      typeof (reply as unknown as { clearCookie?: unknown }).clearCookie ===\n      'function'\n        ? (\n            reply as unknown as {\n              clearCookie: (\n                name: string,\n                options?: CookieSerializeOptions,\n              ) => void;\n            }\n          ).clearCookie\n        : undefined,\n    unsignCookie:\n      typeof (reply as unknown as { unsignCookie?: unknown }).unsignCookie ===\n      'function'\n        ? (\n            reply as unknown as {\n              unsignCookie: (\n                value: string,\n              ) =>\n                | { valid: true; renew: boolean; value: string }\n                | { valid: false; renew: false; value: null };\n            }\n          ).unsignCookie\n        : undefined,\n    signCookie:\n      typeof (reply as unknown as { signCookie?: unknown }).signCookie ===\n      'function'\n        ? (\n            reply as unknown as {\n              signCookie: (value: string) => string;\n            }\n          ).signCookie\n        : undefined,\n  };\n}\n\n/**\n * Validates that no API or page data loader handlers were registered when API handling is disabled.\n * This prevents configuration errors where handlers are registered but won't be used.\n *\n * @param apiRoutes API routes helper instance\n * @param pageDataHandlers Page data loader handlers helper instance\n * @throws Error if handlers were registered when API is disabled\n */\nexport function validateNoHandlersWhenAPIDisabled(\n  apiRoutes: { hasRegisteredHandlers: () => boolean },\n  pageDataHandlers: { hasRegisteredHandlers: () => boolean },\n): void {\n  const hasAPIRoutes = apiRoutes.hasRegisteredHandlers();\n  const hasPageDataHandlers = pageDataHandlers.hasRegisteredHandlers();\n\n  if (hasAPIRoutes || hasPageDataHandlers) {\n    const registered = [\n      hasAPIRoutes ? 'API routes' : null,\n      hasPageDataHandlers ? 'page data loader handlers' : null,\n    ]\n      .filter(Boolean)\n      .join(' and ');\n\n    throw new Error(\n      `Cannot start server: ${registered} were registered but API handling is disabled ` +\n        `(apiEndpoints.apiEndpointPrefix is false). Either enable API handling by setting ` +\n        `apiEndpointPrefix to a value like '/api', or remove the registered handlers.`,\n    );\n  }\n}\n\n/**\n * Validates plugin dependencies and registers a plugin with metadata tracking\n *\n * @param registeredPlugins Array of already registered plugin metadata (mutated by this function)\n * @param pluginResult The result returned by the plugin (either PluginMetadata or void)\n * @throws Error if plugin dependencies are not met or duplicate plugin names\n */\nexport function validateAndRegisterPlugin(\n  registeredPlugins: PluginMetadata[],\n  pluginResult: PluginMetadata | void,\n): void {\n  // If plugin returned no metadata, nothing to track\n  if (!pluginResult) {\n    return;\n  }\n\n  // Check for duplicate plugin names\n  if (registeredPlugins.some((p) => p.name === pluginResult.name)) {\n    throw new Error(\n      `Plugin with name \"${pluginResult.name}\" is already registered`,\n    );\n  }\n\n  // Check dependencies\n  if (pluginResult.dependsOn) {\n    const dependencies = Array.isArray(pluginResult.dependsOn)\n      ? pluginResult.dependsOn\n      : [pluginResult.dependsOn];\n\n    const registeredNames = new Set(registeredPlugins.map((p) => p.name));\n\n    for (const dep of dependencies) {\n      if (!registeredNames.has(dep)) {\n        throw new Error(\n          `Plugin \"${pluginResult.name}\" depends on \"${dep}\" which has not been registered yet. ` +\n            `Plugins must be registered in dependency order.`,\n        );\n      }\n    }\n  }\n\n  // Add to registered plugins list\n  registeredPlugins.push(pluginResult);\n}\n\n/**\n * Decorates requests with a resolved client IP, set once per request.\n * Always sets clientIP to request.ip first (which respects fastifyOptions.trustProxy),\n * then overwrites it with the awaited return value of getClientIP if provided.\n *\n * If getClientIP throws or rejects, clientIP retains request.ip and the error\n * propagates as a normal 500.\n */\nexport function registerClientIPDecoration(\n  fastify: FastifyInstance,\n  getClientIP:\n    | ((request: FastifyRequest) => string | Promise<string>)\n    | undefined,\n): void {\n  fastify.decorateRequest('clientIP', '');\n\n  fastify.addHook('onRequest', async (request, _reply) => {\n    request.clientIP = request.ip;\n\n    if (getClientIP) {\n      request.clientIP = await getClientIP(request);\n    }\n  });\n}\n\n/**\n * Builds Fastify-compatible HTTPS options from the shared HTTPSOptions type.\n * Handles extracting the `sni` field and converting it to a Node.js `SNICallback`\n * that supports both sync and async user functions.\n *\n * Used by APIServer, and SSRServer to avoid duplicating\n * the SNI callback adapter logic.\n *\n * @param httpsConfig - The HTTPSOptions from server configuration\n * @returns A plain object suitable for passing as `fastifyOptions.https`\n */\nexport function buildFastifyHTTPSOptions(\n  httpsConfig: HTTPSOptions,\n): Record<string, unknown> {\n  const { sni, ...httpsOptions } = httpsConfig;\n\n  // Build HTTPS options for Fastify\n  const fastifyHTTPSOptions: Record<string, unknown> = {\n    ...httpsOptions,\n  };\n\n  // Add SNI callback if provided\n  if (sni) {\n    fastifyHTTPSOptions.SNICallback = (\n      servername: string,\n      callback?: (err: Error | null, ctx?: unknown) => void,\n    ) => {\n      // Call user's SNI function (supports both sync and async)\n      const result = sni(servername);\n\n      // Handle Promise return\n      if (result && typeof result === 'object' && 'then' in result) {\n        if (callback) {\n          result\n            .then((ctx: unknown) => {\n              callback(null, ctx);\n            })\n            .catch((error: unknown) => {\n              callback(\n                error instanceof Error ? error : new Error(String(error)),\n              );\n            });\n        } else {\n          return result;\n        }\n      } else if (callback) {\n        callback(null, result);\n      } else {\n        return result;\n      }\n    };\n  }\n\n  return fastifyHTTPSOptions;\n}\n\n/**\n * Normalizes a CDN base URL by stripping a trailing slash, so the value is\n * consistent whether it comes from server config, per-request override, or\n * the injected `window.__CDN_BASE_URL__` global read by the client.\n *\n * Must be applied before the URL is placed into `unirendContext.cdnBaseURL`\n * so that `useCDNBaseURL()` returns the same value on server and client —\n * avoiding React hydration mismatches when a trailing-slash URL is configured.\n */\nexport function normalizeCDNBaseURL(url: string | undefined): string {\n  if (!url) {\n    return '';\n  }\n\n  return url.endsWith('/') ? url.slice(0, -1) : url;\n}\n\n/**\n * Computes domain info from a request hostname using the public suffix list.\n * - `hostname`: the bare hostname (port stripped)\n * - `rootDomain`: the apex domain without a leading dot (e.g. `'example.com'`),\n *   or empty string for localhost / IP addresses where no root domain can be resolved.\n */\nexport function computeDomainInfo(hostname: string): DomainInfo {\n  // Use parseHostHeader for correct IPv6 bracket handling\n  // e.g. '[::1]:3000' → '::1', 'localhost:3000' → 'localhost'\n  const { domain: host } = parseHostHeader(hostname);\n  const root = getDomain(host) ?? '';\n\n  return {\n    hostname: host,\n    // Empty string when domain-utils cannot resolve a root (localhost, raw IP, etc.)\n    rootDomain: root,\n  };\n}\n","import type { PluginHostInstance, PluginOptions, ServerPlugin } from '../types';\nimport type { FastifyRequest } from 'fastify';\nimport { ulid, isValid as isValidULID } from 'ulid';\nimport { isPrivateIP } from 'range_check';\n\nexport interface ClientInfo {\n  requestID: string; // Unique ID for this specific request\n  correlationID: string | null; // ID for tracing requests across services (can be null)\n  /** True when request came from SSR layer with trusted forwarded headers */\n  isFromSSRServerAPICall: boolean;\n  IPAddress: string;\n  userAgent: string;\n  isIPFromHeader: boolean;\n  isUserAgentFromHeader: boolean;\n}\n\n// Extend FastifyRequest to include client info\ndeclare module 'fastify' {\n  interface FastifyRequest {\n    /** Optional unique request ID used by response helpers */\n    requestID?: string;\n    clientInfo?: ClientInfo;\n    /** Unix timestamp (ms) when the request was received — set by onRequest hook */\n    receivedAt?: number;\n  }\n}\n\n/**\n * Configuration options for the clientInfo plugin\n */\nexport interface ClientInfoLoggingOptions {\n  /** Log each request with its generated request ID. Default: false */\n  requestReceived?: boolean;\n  /** Log decision/details when trusting forwarded client info. Default: false */\n  forwardedClientInfo?: boolean;\n  /** Warn when SSR/forwarded headers are present from untrusted source. Default: false */\n  rejectedForwardedHeaders?: boolean;\n}\n\nexport interface ClientInfoConfig {\n  /** Custom function to generate request IDs. Defaults to ulid() */\n  requestIDGenerator?: () => string;\n  /** Custom validator for request/correlation IDs. Defaults to ULID validation */\n  requestIDValidator?: (id: string) => boolean;\n  /** If true, set X-Request-ID and X-Correlation-ID response headers. Default: true */\n  setResponseHeaders?: boolean;\n  /**\n   * Callback that determines whether to accept forwarded client-info headers.\n   * Default: returns true when request.clientIP is private. Otherwise forwarded\n   * headers are ignored and direct request values are used.\n   */\n  trustForwardedHeaders?: (request: FastifyRequest) => boolean;\n  /** Optional logging configuration */\n  logging?: boolean | ClientInfoLoggingOptions;\n}\n\n/**\n * Client Info plugin to extract and normalize client information and handle request IDs\n *\n * This middleware:\n * 1. Generates or forwards request IDs\n * 2. Handles client info from both direct requests and SSR-forwarded requests\n * 3. Validates SSR requests come from private IP ranges\n */\n\nexport function clientInfo(config: ClientInfoConfig = {}): ServerPlugin {\n  return (pluginHost: PluginHostInstance, _options: PluginOptions) => {\n    // Set default values for the clientInfo property\n    pluginHost.decorateRequest('clientInfo', null);\n    // Ensure requestID is a known property on FastifyRequest\n    pluginHost.decorateRequest('requestID', null);\n\n    const generateRequestID =\n      typeof config.requestIDGenerator === 'function'\n        ? config.requestIDGenerator\n        : () => ulid();\n\n    const validateRequestID =\n      typeof config.requestIDValidator === 'function'\n        ? config.requestIDValidator\n        : (id: string) => isValidULID(id);\n\n    pluginHost.addHook('onRequest', async (request, reply) => {\n      const loggingConfig = config.logging;\n      const shouldLogAll = loggingConfig === true;\n      const shouldLogNone =\n        loggingConfig === false || typeof loggingConfig === 'undefined';\n      const loggingObject: ClientInfoLoggingOptions | undefined =\n        typeof loggingConfig === 'object' && loggingConfig !== null\n          ? loggingConfig\n          : undefined;\n\n      const shouldLogRequestReceived =\n        shouldLogAll ||\n        (!shouldLogNone && loggingObject?.requestReceived === true);\n      const shouldLogForwardedClientInfo =\n        shouldLogAll ||\n        (!shouldLogNone && loggingObject?.forwardedClientInfo === true);\n      const shouldLogRejectedForwardedHeaders =\n        shouldLogAll ||\n        (!shouldLogNone && loggingObject?.rejectedForwardedHeaders === true);\n\n      // Generate a request ID for each request\n      const requestID = generateRequestID();\n\n      // The request ID also serves as the correlation ID for the entire request chain\n      // This will be forwarded to the API server\n\n      // Optionally log the request with its ID\n      if (shouldLogRequestReceived) {\n        request.log?.info?.(\n          { requestID },\n          `Request received: ${request.method} ${request.url}`,\n        );\n      }\n\n      // Initialize clientInfo for this request with default values\n      request.clientInfo = {\n        requestID: requestID,\n        correlationID: null,\n        isFromSSRServerAPICall: false,\n        IPAddress: '',\n        userAgent: '',\n        isIPFromHeader: false,\n        isUserAgentFromHeader: false,\n      };\n\n      // Also set request.requestID for envelope helpers and SSR forwarding\n      request.requestID = requestID;\n\n      // Default values from the request\n      let IPAddress = request.clientIP;\n      let isIPFromHeader = false;\n\n      // Handle User-Agent header safely\n      const uaHeader = request.headers['user-agent'];\n      let userAgent = typeof uaHeader === 'string' ? uaHeader : '';\n      let isUserAgentFromHeader = false;\n\n      let isFromSSRServerAPICall = false;\n\n      // We'll determine the correlation ID after validating the source\n      // Correlation ID is used for tracing requests across services\n      let correlationID: string | null = null;\n\n      // isFromSSRServerAPICall is only set when forwarded headers are trusted\n\n      // Decide whether to trust forwarded headers using config or default private IP check\n      const shouldTrustForwarded =\n        (typeof config.trustForwardedHeaders === 'function'\n          ? config.trustForwardedHeaders(request)\n          : isPrivateIP(request.clientIP)) === true;\n\n      if (shouldTrustForwarded) {\n        // keep going\n        // Safely check if x-ssr-request header is 'true'\n        const ssrHeader = request.headers['x-ssr-request'];\n        const isSSRRequest =\n          typeof ssrHeader === 'string' && ssrHeader === 'true';\n\n        // Check if we have any forwarded client info headers\n        const hasForwardedClientInfo =\n          isSSRRequest ||\n          request.headers['x-ssr-original-ip'] ||\n          request.headers['x-ssr-forwarded-user-agent'] ||\n          request.headers['x-correlation-id'];\n\n        // Set SSR flag only if the x-ssr-request header is explicitly true\n        if (isSSRRequest) {\n          isFromSSRServerAPICall = true;\n        }\n\n        if (hasForwardedClientInfo) {\n          // Use X-SSR-Original-IP if provided\n          const originalIPHeader = request.headers['x-ssr-original-ip'];\n\n          if (typeof originalIPHeader === 'string') {\n            IPAddress = originalIPHeader;\n            isIPFromHeader = true;\n          }\n\n          // Use X-SSR-Forwarded-User-Agent if provided\n          const forwardedUserAgentHeader =\n            request.headers['x-ssr-forwarded-user-agent'];\n\n          if (typeof forwardedUserAgentHeader === 'string') {\n            userAgent = forwardedUserAgentHeader;\n            isUserAgentFromHeader = true;\n          }\n\n          // Use X-Correlation-ID from SSR if it's valid\n          const correlationHeader = request.headers['x-correlation-id'];\n\n          if (\n            typeof correlationHeader === 'string' &&\n            validateRequestID(correlationHeader)\n          ) {\n            correlationID = correlationHeader;\n          }\n\n          if (shouldLogForwardedClientInfo) {\n            request.log?.debug?.(\n              {\n                requestID,\n                correlationID,\n                originalIP: IPAddress,\n                ssrIP: request.clientIP,\n                isFromSSRServerAPICall,\n              },\n              'Using forwarded client info from trusted source',\n            );\n          }\n        }\n      } else if (\n        (typeof request.headers['x-ssr-request'] === 'string' &&\n          request.headers['x-ssr-request'] === 'true') ||\n        request.headers['x-ssr-original-ip'] ||\n        request.headers['x-ssr-forwarded-user-agent'] ||\n        request.headers['x-correlation-id']\n      ) {\n        // Log a warning if SSR headers are present but from a non-private IP\n        // As someone might be trying to spoof the request?\n        if (shouldLogRejectedForwardedHeaders) {\n          request.log?.warn?.(\n            {\n              requestID,\n              ip: request.clientIP,\n            },\n            'Rejected SSR headers from untrusted source',\n          );\n        }\n      }\n\n      // For direct API requests, use the request ID as correlation ID\n      // if a correlation ID hasn't been set through X-Correlation-ID\n      if (!correlationID) {\n        correlationID = requestID;\n      }\n\n      // -- Set the headers and request.clientInfo --\n      // Optionally add the request ID and correlation ID to response headers for client-side tracking after we've validated the source\n      if (config.setResponseHeaders !== false) {\n        reply.header('X-Request-ID', requestID);\n        reply.header('X-Correlation-ID', correlationID);\n      }\n\n      // Log the request with its IDs (optional)\n      if (shouldLogRequestReceived) {\n        request.log?.info?.(\n          {\n            requestID,\n            correlationID: correlationID || undefined,\n          },\n          `Request received: ${request.method} ${request.url}`,\n        );\n      }\n\n      // Set the client info on the request\n      request.clientInfo.requestID = requestID;\n      request.clientInfo.correlationID = correlationID;\n      request.clientInfo.isFromSSRServerAPICall = isFromSSRServerAPICall;\n      request.clientInfo.IPAddress = IPAddress;\n      request.clientInfo.userAgent = userAgent;\n      request.clientInfo.isIPFromHeader = isIPFromHeader;\n      request.clientInfo.isUserAgentFromHeader = isUserAgentFromHeader;\n\n      // Freeze the clientInfo object to make it read-only\n      Object.freeze(request.clientInfo);\n    });\n\n    return {\n      name: 'client-info',\n    };\n  };\n}\n","import type { ServerPlugin, PluginHostInstance } from '../types';\nimport fastifyCookie from '@fastify/cookie';\nimport type { FastifyCookieOptions } from '@fastify/cookie';\n\n// Public type alias to align our plugin options with @fastify/cookie options\nexport type CookiesConfig = FastifyCookieOptions;\n\n/**\n * Built-in cookies plugin that registers @fastify/cookie and exposes dependency metadata.\n *\n * Usage:\n *   plugins: [cookies({ secret: \"your-secret\" })]\n *\n * Other plugins can declare a dependency on \"cookies\" in their PluginMetadata.dependsOn\n * to ensure this plugin is registered first.\n */\nexport function cookies(config: CookiesConfig = {}): ServerPlugin {\n  return async (pluginHost: PluginHostInstance) => {\n    await pluginHost.register(fastifyCookie, config as Record<string, unknown>);\n\n    // Expose simple runtime metadata so other plugins/handlers can check\n    // whether a signing secret/signer is configured and which algorithm is set.\n    pluginHost.decorate('cookiePluginInfo', {\n      signingSecretProvided: !!config.secret,\n      algorithm:\n        (config as FastifyCookieOptions & { algorithm?: string }).algorithm ??\n        'sha256',\n    });\n\n    return {\n      name: 'cookies',\n    } as const;\n  };\n}\n","import type { FastifyRequest, FastifyReply } from 'fastify';\nimport type { OutgoingHttpHeaders } from 'node:http';\nimport fs from 'fs';\nimport path from 'path';\nimport crypto from 'node:crypto';\nimport { pipeline } from 'node:stream/promises';\nimport { LRUCache, type LRUCacheChangeEvent } from 'lifecycleion/lru-cache';\nimport type { StaticContentRouterOptions, FolderConfig } from '../types';\nimport { addToVaryHeader } from './http-header-utils';\nimport {\n  buildEncodedETag,\n  compressPayload,\n  isCompressibleContentType,\n  matchesIfNoneMatch,\n  normalizeResponseCompressionOptions,\n  selectResponseEncoding,\n} from './response-compression';\n\n/**\n * Minimal stat info interface with only the properties we actually use\n */\ninterface MinimalStatInfo {\n  isFile: boolean;\n  size: number;\n  mtime: Date;\n  // eslint-disable-next-line @typescript-eslint/naming-convention\n  mtimeMs: number;\n}\n\n/**\n * Negative cache entry type (for 404s and access errors)\n */\ninterface NegativeCacheEntry {\n  notFound: true;\n}\n\ntype CompressedVariantState =\n  | {\n      kind: 'compressed';\n      data: Buffer;\n    }\n  | {\n      kind: 'not-worth-it';\n    }\n  | {\n      kind: 'tombstone';\n    };\n\n/**\n * Combined type for stat cache entries\n */\ntype StatCacheEntry = MinimalStatInfo | NegativeCacheEntry | null;\n\n/**\n * Options for getFile() method\n */\nexport interface GetFileOptions {\n  /** Whether to detect immutable assets for cache control decisions */\n  shouldDetectImmutable?: boolean;\n  /** Optional ETag from client's If-None-Match header (for 304 optimization) */\n  clientETag?: string;\n  /** Accepted content encodings from the request for representation selection */\n  acceptEncoding?: string | string[];\n}\n\n/**\n * Options for creating a read stream (for range requests)\n */\nexport interface CreateStreamOptions {\n  /** Start byte position (inclusive) */\n  start?: number;\n  /** End byte position (inclusive) */\n  end?: number;\n}\n\n/**\n * Result from serveFile() indicating what action was taken\n */\nexport type ServeFileResult =\n  | { served: false; reason: 'not-found' }\n  | { served: false; reason: 'error'; error: Error }\n  | {\n      served: true;\n      statusCode:\n        | 200 // Full file served\n        | 206 // Partial content served\n        | 304 // Not modified\n        | 400 // Invalid range request\n        | 416; // Range not satisfiable\n    };\n\n/**\n * File content discriminated union - either buffered in memory or needs streaming\n */\nexport type FileContent =\n  | {\n      /** Content is buffered in memory (small files) */\n      shouldStream: false;\n      /** The file content buffer */\n      data: Buffer;\n    }\n  | {\n      /** Content needs to be streamed from disk (large files) */\n      shouldStream: true;\n      /** Factory function to create a read stream with optional range support */\n      createStream: (options?: CreateStreamOptions) => fs.ReadStream;\n    };\n\n/**\n * Internal logger object used by static content helpers.\n */\nexport type StaticContentWarnLoggerObject = {\n  warn: (obj: object, msg: string) => void;\n};\n\n/**\n * Result when file is not found (404)\n */\nexport interface FileNotFoundResult {\n  status: 'not-found';\n}\n\n/**\n * Result when an unexpected error occurs (500)\n */\nexport interface FileErrorResult {\n  status: 'error';\n  error: Error;\n}\n\n/**\n * Result when client's ETag matches (304 Not Modified)\n */\nexport interface FileNotModifiedResult {\n  status: 'not-modified';\n  /** Generated ETag for the selected response representation */\n  etag: string;\n  /** Last-Modified date as HTTP header string */\n  lastModified: string;\n  /** Selected content encoding, if a compressed representation was chosen */\n  contentEncoding?: 'br' | 'gzip';\n  /** Whether the response should include `Vary: Accept-Encoding` */\n  varyByAcceptEncoding: boolean;\n}\n\n/**\n * Result when file is found and should be served (200)\n */\nexport interface FileFoundResult {\n  status: 'ok';\n  /** File stats (size, modification time, etc.) */\n  stat: MinimalStatInfo;\n  /** Generated ETag for the selected response representation */\n  etag: string;\n  /** Base file ETag before representation-specific encoding suffixes */\n  baseETag: string;\n  /** Last-Modified date as HTTP header string */\n  lastModified: string;\n  /** MIME type based on file extension */\n  mimeType: string;\n  /** File content - either buffered or needs streaming */\n  content: FileContent;\n  /** Selected content encoding, if a compressed representation was chosen */\n  contentEncoding?: 'br' | 'gzip';\n  /** Whether the response should include `Vary: Accept-Encoding` */\n  varyByAcceptEncoding: boolean;\n  /** Whether this file appears to be fingerprinted/immutable (for aggressive caching) */\n  isImmutableAsset: boolean;\n}\n\nfunction waitForReadStreamOpen(stream: fs.ReadStream): Promise<void> {\n  if (\n    stream.pending === false ||\n    typeof (stream as fs.ReadStream & { fd?: number }).fd === 'number'\n  ) {\n    return Promise.resolve();\n  }\n\n  return new Promise((resolve, reject) => {\n    const onOpen = () => {\n      cleanup();\n      resolve();\n    };\n\n    const onError = (error: Error) => {\n      cleanup();\n      reject(error);\n    };\n\n    const cleanup = () => {\n      stream.off('open', onOpen);\n      stream.off('error', onError);\n    };\n\n    stream.once('open', onOpen);\n    stream.once('error', onError);\n  });\n}\n\n/**\n * Union type for all possible getFile() results\n */\nexport type FileResult =\n  | FileNotFoundResult\n  | FileErrorResult\n  | FileNotModifiedResult\n  | FileFoundResult;\n\n/**\n * Parse a Range header into [start, end] byte offsets (both inclusive).\n *\n * Supports:\n *   bytes=0-499      explicit range\n *   bytes=500-       from offset to end of file\n *   bytes=-500       last 500 bytes (suffix range)\n *\n * Returns [start, end] on success.\n * Returns 'malformed' (→ 400) for syntactically invalid headers (no bytes= prefix, bad spec format).\n * Returns 'unsatisfiable' (→ 416) for multipart ranges or ranges that exceed the file size.\n */\nfunction parseRange(\n  header: string,\n  fileSize: number,\n): [number, number] | 'malformed' | 'unsatisfiable' {\n  if (!header.startsWith('bytes=')) {\n    return 'malformed';\n  }\n\n  const spec = header.slice(6);\n\n  // Reject multipart ranges (satisfiable syntax, but unsupported)\n  if (spec.includes(',')) {\n    return 'unsatisfiable';\n  }\n\n  const match = /^(\\d*)-(\\d*)$/.exec(spec);\n\n  if (!match) {\n    return 'malformed';\n  }\n\n  const startStr = match[1];\n  const endStr = match[2];\n\n  if (startStr === '' && endStr === '') {\n    return 'malformed';\n  }\n\n  let start: number;\n  let end: number;\n\n  if (startStr === '') {\n    // Suffix range: bytes=-500 → last 500 bytes\n    const suffix = parseInt(endStr, 10);\n    start = Math.max(0, fileSize - suffix);\n    end = fileSize - 1;\n  } else if (endStr === '') {\n    // Open-ended range: bytes=500-\n    start = parseInt(startStr, 10);\n    end = fileSize - 1;\n  } else {\n    start = parseInt(startStr, 10);\n    end = parseInt(endStr, 10);\n  }\n\n  // Validate: start must be within file, start must not exceed end\n  if (start >= fileSize || start > end) {\n    return 'unsatisfiable';\n  }\n\n  // Clamp end to last valid byte\n  end = Math.min(end, fileSize - 1);\n\n  return [start, end];\n}\n\n/**\n * Encapsulates caching and serving of static content files.\n *\n * This class manages:\n * - Multiple LRU caches (ETag, file content, and file stats)\n * - Configuration for single asset and folder mappings\n * - Optimized file serving with HTTP caching headers\n * - Content-based ETags for small files, weak ETags for large files\n * - Automatic detection of immutable assets (fingerprinted files)\n *\n * Each instance maintains its own independent caches, allowing\n * multiple instances with different configurations.\n */\nexport class StaticContentCache {\n  // Normalized mappings (mutable to allow runtime updates)\n  private singleAssetMap: Map<string, string>; // URL path → filesystem path\n  private folderMap: Map<string, FolderConfig>; // URL prefix → folder config\n\n  // Cache configuration\n  private readonly smallFileMaxSize: number;\n  private readonly cacheControl: string;\n  private readonly immutableCacheControl: string;\n  private readonly negativeCacheTtl: number;\n  private readonly positiveCacheTtl: number;\n  private readonly compression: ReturnType<\n    typeof normalizeResponseCompressionOptions\n  >;\n\n  // LRU caches (all keyed by filesystem path)\n  private readonly etagCache: LRUCache<string, string>; // fs path → ETag\n  private readonly contentCache: LRUCache<string, Buffer>; // fs path → file content\n  // Keyed by fs path + BASE file ETag + encoding.\n  // The stored ETag component is the uncompressed file's identity; the\n  // representation-specific HTTP ETag is derived later by suffixing the\n  // encoding (e.g. \"--gzip\", \"--br\") when sending the response.\n  private readonly compressedVariantCache: LRUCache<\n    string,\n    CompressedVariantState\n  >; // fs path + base etag + encoding → compressed variant state\n  private readonly statCache: LRUCache<string, StatCacheEntry>; // fs path → file stats\n  // Reverse index of filesystem path -> compressed cache keys for that file.\n  // This lets invalidateFile() clear every cached compressed representation for\n  // a path without needing to know which base ETag variants are currently live.\n  private readonly compressedContentIndex: Map<string, Set<string>> = new Map();\n\n  // Optional logger\n  private readonly logger?: StaticContentWarnLoggerObject;\n\n  /**\n   * Creates a new StaticContentCache instance\n   *\n   * @param options Static content configuration (file mappings, cache settings, etc.)\n   * @param logger Optional logger (e.g., fastify.log) for error logging\n   */\n  constructor(\n    options: StaticContentRouterOptions,\n    logger?: StaticContentWarnLoggerObject,\n  ) {\n    const {\n      singleAssetMap = {},\n      folderMap = {},\n      smallFileMaxSize = 5 * 1024 * 1024, // 5 MB\n      cacheEntries = 100,\n      contentCacheMaxSize = 50 * 1024 * 1024, // 50 MB\n      statCacheEntries = 250,\n      negativeCacheTtl = 30 * 1000, // 30 seconds\n      positiveCacheTtl = 60 * 60 * 1000, // 1 hour\n      cacheControl = 'public, max-age=0, must-revalidate',\n      immutableCacheControl = 'public, max-age=31536000, immutable',\n      compression = true,\n    } = options;\n\n    this.smallFileMaxSize = smallFileMaxSize;\n    this.cacheControl = cacheControl;\n    this.immutableCacheControl = immutableCacheControl;\n    this.negativeCacheTtl = negativeCacheTtl;\n    this.positiveCacheTtl = positiveCacheTtl;\n    this.compression = normalizeResponseCompressionOptions(compression);\n    this.logger = logger;\n\n    // Normalize singleAssetMap\n    this.singleAssetMap = this.normalizeSingleAssetMap(singleAssetMap);\n\n    // Normalize folderMap\n    this.folderMap = this.normalizeFolderMap(folderMap);\n\n    // Initialize LRU caches\n    const defaultTtl = positiveCacheTtl > 0 ? positiveCacheTtl : undefined;\n\n    this.etagCache = new LRUCache<string, string>(cacheEntries, { defaultTtl });\n    this.contentCache = new LRUCache<string, Buffer>(cacheEntries, {\n      defaultTtl,\n      maxSize: contentCacheMaxSize,\n    });\n\n    this.compressedVariantCache = new LRUCache<string, CompressedVariantState>(\n      cacheEntries,\n      {\n        defaultTtl,\n        maxSize: contentCacheMaxSize,\n        // Keep the reverse index aligned when compressed variants disappear from\n        // the LRU on their own, not just when StaticContentCache deletes them.\n        onChange: (event) => this.handleCompressedVariantCacheChange(event),\n        onChangeReasons: ['evict', 'expired', 'delete', 'clear'],\n      },\n    );\n    this.statCache = new LRUCache<string, StatCacheEntry>(statCacheEntries, {\n      defaultTtl,\n    });\n  }\n\n  /**\n   * Gets file metadata and content with optimized caching\n   *\n   * This method handles all the core file operations and caching:\n   * - File stats caching to avoid repeated filesystem operations\n   * - ETag generation and caching (content-based for small files, weak for large files)\n   * - Small file content caching in memory for performance\n   * - Proper MIME type detection\n   * - Immutable asset detection for cache control decisions\n   * - Optional short-circuit if client ETag matches (for 304 responses)\n   *\n   * Useful for both HTTP serving (via serveFile) and programmatic access\n   *\n   * @param resolvedPath The absolute path to the file\n   * @param options Optional configuration for file retrieval\n   * @returns Result with status: 'not-found', 'error', 'not-modified', or 'ok'\n   */\n  public async getFile(\n    resolvedPath: string,\n    options?: GetFileOptions,\n  ): Promise<FileResult> {\n    // Wrap entire operation in try-catch to return errors instead of throwing\n    try {\n      const {\n        shouldDetectImmutable = false,\n        clientETag,\n        acceptEncoding,\n      } = options || {};\n\n      // Step 1: Resolve file metadata, preferably from the stat cache.\n      // Try to get file stats from cache to avoid filesystem operations\n      const cachedStat = this.statCache.get(resolvedPath);\n\n      // Variable that will hold our file information\n      let stat: MinimalStatInfo | null = null;\n\n      // Handle cached entries (LRU handles TTL expiration internally)\n      if (cachedStat) {\n        if ('notFound' in cachedStat) {\n          // File is known to not exist\n          return { status: 'not-found' };\n        } else if (cachedStat !== null) {\n          // We have a valid cached stat, use it\n          stat = cachedStat;\n        }\n      }\n\n      // If stats aren't cached, retrieve them from filesystem\n      if (!stat) {\n        try {\n          const fullStat = await fs.promises.stat(resolvedPath);\n\n          // Only serve regular files, not directories or special files\n          if (!fullStat.isFile()) {\n            // Cache as negative entry with specific TTL\n            this.statCache.set(\n              resolvedPath,\n              { notFound: true },\n              this.negativeCacheTtl,\n            );\n\n            return { status: 'not-found' };\n          }\n\n          // Extract only the properties we need to minimize memory usage\n          stat = {\n            isFile: true, // We know it's a file at this point\n            size: fullStat.size,\n            mtime: fullStat.mtime,\n            // eslint-disable-next-line @typescript-eslint/naming-convention\n            mtimeMs: fullStat.mtimeMs,\n          };\n\n          // Cache the minimal stats for future requests\n          // The TTL was already set when creating the cache\n          this.statCache.set(resolvedPath, stat);\n        } catch (error) {\n          // File doesn't exist or can't be accessed\n          // Cache as negative entry with specific TTL\n          this.statCache.set(\n            resolvedPath,\n            { notFound: true },\n            this.negativeCacheTtl,\n          );\n\n          // Log unexpected errors (like permission issues) but not 'file not found' errors\n          // ENOENT is expected for files that don't exist and shouldn't be logged\n          if (\n            error instanceof Error &&\n            'code' in error &&\n            (error as NodeJS.ErrnoException).code !== 'ENOENT' &&\n            this.logger\n          ) {\n            this.logger.warn(\n              {\n                err: error,\n                path: resolvedPath,\n              },\n              'Unexpected error accessing static file',\n            );\n          }\n\n          return { status: 'not-found' };\n        }\n      }\n\n      // Generate Last-Modified header from file modification time\n      const lastModified = stat.mtime.toUTCString();\n\n      // Step 2: Derive the base file validator used for identity responses and\n      // as the source for encoding-specific ETags.\n      // Try to get ETag from cache\n      let etag = this.etagCache.get(resolvedPath);\n\n      // Generate a new ETag if not cached\n      if (!etag) {\n        // For small files: create content-based strong ETag using SHA-256\n        if (stat.size <= this.smallFileMaxSize) {\n          // Try to get file content from cache for ETag generation\n          let buf = this.contentCache.get(resolvedPath);\n\n          // If content not cached, read and cache it\n          if (!buf) {\n            try {\n              buf = await fs.promises.readFile(resolvedPath);\n              this.contentCache.set(resolvedPath, buf);\n            } catch (error) {\n              // Log unexpected errors when reading file content\n              // Cast to NodeJS.ErrnoException to access error codes if needed\n              const fsError = error as NodeJS.ErrnoException;\n\n              if (this.logger) {\n                this.logger.warn(\n                  {\n                    err: fsError,\n                    path: resolvedPath,\n                    code: fsError.code,\n                  },\n                  'Error reading static file content',\n                );\n              }\n\n              // Re-throw to be handled by outer error handling\n              throw error;\n            }\n          }\n\n          // Generate a strong hash-based ETag from file content\n          const hash = crypto.createHash('sha256').update(buf).digest('base64');\n          etag = `\"${hash}\"`;\n        } else {\n          // For large files: create a weak ETag based on size and modification time\n          // Using W/ prefix to indicate a weak validator per RFC specs\n          etag = `W/\"${stat.size}-${Number(stat.mtimeMs)}\"`;\n        }\n\n        // Cache the ETag for future requests\n        this.etagCache.set(resolvedPath, etag);\n      }\n\n      // Determine if we should use immutable cache headers based on the filename pattern\n      // A fingerprinted file typically has a name like main.a1b2c3.js or chunk-5a7d9c8b.js\n      const isImmutableAsset =\n        shouldDetectImmutable && this.isImmutableAsset(resolvedPath);\n\n      // Get MIME type based on file extension\n      const mimeType = this.getMimeType(resolvedPath);\n\n      // Step 3: Load the file body as either a cached/buffered payload or a\n      // stream factory, depending on size.\n      // Build content discriminated union based on file size\n      // Small files: buffered in memory (get from cache or read from disk as fallback)\n      // Large files: must be streamed from disk with factory function that supports ranges\n      let fileContent: FileContent;\n\n      if (stat.size <= this.smallFileMaxSize) {\n        // Try to get content from cache first\n        let content = this.contentCache.get(resolvedPath);\n\n        // If not in cache, read from disk\n        if (!content) {\n          try {\n            content = await fs.promises.readFile(resolvedPath);\n            this.contentCache.set(resolvedPath, content);\n          } catch (error) {\n            // File disappeared or became inaccessible\n            const fsError = error as NodeJS.ErrnoException;\n\n            // If file no longer exists, treat as not-found\n            if (fsError.code === 'ENOENT') {\n              // Invalidate caches since file disappeared\n              this.statCache.set(\n                resolvedPath,\n                { notFound: true },\n                this.negativeCacheTtl,\n              );\n\n              this.etagCache.delete(resolvedPath);\n              this.contentCache.delete(resolvedPath);\n\n              return { status: 'not-found' };\n            }\n\n            // Other errors - log and re-throw\n            if (this.logger) {\n              this.logger.warn(\n                {\n                  err: fsError,\n                  path: resolvedPath,\n                  code: fsError.code,\n                },\n                'Error reading static file content',\n              );\n            }\n\n            throw error;\n          }\n        }\n\n        fileContent = { shouldStream: false, data: content };\n      } else {\n        fileContent = {\n          shouldStream: true,\n          createStream: (options) => fs.createReadStream(resolvedPath, options),\n        };\n      }\n\n      // Step 4: Choose the response representation before checking\n      // If-None-Match so gzip/br variants get their own ETags and 304 behavior.\n      const shouldVaryByAcceptEncoding =\n        this.compression.enabled &&\n        !fileContent.shouldStream &&\n        isCompressibleContentType(mimeType) &&\n        fileContent.data.length >= this.compression.threshold;\n\n      const selectedEncoding = shouldVaryByAcceptEncoding\n        ? selectResponseEncoding(acceptEncoding, this.compression.preferBrotli)\n        : null;\n      let responseEncoding: 'br' | 'gzip' | undefined;\n\n      // Step 5: For buffered responses, reuse or build a compressed variant if\n      // the negotiated encoding is smaller than the original bytes.\n      if (!fileContent.shouldStream && selectedEncoding) {\n        const compressedCacheKey = this.getCompressedCacheKey(\n          resolvedPath,\n          etag,\n          selectedEncoding,\n        );\n\n        const cachedVariant =\n          this.compressedVariantCache.get(compressedCacheKey);\n\n        let compressed =\n          cachedVariant?.kind === 'compressed' ? cachedVariant.data : undefined;\n        const isCompressedNotWorthIt = cachedVariant?.kind === 'not-worth-it';\n        const isCompressedTombstone = cachedVariant?.kind === 'tombstone';\n\n        if (!cachedVariant || isCompressedTombstone) {\n          // A plain cache miss may leave behind a stale reverse-index entry, so\n          // clean that up before recomputing. Tombstones stay tracked on\n          // purpose: they still represent a live variant key that should block\n          // immediate reinsertion after invalidateFile().\n          if (!isCompressedTombstone) {\n            this.untrackCompressedVariant(resolvedPath, compressedCacheKey);\n          }\n\n          compressed = await compressPayload(\n            fileContent.data,\n            selectedEncoding,\n            this.compression,\n          );\n        }\n\n        // Only keep an encoded variant if it is actually smaller than the\n        // original bytes. Otherwise prefer the identity response and clear any\n        // stale cached compressed entry for this representation.\n        if (compressed && compressed.length < fileContent.data.length) {\n          responseEncoding = selectedEncoding;\n\n          // Only store compressed bytes if we do not already have them cached\n          // and this exact variant key is not still inside the invalidation\n          // tombstone window from a recent invalidateFile() call.\n          if (\n            !this.compressedVariantCache.get(compressedCacheKey) &&\n            !isCompressedTombstone\n          ) {\n            // invalidateFile() leaves a short-lived tombstone for the old\n            // path + base ETag + encoding key so an older in-flight request\n            // cannot immediately repopulate a stale compressed variant.\n            this.compressedVariantCache.set(compressedCacheKey, {\n              kind: 'compressed',\n              data: compressed,\n            });\n\n            // The reverse index groups all compressed variants for a file path\n            // so invalidateFile() can clear them without knowing the current\n            // base ETag or encoding ahead of time.\n            const existingCompressedKeys =\n              this.compressedContentIndex.get(resolvedPath);\n\n            if (existingCompressedKeys) {\n              existingCompressedKeys.add(compressedCacheKey);\n            } else {\n              this.compressedContentIndex.set(\n                resolvedPath,\n                new Set([compressedCacheKey]),\n              );\n            }\n          }\n\n          fileContent = {\n            shouldStream: false,\n            data: compressed,\n          };\n        } else {\n          // Only record a fresh negative result. Reuse existing tombstones and\n          // prior \"not worth it\" decisions instead of resetting their state.\n          if (!isCompressedNotWorthIt && !isCompressedTombstone) {\n            // Record that this exact variant key negotiated successfully but\n            // did not beat the identity response, so future requests can skip\n            // recompressing until the file version changes or the entry expires.\n            this.compressedVariantCache.delete(compressedCacheKey);\n            this.compressedVariantCache.set(compressedCacheKey, {\n              kind: 'not-worth-it',\n            });\n\n            // Track negative variant decisions in the same reverse index so\n            // invalidateFile() can clear all per-variant state for the path.\n            const existingVariantKeys =\n              this.compressedContentIndex.get(resolvedPath);\n\n            if (existingVariantKeys) {\n              existingVariantKeys.add(compressedCacheKey);\n            } else {\n              this.compressedContentIndex.set(\n                resolvedPath,\n                new Set([compressedCacheKey]),\n              );\n            }\n          }\n        }\n      }\n\n      const responseETag = responseEncoding\n        ? buildEncodedETag(etag, responseEncoding)\n        : etag;\n\n      if (clientETag && matchesIfNoneMatch(clientETag, responseETag)) {\n        return {\n          status: 'not-modified',\n          etag: responseETag,\n          lastModified,\n          contentEncoding: responseEncoding,\n          varyByAcceptEncoding: shouldVaryByAcceptEncoding,\n        };\n      }\n\n      return {\n        status: 'ok',\n        stat,\n        etag: responseETag,\n        baseETag: etag,\n        lastModified,\n        mimeType,\n        content: fileContent,\n        contentEncoding: responseEncoding,\n        varyByAcceptEncoding: shouldVaryByAcceptEncoding,\n        isImmutableAsset,\n      };\n    } catch (error) {\n      // Return error status for unexpected errors\n      return {\n        status: 'error',\n        error: error instanceof Error ? error : new Error(String(error)),\n      };\n    }\n  }\n\n  /**\n   * Serves a static file via HTTP with conditional responses\n   *\n   * This is a thin HTTP wrapper around getFile() that handles:\n   * - HTTP 304 Not Modified responses when client cache is valid (If-None-Match)\n   * - HTTP 206 Partial Content responses for range requests\n   * - Proper HTTP headers (Cache-Control, ETag, Content-Type, Last-Modified, etc.)\n   * - Streaming large files vs sending cached buffers for small files\n   *\n   * The heavy lifting (file I/O, caching, ETag generation) is done by getFile()\n   *\n   * @param req The Fastify request object\n   * @param reply The Fastify reply object\n   * @param resolvedPath The absolute path to the file to be served\n   * @param options Optional configuration for file serving\n   * @returns Information about whether the file was served and what status code\n   */\n  public async serveFile(\n    req: FastifyRequest,\n    reply: FastifyReply,\n    resolvedPath: string,\n    options?: GetFileOptions,\n  ): Promise<ServeFileResult> {\n    // Raw static responses use reply.hijack() + writeHead(), which bypasses\n    // Fastify's normal onSend pipeline. Built-in CORS exposes a request-scoped\n    // helper so hijacked paths can still apply the same actual-response headers\n    // before we snapshot reply.getHeaders(). Keep this ahead of hijack so a\n    // CORS/config failure still propagates through normal Fastify error\n    // handling instead of failing after raw ownership has been taken.\n\n    // Get file with all metadata and caching\n    const result = await this.getFile(resolvedPath, {\n      ...options,\n      // Extract client cache validation header (ETag-based validation).\n      clientETag: req.headers['if-none-match'],\n      // Pass through Accept-Encoding so getFile() can choose the response\n      // representation before doing ETag/304 handling.\n      acceptEncoding: req.headers['accept-encoding'],\n    });\n\n    // Handle different result statuses\n    if (result.status === 'not-found') {\n      // File not found, return early (let hook fall through to 404)\n      return { served: false, reason: 'not-found' };\n    } else if (result.status === 'error') {\n      // Unexpected error occurred, return error info\n      return { served: false, reason: 'error', error: result.error };\n    }\n\n    // Mark only when static content is about to take ownership of the response.\n    // This keeps onResponse/access-log consumers from seeing failed pre-hijack\n    // stream opens as static asset responses.\n    const markStaticAsset = () => {\n      (req as { isStaticAsset?: boolean }).isStaticAsset = true;\n    };\n\n    if (result.status === 'not-modified') {\n      // Client's cache is still valid, send 304.\n      // Return HTTP 304 Not Modified response (no body). This saves bandwidth\n      // because the client reuses its cached representation.\n      //\n      // reply.hijack() bypasses Fastify's onSend pipeline (including the generic\n      // response-compression hook), so we write directly to the raw socket.\n      // onResponse hooks still fire because setupResponseListeners attaches to\n      // reply.raw.on('finish', ...) before any hooks run.\n\n      // Representation selection depends on Accept-Encoding, so advertise that\n      // caches must keep separate variants when compression is in play.\n      if (result.varyByAcceptEncoding) {\n        addToVaryHeader(reply, 'Accept-Encoding');\n      }\n\n      // A 304 carries metadata for the representation the client validated, so\n      // keep Content-Encoding aligned with the selected cached variant.\n      if (result.contentEncoding) {\n        reply.header('Content-Encoding', result.contentEncoding);\n      }\n\n      reply\n        .code(304)\n        .header('ETag', result.etag)\n        .header('Last-Modified', result.lastModified);\n\n      await req.applyCORSHeaders?.(reply);\n      markStaticAsset();\n      reply.hijack();\n      reply.raw.writeHead(304, reply.getHeaders() as OutgoingHttpHeaders);\n      reply.raw.end();\n\n      return { served: true, statusCode: 304 };\n    }\n\n    // File found (status === 'ok'), proceed with serving.\n    //\n    // reply.hijack() bypasses Fastify's onSend pipeline entirely, preventing\n    // the generic response-compression hook from re-processing a response whose\n    // representation (identity/gzip/br) and ETag were already finalized by\n    // getFile(). Without hijack(), the compression hook could re-compress an\n    // identity response and mutate the ETag the client already validated against.\n\n    // Determine Cache-Control header based on immutability\n    const headerCacheControl = result.isImmutableAsset\n      ? this.immutableCacheControl\n      : this.cacheControl;\n\n    // Representation selection depends on Accept-Encoding, so advertise that\n    // caches must keep separate variants when compression is in play.\n    if (result.varyByAcceptEncoding) {\n      addToVaryHeader(reply, 'Accept-Encoding');\n    }\n\n    // Only encoded representations send Content-Encoding; identity responses\n    // intentionally omit it even when compression was considered.\n    if (result.contentEncoding) {\n      reply.header('Content-Encoding', result.contentEncoding);\n    }\n\n    // Set common headers\n    reply\n      .header('Last-Modified', result.lastModified)\n      .header('ETag', result.etag)\n      .header('Cache-Control', headerCacheControl)\n      .type(result.mimeType);\n\n    // Only advertise range support for streaming files\n    // (files larger than smallFileMaxSize that are streamed from disk)\n    // Buffered files in memory don't support range requests\n    if (result.content.shouldStream) {\n      reply.header('Accept-Ranges', 'bytes');\n    }\n\n    // Check for Range header to handle partial content requests\n    const rangeHeader = req.headers.range;\n\n    // Handle range requests if present and file is being streamed\n    if (rangeHeader && result.content.shouldStream) {\n      const range = parseRange(rangeHeader, result.stat.size);\n\n      if (range === 'malformed') {\n        // Bad Request — syntactically invalid Range header\n        const body = JSON.stringify({ error: 'Invalid Range header' });\n        reply\n          .code(400)\n          .header('Cache-Control', 'no-store')\n          .type('application/json')\n          .header('Content-Length', String(Buffer.byteLength(body)));\n        await req.applyCORSHeaders?.(reply);\n        markStaticAsset();\n        reply.hijack();\n        reply.raw.writeHead(400, reply.getHeaders() as OutgoingHttpHeaders);\n        reply.raw.end(req.method === 'HEAD' ? undefined : body);\n        return { served: true, statusCode: 400 };\n      } else if (range === 'unsatisfiable') {\n        const body = JSON.stringify({ error: 'Range not satisfiable' });\n        reply\n          .code(416)\n          .header('Cache-Control', 'no-store')\n          .type('application/json')\n          .header('Content-Range', `bytes */${result.stat.size}`)\n          .header('Content-Length', String(Buffer.byteLength(body)));\n        await req.applyCORSHeaders?.(reply);\n        markStaticAsset();\n        reply.hijack();\n        reply.raw.writeHead(416, reply.getHeaders() as OutgoingHttpHeaders);\n        reply.raw.end(req.method === 'HEAD' ? undefined : body);\n        return { served: true, statusCode: 416 };\n      }\n\n      const [start, end] = range;\n      const chunkSize = end - start + 1;\n      const rangeStream = result.content.createStream({ start, end });\n\n      await waitForReadStreamOpen(rangeStream);\n\n      // Set headers for partial content response\n      reply\n        .code(206) // Partial Content\n        .header('Content-Range', `bytes ${start}-${end}/${result.stat.size}`)\n        .header('Content-Length', chunkSize.toString());\n\n      await req.applyCORSHeaders?.(reply);\n      markStaticAsset();\n      reply.hijack();\n      reply.raw.writeHead(206, reply.getHeaders() as OutgoingHttpHeaders);\n\n      // HEAD — headers are set; skip stream creation entirely (no fd opened, no disk I/O)\n      if (req.method === 'HEAD') {\n        reply.raw.end();\n        return { served: true, statusCode: 206 };\n      }\n\n      // Stream the requested range using factory function with range options\n      await pipeline(rangeStream, reply.raw);\n      return { served: true, statusCode: 206 };\n    }\n\n    // HEAD — set Content-Length from stat, then end without a body\n    if (req.method === 'HEAD') {\n      // When the response would be compressed, report the compressed size so\n      // the Content-Length matches what a GET would actually transfer.\n      // Compressed responses are always buffered (!shouldStream), so narrow first.\n      const headContentLength =\n        result.contentEncoding && !result.content.shouldStream\n          ? result.content.data.length\n          : result.stat.size;\n      reply.header('Content-Length', headContentLength.toString());\n      await req.applyCORSHeaders?.(reply);\n      markStaticAsset();\n      reply.hijack();\n      reply.raw.writeHead(\n        reply.statusCode,\n        reply.getHeaders() as OutgoingHttpHeaders,\n      );\n      reply.raw.end();\n      return { served: true, statusCode: 200 };\n    }\n\n    // Serve full file based on whether streaming is needed\n    const fullFileStream = result.content.shouldStream\n      ? result.content.createStream()\n      : null;\n\n    if (fullFileStream) {\n      await waitForReadStreamOpen(fullFileStream);\n    }\n\n    await req.applyCORSHeaders?.(reply);\n\n    if (!result.content.shouldStream) {\n      // reply.raw.end(buffer) does not get Fastify's normal Content-Length\n      // inference, so buffered 200 responses must set it explicitly here.\n      reply.header('Content-Length', result.content.data.length.toString());\n    }\n\n    markStaticAsset();\n    reply.hijack();\n    reply.raw.writeHead(200, reply.getHeaders() as OutgoingHttpHeaders);\n\n    if (result.content.shouldStream) {\n      // Large file — stream from disk directly to the socket.\n      // pipeline() propagates backpressure and destroys the stream on error.\n      await pipeline(fullFileStream as fs.ReadStream, reply.raw);\n    } else {\n      // Small file — send the buffered bytes in a single write.\n      reply.raw.end(result.content.data);\n    }\n\n    return { served: true, statusCode: 200 };\n  }\n\n  /**\n   * Replaces routing maps and clears all file caches in one shot.\n   *\n   * Use this after a full build has completed. Unlike `updateConfig`, this method\n   * makes no attempt at smart per-path invalidation — it simply replaces\n   * whichever maps you provide and wipes the content, stat, and ETag caches\n   * unconditionally, guaranteeing fresh reads for the next request.\n   *\n   * You may pass `singleAssetMap`, `folderMap`, or both. Omitted sections retain\n   * their current routing configuration. Pass an empty object (`{}`) for a\n   * section to clear all mappings in that section. All file caches are always\n   * cleared, regardless of which sections are provided — even when only\n   * `singleAssetMap` is passed, the rebuilt HTML pages likely reference JS/CSS\n   * bundles served from `folderMap` directories that were also regenerated in\n   * the same build step, so preserving folder caches would risk serving stale\n   * assets alongside fresh pages.\n   *\n   * For targeted cache invalidation (when URL-to-path mappings changed but\n   * file contents at those paths are unchanged), use `updateConfig` instead.\n   * Note: `updateConfig` does not detect in-place file content changes — it\n   * only tracks which filesystem paths entered or left the map.\n   *\n   * @param newConfig Sections to replace (at least one should be provided)\n   *\n   * @example\n   * ```typescript\n   * // After an SSG build completes (page map only):\n   * cache.replaceConfig({ singleAssetMap: await loadPageMap() });\n   *\n   * // After a build that changes both pages and asset folders:\n   * cache.replaceConfig({\n   *   singleAssetMap: await loadPageMap(),\n   *   folderMap: { '/assets/': { path: './dist/assets', detectImmutableAssets: true } },\n   * });\n   * ```\n   */\n  public replaceConfig(newConfig: {\n    singleAssetMap?: Record<string, string>;\n    folderMap?: Record<string, string | FolderConfig>;\n  }): void {\n    if (newConfig.singleAssetMap !== undefined) {\n      this.singleAssetMap = this.normalizeSingleAssetMap(\n        newConfig.singleAssetMap,\n      );\n    }\n\n    if (newConfig.folderMap !== undefined) {\n      this.folderMap = this.normalizeFolderMap(newConfig.folderMap);\n    }\n\n    // Always clear all caches — no smart invalidation.\n    // A build can change file contents in-place without renaming files,\n    // so preserving any cached content or stat data would risk stale reads.\n    this.clearCaches();\n  }\n\n  /**\n   * Evicts a single file's cached content, stat, and ETag without touching\n   * any URL-to-path mappings.\n   *\n   * Use this when you know a specific file changed on disk and want to force\n   * a fresh read on the next request — without flushing the entire cache.\n   * Works for files served via `singleAssetMap` or `folderMap`.\n   *\n   * The parameter is the **filesystem path** (as it appears in the cache key),\n   * not a URL.\n   *\n   * For `singleAssetMap` entries these are the absolute paths you\n   * provided.\n   *\n   * For folder-served files the cache key is the absolute path\n   * resolved at request time.\n   *\n   * @param fsPath Absolute filesystem path of the file to evict\n   *\n   * @example\n   * ```typescript\n   * // A file watcher detected /dist/about.html was rewritten:\n   * cache.invalidateFile('/dist/about.html');\n   * ```\n   */\n  public invalidateFile(fsPath: string): void {\n    this.etagCache.delete(fsPath);\n    this.contentCache.delete(fsPath);\n    this.statCache.delete(fsPath);\n    this.invalidateCompressedVariants(fsPath);\n  }\n\n  /**\n   * Clears all caches (useful for testing or cache invalidation)\n   */\n  public clearCaches(): void {\n    this.etagCache.clear();\n    this.contentCache.clear();\n    this.compressedVariantCache.clear();\n    this.statCache.clear();\n    this.compressedContentIndex.clear();\n  }\n\n  /**\n   * Gets statistics about cache usage\n   */\n  public getCacheStats() {\n    return {\n      etag: {\n        items: this.etagCache.size,\n        byteSize: this.etagCache.byteSize,\n      },\n      content: {\n        items: this.contentCache.size,\n        byteSize: this.contentCache.byteSize,\n      },\n      compressedVariants: {\n        items: this.compressedVariantCache.size,\n        byteSize: this.compressedVariantCache.byteSize,\n      },\n      stat: {\n        items: this.statCache.size,\n        byteSize: this.statCache.byteSize,\n      },\n    };\n  }\n\n  /**\n   * Updates the static content configuration at runtime with targeted cache\n   * invalidation — only evicting entries whose URL-to-path mapping changed.\n   *\n   * Use this when URL routing is changing but file contents at existing paths\n   * are unchanged (e.g., adding or removing pages without rebuilding assets).\n   * For post-build reloads where file contents may have changed, use\n   * `replaceConfig` instead.\n   *\n   * **Important:** When providing a section, you must provide the COMPLETE mapping for that section.\n   * - If you provide `singleAssetMap`, it replaces the entire single asset map\n   * - If you provide `folderMap`, it replaces the entire folder map\n   * - You can update one section, the other, or both\n   * - Omitted sections remain unchanged\n   *\n   * **Cache invalidation strategy:**\n   * - `singleAssetMap` changes: Only invalidates filesystem paths whose URL-to-path\n   *   *mapping* changed (added, removed, or pointed to a different file). Paths whose\n   *   mapping is unchanged are not evicted — this method has no visibility into whether\n   *   the file content on disk changed. If files were rebuilt in-place, use\n   *   `replaceConfig` instead.\n   * - `folderMap` changes: Clears all caches (folder changes are structural)\n   *\n   * @param newConfig Complete mapping(s) for the section(s) you want to update\n   *\n   * @example Update only single asset mappings\n   * ```typescript\n   * cache.updateConfig({\n   *   singleAssetMap: {\n   *     '/': './dist/index.html',\n   *     '/blog/new-post': './dist/blog/new-post.html'\n   *   }\n   * });\n   * ```\n   *\n   * @example Update only folder mappings\n   * ```typescript\n   * cache.updateConfig({\n   *   folderMap: {\n   *     '/assets': { path: './dist/assets', detectImmutableAssets: true }\n   *   }\n   * });\n   * ```\n   *\n   * @example Update both sections\n   * ```typescript\n   * cache.updateConfig({\n   *   singleAssetMap: { '/': './dist/index.html' },\n   *   folderMap: { '/assets': './dist/assets' }\n   * });\n   * ```\n   */\n  public updateConfig(newConfig: {\n    singleAssetMap?: Record<string, string>;\n    folderMap?: Record<string, string | FolderConfig>;\n  }): void {\n    // Handle singleAssetMap - smart invalidation of specific filesystem paths\n    if (newConfig.singleAssetMap !== undefined) {\n      const newMap = this.normalizeSingleAssetMap(newConfig.singleAssetMap);\n\n      // Track filesystem paths that need cache invalidation\n      const pathsToInvalidate = new Set<string>();\n\n      // Loop 1: Iterate over OLD map to find filesystem paths that are no longer in use\n      // Example: '/page' used to point to '/dist/old.html', now points to '/dist/new.html'\n      // Result: Invalidate '/dist/old.html' (the old file's cache is stale)\n      for (const [url, oldFsPath] of this.singleAssetMap.entries()) {\n        const newFsPath = newMap.get(url);\n\n        // If URL was removed OR now points to a different file, invalidate OLD filesystem path\n        if (newFsPath === undefined || newFsPath !== oldFsPath) {\n          pathsToInvalidate.add(oldFsPath);\n        }\n      }\n\n      // Loop 2: Iterate over NEW map to find filesystem paths that changed\n      // Example: '/page' used to point to '/dist/old.html', now points to '/dist/new.html'\n      // Result: Invalidate '/dist/new.html' (ensure fresh read from disk)\n      // IMPORTANT: This prevents serving stale cached data if the new file was already cached\n      // from a previous mapping (e.g., same file was previously mapped to a different URL)\n      for (const [url, newFsPath] of newMap.entries()) {\n        const oldFsPath = this.singleAssetMap.get(url);\n\n        // Only invalidate NEW filesystem path if URL existed before AND now points to different file\n        if (oldFsPath !== undefined && oldFsPath !== newFsPath) {\n          pathsToInvalidate.add(newFsPath);\n        }\n      }\n\n      // Replace the map\n      this.singleAssetMap = newMap;\n\n      // Invalidate caches for affected filesystem paths only\n      for (const fsPath of pathsToInvalidate) {\n        this.etagCache.delete(fsPath);\n        this.contentCache.delete(fsPath);\n        this.statCache.delete(fsPath);\n        this.invalidateCompressedVariants(fsPath);\n      }\n    }\n\n    // Handle folderMap - clear all caches only if it changed\n    if (newConfig.folderMap !== undefined) {\n      const newFolderMap = this.normalizeFolderMap(newConfig.folderMap);\n\n      // Check if folderMap actually changed\n      // Note: Can't just compare size - could have same number of folders but different prefixes/configs\n      let hasFolderMapChanged = false;\n\n      // Quick check: if sizes differ, it definitely changed\n      if (this.folderMap.size !== newFolderMap.size) {\n        hasFolderMapChanged = true;\n      } else {\n        // Sizes match - need to check if any prefix or config changed\n        for (const [prefix, config] of newFolderMap.entries()) {\n          const oldConfig = this.folderMap.get(prefix);\n\n          if (!oldConfig || !this.isSameFolderConfig(oldConfig, config)) {\n            hasFolderMapChanged = true;\n            break;\n          }\n        }\n      }\n\n      this.folderMap = newFolderMap;\n\n      // Only clear all caches if folderMap actually changed\n      // Folder mapping changes are rare and structural - clearing everything is safe\n      if (hasFolderMapChanged) {\n        this.clearCaches();\n      }\n    }\n  }\n\n  /**\n   * Handles an HTTP request by resolving the URL to a file path and serving it\n   *\n   * This is a convenience method that combines URL resolution with file serving.\n   * If no file matches the URL, it returns without sending a response (lets the hook fall through).\n   *\n   * @param rawURL The raw request URL (may include query string or hash)\n   * @param req The Fastify request object\n   * @param reply The Fastify reply object\n   * @returns Information about whether a file was served\n   */\n  public async handleRequest(\n    rawURL: string,\n    req: FastifyRequest,\n    reply: FastifyReply,\n  ): Promise<ServeFileResult> {\n    // Strip off query string, hash, etc., and ensure a single leading slash for matching\n    const cleanedURL = rawURL.split('?')[0].split('#')[0];\n    const url = cleanedURL.startsWith('/') ? cleanedURL : '/' + cleanedURL;\n\n    // Security: Reject URLs containing null bytes to prevent path truncation attacks\n    if (url.includes('\\0')) {\n      return { served: false, reason: 'not-found' };\n    }\n\n    let resolved = '';\n    let shouldDetectImmutable = false;\n\n    // 1. Try singleAssetMap first (exact URL → file)\n    if (this.singleAssetMap.has(url)) {\n      resolved = this.singleAssetMap.get(url) as string;\n    }\n    // 2. If not matched, try folderMap (URL prefix → directory)\n    else {\n      const folder = Array.from(this.folderMap.keys()).find((prefix) =>\n        url.startsWith(prefix),\n      );\n\n      if (folder) {\n        // Get resolved base folder and config\n        const folderConfig = this.folderMap.get(folder);\n\n        if (folderConfig) {\n          // Calculate file path relative to the matched prefix\n          const relativePath = url.slice(folder.length);\n\n          // Guard against absolute path behavior if a leading slash sneaks in\n          const safeRelativePath = relativePath.startsWith('/')\n            ? relativePath.slice(1)\n            : relativePath;\n\n          // Only allow files that don't contain '..' to prevent directory traversal\n          if (\n            !safeRelativePath.includes('../') &&\n            !safeRelativePath.includes('..\\\\')\n          ) {\n            resolved = path.join(folderConfig.path, safeRelativePath);\n            shouldDetectImmutable = folderConfig.detectImmutableAssets ?? false;\n          }\n        }\n      }\n    }\n\n    // If we found a file to serve, serve it\n    // otherwise: return not-found (let hook fall through)\n    if (resolved) {\n      return this.serveFile(req, reply, resolved, { shouldDetectImmutable });\n    }\n\n    return { served: false, reason: 'not-found' };\n  }\n\n  /**\n   * Normalizes single asset map keys to ensure leading slash\n   * Also validates against null bytes to prevent path injection\n   */\n  private normalizeSingleAssetMap(\n    singleAssetMap: Record<string, string>,\n  ): Map<string, string> {\n    const normalized = new Map<string, string>();\n\n    for (const [key, value] of Object.entries(singleAssetMap)) {\n      // Security: Skip entries with null bytes to prevent path truncation attacks\n      if (key.includes('\\0') || value.includes('\\0')) {\n        if (this.logger) {\n          this.logger.warn(\n            { key, value },\n            'Skipping singleAssetMap entry with null byte',\n          );\n        }\n\n        continue;\n      }\n\n      const normalizedKey = key.startsWith('/') ? key : '/' + key;\n      normalized.set(normalizedKey, value);\n    }\n\n    return normalized;\n  }\n\n  /**\n   * Normalizes folder map with proper prefix formatting\n   * Also validates against null bytes to prevent path injection\n   *\n   * Handles two config formats:\n   * 1. String shorthand: { \"/assets/\": \"/path/to/assets\" }\n   * 2. Full config object: { \"/assets/\": { path: \"/path/to/assets\", detectImmutableAssets: true } }\n   */\n  private normalizeFolderMap(\n    folderMap: Record<string, string | FolderConfig>,\n  ): Map<string, FolderConfig> {\n    const normalized = new Map<string, FolderConfig>();\n\n    for (const [prefix, config] of Object.entries(folderMap)) {\n      const normalizedPrefix = this.normalizePrefix(prefix);\n\n      // Security: Skip entries with null bytes to prevent path truncation attacks\n      const configPath = typeof config === 'string' ? config : config.path;\n      if (prefix.includes('\\0') || configPath.includes('\\0')) {\n        if (this.logger) {\n          this.logger.warn(\n            { prefix, configPath },\n            'Skipping folderMap entry with null byte',\n          );\n        }\n\n        continue;\n      }\n\n      // Handle string shorthand: just a directory path\n      if (typeof config === 'string') {\n        normalized.set(normalizedPrefix, {\n          path: config,\n          detectImmutableAssets: false,\n        });\n      } else {\n        // Handle full config object with optional detectImmutableAssets\n        normalized.set(normalizedPrefix, {\n          path: config.path,\n          detectImmutableAssets: config.detectImmutableAssets ?? false,\n        });\n      }\n    }\n\n    return normalized;\n  }\n\n  /**\n   * Normalizes URL prefix: ensures leading and trailing slash, collapses multiple slashes\n   */\n  private normalizePrefix(prefix: string): string {\n    let p = prefix || '/';\n\n    // Collapse multiple consecutive slashes into a single slash\n    p = p.replace(/\\/+/g, '/');\n\n    if (!p.startsWith('/')) {\n      p = '/' + p;\n    }\n\n    if (!p.endsWith('/')) {\n      p = p + '/';\n    }\n\n    return p;\n  }\n\n  /**\n   * Gets the MIME type for a file based on its extension\n   */\n  private getMimeType(filePath: string): string {\n    // Strip the leading dot from the extension\n    const ext = path.extname(filePath).toLowerCase().replace(/^\\./, '');\n\n    // Map common extensions to MIME types (alphabetical order)\n    const mimeTypes: Record<string, string> = {\n      css: 'text/css',\n      gif: 'image/gif',\n      html: 'text/html',\n      ico: 'image/x-icon',\n      jpeg: 'image/jpeg',\n      jpg: 'image/jpeg',\n      js: 'application/javascript',\n      json: 'application/json',\n      mp4: 'video/mp4',\n      pdf: 'application/pdf',\n      png: 'image/png',\n      svg: 'image/svg+xml',\n      txt: 'text/plain',\n      webmanifest: 'application/manifest+json',\n      xml: 'application/xml',\n    };\n\n    return mimeTypes[ext] || 'application/octet-stream';\n  }\n\n  /**\n   * Compares two FolderConfig objects for equality\n   * Dynamically checks all properties so we don't need to update this if FolderConfig changes\n   */\n  private isSameFolderConfig(a: FolderConfig, b: FolderConfig): boolean {\n    const keysA = Object.keys(a) as (keyof FolderConfig)[];\n    const keysB = Object.keys(b) as (keyof FolderConfig)[];\n\n    // Different number of keys means they're not equal\n    if (keysA.length !== keysB.length) {\n      return false;\n    }\n\n    // Check all keys from a (sufficient now since lengths match)\n    return keysA.every((key) => a[key] === b[key]);\n  }\n\n  /**\n   * Checks if a file appears to be fingerprinted/immutable based on filename\n   *\n   * Detects common build tool fingerprinting patterns:\n   * - .{hash}.{ext} format (e.g., main.a1b2c3d4.js, styles.CTpDmzGw.css)\n   * - -{hash}.{ext} format (e.g., chunk-a1b2c3d4.js, vendor-5f8e9a2b.js)\n   *\n   * Hash must be at least 6 alphanumeric characters\n   *\n   * @param filePath The file path to check\n   * @returns True if the file appears to be fingerprinted\n   */\n  private isImmutableAsset(filePath: string): boolean {\n    const fileBasename = path.basename(filePath);\n\n    // Check for fingerprint patterns:\n    // 1. .{hash}.{ext} pattern (e.g., main.CTpDmzGw.js)\n    // 2. -{hash}.{ext} pattern (e.g., chunk-CTpDmzGw.js)\n    return (\n      /\\.[A-Za-z0-9]{6,}\\./.test(fileBasename) ||\n      /-[A-Za-z0-9]{6,}\\./.test(fileBasename)\n    );\n  }\n\n  private getCompressedCacheKey(\n    resolvedPath: string,\n    etag: string,\n    encoding: string,\n  ): string {\n    return `${resolvedPath}::${etag}::${encoding}`;\n  }\n\n  private invalidateCompressedVariants(fsPath: string): void {\n    // Leave a short-lived tombstone for each invalidated compressed variant so\n    // an older in-flight request cannot immediately repopulate the same stale\n    // path + base ETag + encoding entry after invalidateFile() runs.\n    const keys = this.compressedContentIndex.get(fsPath);\n\n    if (!keys) {\n      return;\n    }\n\n    for (const key of keys) {\n      // Replace the current variant state with a short-lived tombstone so an\n      // older in-flight request cannot immediately repopulate the same stale\n      // compressed variant after invalidateFile() runs.\n      this.compressedVariantCache.set(key, { kind: 'tombstone' }, 5 * 1000);\n    }\n\n    this.compressedContentIndex.delete(fsPath);\n  }\n\n  private handleCompressedVariantCacheChange(\n    event: LRUCacheChangeEvent<string, CompressedVariantState>,\n  ): void {\n    if (\n      event.reason !== 'evict' &&\n      event.reason !== 'expired' &&\n      event.reason !== 'delete' &&\n      event.reason !== 'clear'\n    ) {\n      return;\n    }\n\n    // The compressed LRU is keyed by path + base ETag + encoding, but the\n    // reverse index is keyed only by filesystem path.\n    this.untrackCompressedVariantByKey(event.key);\n  }\n\n  private untrackCompressedVariantByKey(cacheKey: string): void {\n    // Compressed cache keys are stored as path + base ETag + encoding, so drop\n    // the final two segments to recover the filesystem path used by the index.\n    const keyParts = cacheKey.split('::');\n    const fsPath = keyParts.slice(0, -2).join('::');\n\n    this.untrackCompressedVariant(fsPath, cacheKey);\n  }\n\n  private untrackCompressedVariant(fsPath: string, cacheKey: string): void {\n    // Missing index state is harmless here. This map only helps invalidate all\n    // variant keys for a file path later, it is not consulted when choosing\n    // what bytes or variant state to serve for the current request.\n    const existing = this.compressedContentIndex.get(fsPath);\n\n    if (!existing) {\n      return;\n    }\n\n    existing.delete(cacheKey);\n\n    // Remove the path entry entirely once no compressed variants remain for it.\n    if (existing.size === 0) {\n      this.compressedContentIndex.delete(fsPath);\n    }\n  }\n}\n","import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';\nimport { brotliCompress, constants as zlibConstants, gzip } from 'node:zlib';\nimport { promisify } from 'node:util';\nimport type { ResponseCompressionOptions } from '../types';\nimport { addToVaryHeader } from './http-header-utils';\n\nconst gzipAsync = promisify(gzip);\nconst brotliCompressAsync = promisify(brotliCompress);\n\nexport type ResponseEncoding = 'br' | 'gzip';\n\nexport interface NormalizedResponseCompressionOptions {\n  enabled: boolean;\n  threshold: number;\n  preferBrotli: boolean;\n  brotliQuality: number;\n  gzipLevel: number;\n}\n\nconst DEFAULT_OPTIONS: NormalizedResponseCompressionOptions = {\n  enabled: true,\n  threshold: 1024,\n  preferBrotli: true,\n  brotliQuality: 4,\n  gzipLevel: 6,\n};\n\nconst COMPRESSIBLE_CONTENT_TYPE_PREFIXES = [\n  'text/',\n  'application/json',\n  'application/ld+json',\n  'application/manifest+json',\n  'application/javascript',\n  'text/javascript',\n  'application/xml',\n  'text/xml',\n  'application/xhtml+xml',\n  'application/rss+xml',\n  'application/atom+xml',\n  'image/svg+xml',\n];\n\n/**\n * Normalize the public boolean/object config into one internal shape so the\n * hot path does not need to branch on user-facing option forms.\n */\nexport function normalizeResponseCompressionOptions(\n  options: boolean | ResponseCompressionOptions | undefined,\n): NormalizedResponseCompressionOptions {\n  if (options === false) {\n    return {\n      ...DEFAULT_OPTIONS,\n      enabled: false,\n    };\n  }\n\n  if (options === true || options === undefined) {\n    return { ...DEFAULT_OPTIONS };\n  }\n\n  return {\n    enabled: options.enabled ?? DEFAULT_OPTIONS.enabled,\n    threshold: options.threshold ?? DEFAULT_OPTIONS.threshold,\n    preferBrotli: options.preferBrotli ?? DEFAULT_OPTIONS.preferBrotli,\n    brotliQuality: options.brotliQuality ?? DEFAULT_OPTIONS.brotliQuality,\n    gzipLevel: options.gzipLevel ?? DEFAULT_OPTIONS.gzipLevel,\n  };\n}\n\n/**\n * Minimal content-type allowlist for generic response compression.\n *\n * Static file handling uses MIME metadata from the file layer, but the\n * server-level onSend hook needs a straightforward check based only on the\n * response header that was already chosen.\n */\nexport function isCompressibleContentType(\n  contentType: string | undefined,\n): boolean {\n  if (!contentType) {\n    return false;\n  }\n\n  const normalized = contentType.split(';')[0]?.trim().toLowerCase();\n\n  if (!normalized) {\n    return false;\n  }\n\n  return COMPRESSIBLE_CONTENT_TYPE_PREFIXES.some((prefix) =>\n    normalized.startsWith(prefix),\n  );\n}\n\n/**\n * Parse Accept-Encoding into a simple encoding -> q-value map.\n *\n * This intentionally supports the small subset we need: direct `br`, `gzip`,\n * and wildcard fallback. We do not need full RFC-grade preference sorting for\n * the current use case.\n */\nfunction parseAcceptEncoding(\n  acceptEncoding: string | string[] | undefined,\n): Map<string, number> {\n  const header = Array.isArray(acceptEncoding)\n    ? acceptEncoding.join(',')\n    : acceptEncoding;\n  const values = new Map<string, number>();\n\n  if (!header) {\n    return values;\n  }\n\n  for (const part of header.split(',')) {\n    const [encodingRaw, ...params] = part.trim().split(';');\n\n    if (!encodingRaw) {\n      continue;\n    }\n\n    let quality = 1;\n\n    for (const param of params) {\n      const [key, value] = param.trim().split('=');\n\n      if (key === 'q' && value) {\n        const parsed = Number.parseFloat(value);\n\n        if (!Number.isNaN(parsed)) {\n          quality = parsed;\n        }\n      }\n    }\n\n    values.set(encodingRaw.toLowerCase(), quality);\n  }\n\n  return values;\n}\n\n/**\n * Choose the best supported encoding for the current request.\n *\n * Preference is controlled by config (`preferBrotli`), while q-values still\n * gate whether an encoding is allowed at all.\n */\nexport function selectResponseEncoding(\n  acceptEncoding: string | string[] | undefined,\n  shouldPreferBrotli: boolean,\n): ResponseEncoding | null {\n  const parsed = parseAcceptEncoding(acceptEncoding);\n  const brQuality = parsed.get('br') ?? parsed.get('*') ?? 0;\n  const gzipQuality = parsed.get('gzip') ?? parsed.get('*') ?? 0;\n\n  if (brQuality <= 0 && gzipQuality <= 0) {\n    return null;\n  }\n\n  if (brQuality > gzipQuality) {\n    return 'br';\n  }\n\n  if (gzipQuality > brQuality) {\n    return 'gzip';\n  }\n\n  return shouldPreferBrotli\n    ? brQuality > 0\n      ? 'br'\n      : 'gzip'\n    : gzipQuality > 0\n      ? 'gzip'\n      : 'br';\n}\n\n/**\n * Derive a representation-specific ETag from a base file/response ETag.\n *\n * This lets compressed and identity responses vary independently while still\n * preserving the original validator as the underlying content identity.\n */\nexport function buildEncodedETag(\n  etag: string,\n  encoding: ResponseEncoding,\n): string {\n  const isWeakPrefix = etag.startsWith('W/');\n  const quoted = isWeakPrefix ? etag.slice(2) : etag;\n\n  if (quoted.startsWith('\"') && quoted.endsWith('\"')) {\n    return `${isWeakPrefix ? 'W/' : ''}\"${quoted.slice(1, -1)}--${encoding}\"`;\n  }\n\n  return `${etag}--${encoding}`;\n}\n\n/**\n * Simple If-None-Match matcher for the exact representation ETag we are about\n * to send. Wildcard `*` is supported because callers may use it to indicate\n * \"any current representation\".\n */\nexport function matchesIfNoneMatch(\n  ifNoneMatchHeader: string | string[] | undefined,\n  etag: string,\n): boolean {\n  const header = Array.isArray(ifNoneMatchHeader)\n    ? ifNoneMatchHeader.join(',')\n    : ifNoneMatchHeader;\n\n  if (!header) {\n    return false;\n  }\n\n  const normalizeWeakETag = (value: string): string =>\n    value.startsWith('W/') ? value.slice(2) : value;\n  const normalizedETag = normalizeWeakETag(etag);\n\n  return header\n    .split(',')\n    .map((value) => value.trim())\n    .some(\n      (value) => value === '*' || normalizeWeakETag(value) === normalizedETag,\n    );\n}\n\nexport async function compressPayload(\n  payload: Buffer,\n  encoding: ResponseEncoding,\n  options: NormalizedResponseCompressionOptions,\n): Promise<Buffer> {\n  if (encoding === 'br') {\n    return brotliCompressAsync(payload, {\n      params: {\n        [zlibConstants.BROTLI_PARAM_QUALITY]: options.brotliQuality,\n      },\n    });\n  }\n\n  return gzipAsync(payload, {\n    level: options.gzipLevel,\n  });\n}\n\n/**\n * Compress a non-streaming reply payload when it is safe and worthwhile.\n *\n * Important constraints:\n * - skips replies that already selected a representation (Content-Encoding)\n * - skips ranged responses because byte ranges and on-the-fly compression do\n *   not compose cleanly\n * - skips small payloads to avoid wasting CPU and bytes\n *\n * The hook is deliberately representation-aware:\n * - once we choose gzip/br, we must treat the compressed bytes as a distinct\n *   HTTP representation with their own validator (`ETag`)\n * - `HEAD` must negotiate the same representation metadata as `GET`, even\n *   though no body will actually be written to the socket\n * - `If-None-Match` must be evaluated against the representation we are about\n *   to send, not the original uncompressed bytes\n */\nexport async function compressReplyPayload(\n  request: FastifyRequest,\n  reply: FastifyReply,\n  payload: unknown,\n  options: boolean | ResponseCompressionOptions | undefined,\n): Promise<unknown> {\n  const normalized = normalizeResponseCompressionOptions(options);\n\n  if (!normalized.enabled) {\n    return payload;\n  }\n\n  // Safety guard: if headers are already written (e.g. hijacked reply or a\n  // race in edge cases), adding compression headers would be a no-op at best\n  // and would corrupt the response at worst. Bail out early.\n  if (reply.sent || reply.raw?.headersSent) {\n    return payload;\n  }\n\n  if (\n    reply.statusCode < 200 ||\n    reply.statusCode === 204 ||\n    reply.statusCode === 304\n  ) {\n    return payload;\n  }\n\n  if (request.headers.range || reply.hasHeader('Content-Range')) {\n    return payload;\n  }\n\n  if (reply.hasHeader('Content-Encoding')) {\n    return payload;\n  }\n\n  const contentType = reply.getHeader('Content-Type') as string | undefined;\n\n  if (!isCompressibleContentType(contentType)) {\n    return payload;\n  }\n\n  // This generic hook only handles fully materialized payloads. Streamed\n  // responses stay on their existing path because on-the-fly compression would\n  // need different range/backpressure semantics.\n  if (typeof payload !== 'string' && !Buffer.isBuffer(payload)) {\n    return payload;\n  }\n\n  const bufferPayload =\n    typeof payload === 'string' ? Buffer.from(payload) : payload;\n\n  // Match the static-content behavior: small bodies usually are not worth the\n  // extra CPU, header bytes, and cache fragmentation that compression adds.\n  if (bufferPayload.length < normalized.threshold) {\n    return payload;\n  }\n\n  // Only add Vary when the payload is otherwise eligible for compression.\n  // That keeps the header tied to actual response-shape variation.\n  addToVaryHeader(reply, 'Accept-Encoding');\n\n  const encoding = selectResponseEncoding(\n    request.headers['accept-encoding'],\n    normalized.preferBrotli,\n  );\n\n  if (!encoding) {\n    return payload;\n  }\n\n  // Compression is attempted before mutating representation headers so we can\n  // bail out cleanly when the encoded bytes are not actually smaller.\n  const compressed = await compressPayload(bufferPayload, encoding, normalized);\n\n  if (compressed.length >= bufferPayload.length) {\n    return payload;\n  }\n\n  // If upstream already attached an ETag to the identity response, convert it\n  // into a representation-specific validator before the reply leaves the hook.\n  // Otherwise caches would see the same ETag for both compressed and\n  // uncompressed bytes and could serve or validate the wrong representation.\n  const existingETag = reply.getHeader('ETag');\n  const currentETag = Array.isArray(existingETag)\n    ? existingETag[0]\n    : existingETag;\n\n  if (typeof currentETag === 'string' && currentETag.length > 0) {\n    reply.header('ETag', buildEncodedETag(currentETag, encoding));\n  }\n\n  const encodedETag = reply.getHeader('ETag');\n  const responseETag =\n    typeof encodedETag === 'string'\n      ? encodedETag\n      : Array.isArray(encodedETag) && typeof encodedETag[0] === 'string'\n        ? encodedETag[0]\n        : undefined;\n\n  reply.header('Content-Encoding', encoding);\n\n  // Conditional GET/HEAD must be checked against the final representation\n  // validator, not the base identity ETag. If the client already has the\n  // encoded variant we can return 304 without sending the compressed body.\n  if (\n    responseETag &&\n    matchesIfNoneMatch(request.headers['if-none-match'], responseETag)\n  ) {\n    reply.code(304);\n    reply.removeHeader('Content-Length');\n    return '';\n  }\n\n  if (request.method === 'HEAD') {\n    // HEAD still needs to advertise the metadata of the representation that a\n    // GET would have produced after negotiation.\n    //\n    // Fastify still uses the payload returned from onSend to derive outgoing\n    // metadata for HEAD responses. Returning the compressed buffer keeps the\n    // wire-level Content-Length aligned with the corresponding GET, while\n    // Fastify itself suppresses the actual response body for HEAD.\n    reply.header('Content-Length', compressed.length.toString());\n    return compressed;\n  }\n\n  // We already know the exact compressed byte length here, so set it\n  // explicitly instead of relying on later framework inference.\n  reply.header('Content-Length', compressed.length.toString());\n\n  return compressed;\n}\n\n/**\n * Register the generic response-compression hook for dynamic API/web replies.\n *\n * Static file serving uses its own representation-selection path so it can keep\n * ETags, range requests, and cache invalidation tied to concrete file variants.\n *\n * This hook uses an async buffer approach (brotli/gzip in memory) rather than a\n * streaming Transform. The async approach preserves Content-Length, ETag\n * representation variants, and If-None-Match 304 responses.\n *\n * Safety note: async onSend hooks interact with Fastify 5's wrapThenable mechanism.\n * When an async route handler calls reply.send() and returns undefined,\n * wrapThenable can fire a second reply.send(undefined) while the async onSend\n * hook is still pending (reply.sent may remain false until headers are written).\n *\n * To avoid this race, Unirend async route handlers return the payload directly\n * instead of calling reply.send() manually. That lets wrapThenable make exactly\n * one reply.send() call.\n */\nexport function registerResponseCompression(\n  fastifyInstance: FastifyInstance,\n  options: boolean | ResponseCompressionOptions | undefined,\n): void {\n  const normalized = normalizeResponseCompressionOptions(options);\n\n  if (!normalized.enabled) {\n    return;\n  }\n\n  fastifyInstance.addHook('onSend', async (request, reply, payload) => {\n    try {\n      return await compressReplyPayload(request, reply, payload, normalized);\n    } catch (error) {\n      // Compression failure must not take down the server — fall back to the\n      // uncompressed payload so the request still completes.\n      request.log.error(\n        { error },\n        'Response compression failed; sending uncompressed',\n      );\n      return payload;\n    }\n  });\n}\n","import type { FastifyRequest, FastifyReply } from 'fastify';\nimport { StaticContentCache } from './static-content-cache';\nimport type {\n  StaticContentWarnLoggerObject,\n  ServeFileResult,\n} from './static-content-cache';\nimport type { StaticContentRouterOptions } from '../types';\n\n/**\n * Static content hook handler that delegates to a StaticContentCache instance.\n *\n * Performs safety checks before delegating to the cache:\n * - Only handles GET requests\n * - Requires a valid URL to be present\n *\n * @param cache StaticContentCache instance to delegate to\n * @param req Fastify request object\n * @param reply Fastify reply object\n * @returns Promise that resolves to ServeFileResult, or undefined if request was filtered out\n * @internal Shared handler logic used by createStaticContentHook and SSRServer\n */\nexport async function staticContentHookHandler(\n  cache: StaticContentCache,\n  req: FastifyRequest,\n  reply: FastifyReply,\n): Promise<ServeFileResult | undefined> {\n  // Only handle GET and HEAD requests — all other methods (POST, PUT, etc.) fall through\n  if (req.method !== 'GET' && req.method !== 'HEAD') {\n    return;\n  }\n\n  // If there's no URL, we can't handle it\n  if (!req.raw.url) {\n    return;\n  }\n\n  // Delegate to cache to handle URL cleaning, resolution, and file serving\n  return cache.handleRequest(req.raw.url, req, reply);\n}\n\n/**\n * Creates a static content serving hook with its own caches and configuration.\n *\n * Rationale:\n * - Unlike generic static handlers which may check disk for every path or apply\n *   wildcard matching, this only hits the filesystem when:\n *     1) the request URL exactly matches an entry in `singleAssetMap`, or\n *     2) it falls under a configured prefix in `folderMap`.\n * - Adds strong ETag support with optional LRU caching of ETag values and small file content.\n * - Caches file stat results to avoid repeated `stat()` calls.\n * - This minimizes unnecessary disk I/O, improves performance, and locks down\n *   asset serving to known files and directories, preventing accidental exposure\n *   or directory traversal beyond the intended public paths.\n *\n * Each call creates an independent instance with its own caches, allowing multiple\n * instances to be registered with different configurations.\n *\n * @param optionsOrCache Static content configuration OR an existing StaticContentCache instance\n * @param logger Optional logger (e.g., fastify.log) for error logging (ignored if cache instance provided)\n * @returns Fastify onRequest hook handler function\n * @internal Used by SSRServer (internal) and staticContent() plugin (public API)\n */\nexport function createStaticContentHook(\n  optionsOrCache: StaticContentRouterOptions | StaticContentCache,\n  logger?: StaticContentWarnLoggerObject,\n) {\n  // Determine cache source: use provided instance or create new one from options\n  let cache: StaticContentCache;\n\n  if (optionsOrCache instanceof StaticContentCache) {\n    // Using externally-created cache instance (for runtime updates)\n    cache = optionsOrCache;\n    // Note: logger parameter is ignored when cache instance is provided\n  } else {\n    // Creating new cache from configuration options\n    cache = new StaticContentCache(optionsOrCache, logger);\n  }\n\n  // Return the hook handler using shared handler logic\n  return async (req: FastifyRequest, reply: FastifyReply) => {\n    return staticContentHookHandler(cache, req, reply);\n  };\n}\n","import type { ServerPlugin, StaticContentRouterOptions } from '../types';\nimport { createStaticContentHook } from '../internal/static-content-hook';\nimport { StaticContentCache } from '../internal/static-content-cache';\n\n// Re-export the options type for convenience\n\n// Also re-export FolderConfig since users will need it for folderMap\nexport type { FolderConfig, StaticContentRouterOptions } from '../types';\n\n// Re-export StaticContentCache for external cache management\n\n/**\n * Creates a static content serving plugin that can be used with any Unirend server.\n *\n * This plugin serves static files from configured paths with:\n * - Efficient file caching and ETag support for conditional requests\n * - Content-based strong ETags for small files (SHA-256)\n * - Weak ETags for large files (size + mtime based)\n * - LRU caching for stats, content, and ETags\n * - Range request support for large files\n * - Immutable asset detection for fingerprinted files (optional)\n *\n * Multiple instances can be registered with different configurations,\n * allowing you to serve files from different directories with different settings.\n *\n * @example Basic usage - serve uploads folder\n * ```typescript\n * import { staticContent } from 'unirend/plugins';\n *\n * const server = serveSSRDev(paths, {\n *   plugins: [\n *     staticContent({\n *       folderMap: {\n *         '/uploads': './uploads',\n *         '/static': './public/static',\n *       },\n *     }),\n *   ],\n * });\n * ```\n *\n * @example Multiple folders with different settings\n * ```typescript\n * import { staticContent } from 'unirend/plugins';\n *\n * const server = serveSSRProd(buildDir, {\n *   plugins: [\n *     // User uploads - no immutable caching\n *     staticContent({\n *       folderMap: {\n *         '/uploads': { path: './uploads', detectImmutableAssets: false },\n *       },\n *     }),\n *     // Static assets with fingerprinted filenames - immutable caching\n *     staticContent({\n *       folderMap: {\n *         '/static': { path: './public/static', detectImmutableAssets: true },\n *       },\n *     }),\n *   ],\n * });\n * ```\n *\n * @example Custom plugin name for debugging and dependencies\n * ```typescript\n * const server = serveSSRProd(buildDir, {\n *   plugins: [\n *     staticContent({\n *       folderMap: { '/uploads': './uploads' },\n *     }, 'uploads-handler'),\n *   ],\n * });\n * ```\n *\n * @example Use on standalone API server\n * ```typescript\n * import { createAPIServer } from 'unirend/server';\n * import { staticContent } from 'unirend/plugins';\n *\n * const server = createAPIServer({\n *   plugins: [\n *     staticContent({\n *       folderMap: {\n *         '/files': './data/files',\n *       },\n *       singleAssetMap: {\n *         '/favicon.ico': './public/favicon.ico',\n *       },\n *     }),\n *   ],\n * });\n * ```\n *\n * @example Fine-tuned caching settings\n * ```typescript\n * staticContent({\n *   folderMap: { '/assets': './dist/assets' },\n *   smallFileMaxSize: 1024 * 1024, // 1MB - files below this get content-based ETags\n *   cacheEntries: 200, // Max LRU cache entries\n *   contentCacheMaxSize: 100 * 1024 * 1024, // 100MB total content cache\n *   positiveCacheTtl: 3600 * 1000, // 1 hour for found files\n *   negativeCacheTtl: 60 * 1000, // 1 minute for 404s\n *   cacheControl: 'public, max-age=3600', // Custom Cache-Control\n * })\n * ```\n *\n * @example Provide external cache for runtime updates\n * ```typescript\n * import { staticContent, StaticContentCache } from 'unirend/plugins';\n *\n * // Create cache externally for runtime control\n * const cache = new StaticContentCache({\n *   folderMap: { '/pages': './dist/pages' }\n * });\n *\n * const server = serveSSRDev(paths, {\n *   plugins: [\n *     staticContent(cache, 'pages-handler'),\n *   ],\n * });\n *\n * await server.listen(3000);\n *\n * // Later: update mappings dynamically\n * cache.updateConfig({\n *   singleAssetMap: {\n *     '/blog/new-post': './dist/blog/new-post.html'\n *   }\n * });\n * ```\n *\n * @param configOrCache Static content router configuration OR an existing StaticContentCache instance\n * @param name Optional custom name for this plugin instance (useful for debugging and plugin dependencies)\n * @returns A ServerPlugin that can be added to the plugins array\n */\nexport function staticContent(\n  configOrCache: StaticContentRouterOptions | StaticContentCache,\n  name?: string,\n): ServerPlugin {\n  // Validate custom name if provided\n  if (name !== undefined) {\n    if (typeof name !== 'string' || name.trim().length === 0) {\n      throw new Error(\n        'staticContent plugin name must be a non-empty string if provided',\n      );\n    }\n  }\n\n  // Use custom name or generate a unique instance ID for this plugin registration\n  // This allows multiple instances to be registered and tracked independently\n  const instanceID =\n    name ||\n    `static-content-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n\n  const staticContentPlugin: ServerPlugin = (pluginHost, _pluginOptions) => {\n    // Determine cache source: use provided instance or create new one from config\n    let cache: StaticContentCache;\n\n    if (configOrCache instanceof StaticContentCache) {\n      // Using externally-created cache instance (for runtime updates via updateConfig)\n      // User maintains reference to cache for dynamic updates\n      cache = configOrCache;\n    } else {\n      // Creating new cache from configuration options\n      // Try to get logger from fastify instance if available\n      const logger = pluginHost.getDecoration<{\n        warn: (obj: object, msg: string) => void;\n      }>('log');\n      cache = new StaticContentCache(configOrCache, logger);\n    }\n\n    // Create and register the hook with the cache instance\n    const hook = createStaticContentHook(cache);\n    pluginHost.addHook('onRequest', hook);\n\n    return {\n      name: instanceID,\n    };\n  };\n\n  return staticContentPlugin;\n}\n\nexport { StaticContentCache } from '../internal/static-content-cache';\n"],"mappings":"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","names":["import_cookie","import_domain_utils","import_domain_utils","isValidULID","fastifyCookie","zlibConstants","fs","crypto","options","path","fastifyCookieModule","createCookieSigner","CookieSigner","signCookieValue","unsignCookieValue"]}