import { FastifyReply, FastifyRequest } from 'fastify'; import { CookieSerializeOptions } from '@fastify/cookie'; interface PageMetadata { title: string; description: string; keywords?: string; canonical?: string; og?: { title?: string; description?: string; image?: string; }; } /** * Base meta structure with required page metadata */ interface BaseMeta { page?: PageMetadata; } interface ErrorDetails { [key: string]: unknown; } /** * Error details value - supports both object and array formats * * @example Object format (structured errors) * { field: 'email', reason: 'invalid format', code: 'VALIDATION_ERROR' } * * @example Array format (multiple validation errors with type field) * [ * { field: 'email', type: 'invalid_email', message: 'Must be a valid email address' }, * { field: 'password', type: 'invalid_length', message: 'Must be at least 8 characters long' } * ] * * @example Array format (error trace) * ['Step 1 failed', 'Rollback initiated', 'Cleanup completed'] */ type ErrorDetailsValue = ErrorDetails | unknown[]; /** * Error object structure for API error responses */ interface ErrorObject { code: string; message: string; details?: ErrorDetailsValue; } interface RedirectInfo { target: string; permanent: boolean; preserve_query?: boolean; } /** * API Success Response with extensible meta * * @template T - The data type * @template M - Additional meta properties (extends BaseMeta) * * @example * // Basic usage (no extra meta) * type BasicResponse = APISuccessResponse; * * @example * // With required extra meta fields * interface CustomMeta extends BaseMeta { * pagination: { page: number; total: number }; * cache: { expires: string }; * } * type PaginatedResponse = APISuccessResponse; */ interface APISuccessResponse { status: 'success'; status_code: number; request_id: string; request_timestamp?: string; type: 'api'; data: T; meta: M; error?: null; } /** * API Error Response with extensible meta * * @template M - Additional meta properties (extends BaseMeta) */ interface APIErrorResponse { status: 'error'; status_code: number; request_id: string; request_timestamp?: string; type: 'api'; data: null; meta: M; error: ErrorObject; } /** * API response envelope as a discriminated union * * @template T - The data type for success responses * @template M - Additional meta properties (extends BaseMeta) */ type APIResponseEnvelope = APISuccessResponse | APIErrorResponse; /** * Page Success Response with extensible meta * * @template T - The data type * @template M - Additional meta properties (extends BaseMeta) */ interface PageSuccessResponse { status: 'success'; status_code: number; request_id: string; request_timestamp?: string; type: 'page'; data: T; meta: M; error?: null; ssr_request_context?: Record; } /** * Page Error Response with extensible meta * * @template M - Additional meta properties (extends BaseMeta) */ interface PageErrorResponse { status: 'error'; status_code: number; request_id: string; request_timestamp?: string; type: 'page'; data: null; meta: M; error: ErrorObject; ssr_request_context?: Record; } /** * Page Redirect Response with extensible meta * * @template M - Additional meta properties (extends BaseMeta) */ interface PageRedirectResponse { status: 'redirect'; status_code: 200; request_id: string; request_timestamp?: string; type: 'page'; data: null; meta: M; error?: null; redirect: RedirectInfo; ssr_request_context?: Record; } /** * Page response envelope as a discriminated union * * @template T - The data type for success responses * @template M - Additional meta properties (extends BaseMeta) */ type PageResponseEnvelope = PageSuccessResponse | PageErrorResponse | PageRedirectResponse; /** * Parsed domain information derived from a request hostname. * * Used both on the Fastify request object (`request.domainInfo`) and in the * Unirend React context (`useDomainInfo()`). */ interface DomainInfo { /** Bare hostname with port stripped (IPv6-safe, e.g. `'app.example.com'` or `'::1'`). */ hostname: string; /** * Apex domain without a leading dot (e.g. `'example.com'`). * Empty string for localhost and raw IP addresses where no root domain can be resolved. * * When empty, omit the `domain` attribute entirely — `domain=.localhost` is invalid * per RFC 6265 and browsers reject it. A cookie without a `domain` attribute becomes * a host-only cookie scoped to the exact hostname, which is correct for localhost and IPs. * * Prepend `.` when using as a cookie `domain` attribute to span subdomains: * ```ts * document.cookie = [ * 'theme=dark', * 'path=/', * 'max-age=31536000', * domainInfo.rootDomain ? `domain=.${domainInfo.rootDomain}` : null, * ].filter(Boolean).join('; '); * ``` */ rootDomain: string; } /** * Controlled reply surface available to handlers. * Allows setting headers and cookies without giving full reply control. * * Used by page data loader handlers, API route handlers, and processFileUpload(). * Provides limited access to prevent handlers from prematurely sending responses * or bypassing the framework's envelope pattern. */ interface ControlledReply { /** Set a response header (content-type may be enforced by framework) */ header: (name: string, value: string) => void; /** Set a cookie if @fastify/cookie is registered */ setCookie?: (name: string, value: string, options?: CookieSerializeOptions) => void; /** Alias for setCookie if @fastify/cookie is registered */ cookie?: (name: string, value: string, options?: CookieSerializeOptions) => void; /** Clear a cookie if @fastify/cookie is registered */ clearCookie?: (name: string, options?: CookieSerializeOptions) => void; /** Verify and unsign a cookie value if @fastify/cookie is registered */ unsignCookie?: (value: string) => { valid: true; renew: boolean; value: string; } | { valid: false; renew: false; value: null; }; /** Sign a cookie value if @fastify/cookie is registered */ signCookie?: (value: string) => string; /** Read a response header value (if available) */ getHeader: (name: string) => string | number | string[] | undefined; /** Read all response headers as a plain object */ getHeaders: () => Record; /** Remove a response header by name */ removeHeader: (name: string) => void; /** Check if a response header has been set */ hasHeader: (name: string) => boolean; /** Whether the reply has already been sent */ sent: boolean; /** * Access to the underlying response stream (for connection monitoring) * * Limited scope: Only used internally by processFileUpload() for detecting * broken connections during file uploads. Most handlers won't need this */ raw: { /** Whether the underlying connection has been destroyed */ destroyed: boolean; }; /** * Internal: send an error envelope and finish the response immediately. * * This is installed by the framework's controlled-reply wrapper so helpers * can terminate with the same raw/hijacked path while still reusing shared * header logic such as CORS application. * * ControlledReply intentionally does not expose general-purpose send/write * methods to user handlers. This internal escape hatch exists only so * framework-owned helpers like APIResponseHelpers can terminate early in a * controlled way without reopening arbitrary reply access. */ _sendErrorEnvelope: (statusCode: number, errorEnvelope: APIErrorResponse | PageErrorResponse) => Promise; } declare module 'fastify' { interface FastifyRequest { /** * Active SSR app key for multi-app routing. * * Read-only request value. Defaults to `'__default__'`. * Use `request.setActiveSSRApp(appKey)` in SSR middleware to select a * registered app and refresh app-derived request values. */ readonly activeSSRApp: string; /** * Select the active SSR app for this request. * * Validates that the app exists, updates `request.activeSSRApp`, refreshes * `request.publicAppConfig`, and updates the app-level CDN default unless * middleware already overrode `request.CDNBaseURL`. */ setActiveSSRApp: (appKey: string) => void; /** * Resolved client IP address. * * Set once per request by the framework using `getClientIP` (if provided) * or falling back to `request.ip` (which reflects Fastify proxy handling * when `fastifyOptions.trustProxy` is configured). * * Available throughout the entire request lifecycle, including plugins, * hooks, page data loader handlers, API route handlers, and access log * templates/hooks. */ clientIP: string; /** * Server label for this instance (e.g. `'SSR'`, `'API'`). * * Set once per request by the framework from the `serverLabel` server option. * Available in access log templates as `{{serverLabel}}` and throughout * the request lifecycle via `request.serverLabel`. */ serverLabel: string; /** * Safe-to-share app configuration cloned and frozen for this request. * * Available to plugins, handlers, and helpers on SSR and API servers. * SSR also exposes it to React and injects it into HTML. API servers only * send it to clients if your response includes selected values. */ publicAppConfig?: Record; /** * Effective CDN base URL for this SSR request. * * SSR middleware can set this as a per-request override. If it remains * unset, the SSR server populates it from the active app's `CDNBaseURL` * before preHandler hooks, route handlers, SSR render, and custom 500 * pages run. * * API servers do not set this field. */ CDNBaseURL?: string; /** * Optional request-scoped helper installed by the built-in CORS plugin. * * Raw/hijacked response paths can call this before `writeHead(...)` to * apply the same actual-response CORS/security headers that normal * Fastify-managed responses receive. */ applyCORSHeaders?: (reply: FastifyReply) => void | Promise; /** * Internal request-start timestamp captured by the framework. * * Used by framework features that need a stable "request received" time, * including API/page envelope timestamps and fallback response-time * calculation when Fastify's built-in elapsedTime is unavailable. */ receivedAt?: number; /** * Per-request mutable key-value store initialized by both SSRServer and APIServer * before any plugins or hooks run. * * Use this to pass data between middleware, hooks, and handlers — for example, * seeding user session info, theme preferences, or CSRF tokens from an onRequest * hook so that page data loader handlers and API handlers can read them. * * During SSR, the server injects the final context into the rendered HTML so * client-side React can hydrate with the same values via `useRequestContext()`. * * In separated SSR/API deployments the SSR layer forwards this context to trusted * API page data requests and merges returned context back automatically. */ requestContext: Record; /** * Parsed domain information for this request, computed once per request from * `request.hostname` by both SSRServer and APIServer. * * - `hostname`: bare hostname with port stripped (IPv6-safe) * - `rootDomain`: apex domain without a leading dot (e.g. `'example.com'`); * empty string for localhost and raw IP addresses * * Use `rootDomain` to set subdomain-spanning cookies by prepending a dot: * `domain=.${request.domainInfo.rootDomain}` — the same value that the * client-side `cycleTheme()` helper uses. */ domainInfo: DomainInfo; /** * Set to `true` when the request is handled by the built-in static content * handler (fingerprinted assets, public files, etc.), regardless of which * server type is serving the file. * * Initialized to `false` for every request. The static content handler sets * it to `true` before calling `reply.hijack()`, so it is observable in * `onResponse` hooks and access log templates even though `onSend` is bypassed. * * Use this to skip work that is inappropriate for static asset responses — * for example, cookie renewal should only happen on document/API responses, * not on every `.js` or `.css` file request. */ isStaticAsset: boolean; } } /** * Helper utilities for constructing API/Page response envelopes. * * These are static so the class can be easily subclassed or the methods can be * re-exported. Users may extend this class to inject their own default meta or * wrap additional logic (e.g., account metadata, logging, etc.). */ declare class APIResponseHelpers { /** * Creates a standardized API success response envelope for API (AJAX/JSON) endpoints. * * @typeParam T - The type of the response data payload. * @typeParam M - Meta type that extends BaseMeta. * Allows consumers to add application specific meta keys * (e.g. `account`, `pagination`, etc.). * @param params - Object containing request, data, statusCode (default 200), and optional meta. * @returns An APISuccessResponse envelope with merged meta and a request_id. */ static createAPISuccessResponse(params: { request: FastifyRequest; data: T; statusCode?: number; meta?: Partial; }): APISuccessResponse; /** * Creates a standardized API error response envelope for API (AJAX/JSON) endpoints. * * @typeParam M - Meta type that extends BaseMeta. * Allows consumers to add application specific meta keys * (e.g. `account`, `pagination`, etc.). * @param params - Object containing request, statusCode, errorCode, errorMessage, optional errorDetails, and optional meta. * @returns An APIErrorResponse envelope with merged meta and a request_id. */ static createAPIErrorResponse(params: { request: FastifyRequest; statusCode: number; errorCode: string; errorMessage: string; errorDetails?: ErrorDetailsValue; meta?: Partial; }): APIErrorResponse; /** * Creates a standardized Page success response envelope for SSR/data loaders. * * @typeParam T - The type of the response data payload. * @typeParam M - Meta type that extends BaseMeta. * Allows consumers to add application specific meta keys * (e.g. `account`, `pagination`, etc.). * @param params - Object containing request, data, pageMetadata, statusCode (default 200), and optional meta. * @returns A PageSuccessResponse envelope with merged meta and a request_id. */ static createPageSuccessResponse(params: { request: FastifyRequest; data: T; pageMetadata: PageMetadata; statusCode?: number; meta?: Partial; }): PageSuccessResponse; /** * Creates a standardized Page redirect response envelope for SSR/data loaders. * Always uses status code 200 to avoid confusion with HTTP redirects. * * @typeParam M - Meta type that extends BaseMeta. * Allows consumers to add application specific meta keys. * @param params - Object containing request, redirectInfo, pageMetadata, and optional meta. * @returns A PageRedirectResponse envelope with merged meta and a request_id. */ static createPageRedirectResponse(params: { request: FastifyRequest; redirectInfo: RedirectInfo; pageMetadata: PageMetadata; meta?: Partial; }): PageRedirectResponse; /** * Creates a standardized Page error response envelope for SSR/data loaders. * * @typeParam M - Meta type that extends BaseMeta. * Allows consumers to add application specific meta keys * (e.g. `account`, `pagination`, etc.). * @param params - Object containing request, statusCode, errorCode, errorMessage, * optional errorDetails, pageMetadata, and optional meta. * @returns A PageErrorResponse envelope with merged meta and a request_id. */ static createPageErrorResponse(params: { request: FastifyRequest; statusCode: number; errorCode: string; errorMessage: string; errorDetails?: ErrorDetailsValue; pageMetadata: PageMetadata; meta?: Partial; }): PageErrorResponse; /** * Send an error envelope response with the appropriate method * Works with both FastifyReply and ControlledReply * * This is a public utility for sending error responses in a way that works * with both standard Fastify handlers and controlled reply handlers. * * @param reply - Fastify reply object or ControlledReply * @param statusCode - HTTP status code to send * @param errorResponse - Error envelope to send * * @example * ```typescript * const errorResponse = APIResponseHelpers.createAPIErrorResponse({ * request, * statusCode: 400, * errorCode: 'invalid_input', * errorMessage: 'Invalid input provided', * }); * * await APIResponseHelpers.sendErrorEnvelope( * request, * reply, * 400, * errorResponse, * ); * ``` * * This helper is usable directly, but it is also part of the framework's * controlled early-termination path. Unlike the envelope creation helpers, * it has transport semantics (shared headers, hijack/raw write, immediate * response finalization), so overriding it in a custom helpers subclass is * discouraged unless you intend to preserve that contract. */ static sendErrorEnvelope(request: FastifyRequest, reply: FastifyReply | ControlledReply, statusCode: number, errorResponse: APIErrorResponse | PageErrorResponse): Promise; /** * Ensures an incoming Fastify request has a valid JSON body. * If invalid, sends a standardized error response and returns false. * * Use this helper for POST, PUT, PATCH, and DELETE endpoints that expect JSON payloads. * This is a pre-validation convenience before using schema validators like Zod. * * @param request - Fastify request object * @param reply - Fastify reply object or ControlledReply * @returns true if body is valid JSON, otherwise false (error envelope already sent) * * @example * ```typescript * server.api.post('users', async (request, reply) => { * if (!(await APIResponseHelpers.ensureJSONBody(request, reply))) { * return false; // Error envelope already sent * } * * // Now safe to validate using a schema validator (e.g. Zod) or process the body * const validated = userSchema.parse(request.body); * // ... * }); * ``` */ static ensureJSONBody(request: FastifyRequest, reply: FastifyReply | ControlledReply): Promise; /** * Ensures an incoming Fastify request has a valid URL-encoded form body. * If invalid, sends a standardized error response and returns false. * * Use this helper for POST, PUT, or PATCH endpoints that expect URL-encoded form data. * This is a pre-validation convenience before processing form fields. * * Note: For file uploads with multipart/form-data, use ensureMultipartBody instead. * * @param request - Fastify request object * @param reply - Fastify reply object or ControlledReply * @returns true if form body is valid, otherwise false (error envelope already sent) * * @example * ```typescript * server.api.post('contact', async (request, reply) => { * if (!(await APIResponseHelpers.ensureURLEncodedBody(request, reply))) { * return false; // Error envelope already sent * } * * // Now safe to process form fields * const formData = request.body as Record; * // ... * }); * ``` */ static ensureURLEncodedBody(request: FastifyRequest, reply: FastifyReply | ControlledReply): Promise; /** * Ensures an incoming Fastify request has multipart/form-data Content-Type. * If invalid, sends a standardized error response and returns false. * * **Note:** `processFileUpload()` automatically validates Content-Type, * so you typically don't need this helper when using `processFileUpload()`. * * **Advanced use case:** Use this for early validation in middleware (e.g., auth/rate-limiting) * before multipart parsing begins: * * ```typescript * // Block uploads for non-premium users before parsing * pluginHost.addHook('preHandler', async (request, reply) => { * if (request.headers['content-type']?.includes('multipart/form-data')) { * if (!user.isPremium) { * return reply.code(403).send({ error: 'Premium feature' }); * } * } * }); * ``` * * For standard file uploads, use `processFileUpload()` instead: * ```typescript * import { processFileUpload } from 'unirend/server'; * * const results = await processFileUpload({ * request, * reply, * maxSizePerFile: 5 * 1024 * 1024, * allowedMimeTypes: ['image/jpeg', 'image/png'], * processor: async (stream, metadata, context) => { * // ... handle upload * }, * }); * ``` * * @param request - Fastify request object * @param reply - Fastify reply object or ControlledReply * @returns true if Content-Type is multipart/form-data, otherwise false (error envelope already sent) * */ static ensureMultipartBody(request: FastifyRequest, reply: FastifyReply | ControlledReply): Promise; /** Determines if envelope is a success response */ static isSuccessResponse(response: APIResponseEnvelope | PageResponseEnvelope): response is APISuccessResponse | PageSuccessResponse; /** Determines if envelope is an error response */ static isErrorResponse(response: APIResponseEnvelope | PageResponseEnvelope): response is APIErrorResponse | PageErrorResponse; /** Determines if envelope is a redirect response */ static isRedirectResponse(response: APIResponseEnvelope | PageResponseEnvelope): response is PageRedirectResponse; /** Determines if envelope is a page (SSR) response */ static isPageResponse(response: APIResponseEnvelope | PageResponseEnvelope): response is PageResponseEnvelope; /** * Validates that an unknown value is a proper envelope object * This is a catch-all validation function that checks for proper envelope structure * without requiring specific typing - useful for runtime validation of handler responses */ static isValidEnvelope(result: unknown): result is PageResponseEnvelope | APIResponseEnvelope; } export { type APIErrorResponse, type APIResponseEnvelope, APIResponseHelpers, type APISuccessResponse, type BaseMeta, type ErrorDetails, type ErrorDetailsValue, type ErrorObject, type PageErrorResponse, type PageMetadata, type PageRedirectResponse, type PageResponseEnvelope, type PageSuccessResponse, type RedirectInfo };