UNPKG

opinionated-machine

Version:

Very opinionated DI framework for fastify, built on top of awilix

821 lines (820 loc) 35.2 kB
import type { AnyDualModeContractDefinition, AnySSEContractDefinition, HttpStatusCode } from '@lokalise/api-contracts'; import type { ApiContractMetadataToRouteMapper } from '@lokalise/fastify-api-contracts'; import type { FastifyReply, FastifyRequest } from 'fastify'; import type { z } from 'zod'; import type { DualModeType } from '../dualmode/dualModeTypes.ts'; import type { SSERoomOperations } from '../sse/rooms/types.ts'; import type { SSEEventSchemas, SSEEventSender, SSELogger, SSEMessage } from '../sse/sseTypes.ts'; import type { SSECloseReason } from './fastifyRouteUtils.ts'; /** * Infer the union of all response body types from responseBodySchemasByStatusCode. * This allows handlers to return responses for specific status codes with proper typing. * Typically used for error responses, but can define schemas for any HTTP status code. * * @example * ```typescript * // Given responseBodySchemasByStatusCode: { 400: z.object({ error: string }), 404: z.object({ notFound: true }) } * // InferErrorResponses<typeof contract> = { error: string } | { notFound: true } * ``` */ export type InferErrorResponses<ResponseSchemas extends Partial<Record<HttpStatusCode, z.ZodTypeAny>> | undefined> = ResponseSchemas extends Partial<Record<HttpStatusCode, z.ZodTypeAny>> ? { [K in keyof ResponseSchemas]: ResponseSchemas[K] extends z.ZodTypeAny ? z.infer<ResponseSchemas[K]> : never; }[keyof ResponseSchemas] : never; /** * Type for responseSchemasByStatusCode - maps HTTP status codes to Zod schemas. */ export type ResponseSchemasByStatusCode = Partial<Record<HttpStatusCode, z.ZodTypeAny>>; /** * Strictly typed respond function for sse.respond(). * * When responseBodySchemasByStatusCode is defined, this provides strict typing: * - For defined status codes: body must match the corresponding schema * - For undefined status codes: use respondRaw() or cast to bypass strict typing * * @example * ```typescript * // With responseBodySchemasByStatusCode: { 400: z.object({ error: string, details: string[] }), 404: z.object({ error: string, resourceId: string }) } * sse.respond(404, { error: 'Not Found', resourceId: '123' }) // ✓ OK - matches 404 schema * sse.respond(404, { error: 'Not Found' }) // ✗ Error - missing resourceId * sse.respond(400, { error: 'Bad', resourceId: '123' }) // ✗ Error - wrong schema for 400 * ``` */ export type StrictRespondFunction<ResponseSchemas extends ResponseSchemasByStatusCode | undefined> = ResponseSchemas extends ResponseSchemasByStatusCode ? keyof ResponseSchemas & number extends never ? (code: number, body: unknown) => SSERespondResult : <Code extends keyof ResponseSchemas & number>(code: Code, body: ResponseSchemas[Code] extends z.ZodTypeAny ? z.infer<ResponseSchemas[Code]> : unknown) => SSERespondResult : (code: number, body: unknown) => SSERespondResult; /** * Result indicating the handler returned an HTTP response before streaming started. * Created via `sse.respond(code, body)`. * Use this for early returns (validation errors, not found, etc.) before starting SSE. */ export type SSERespondResult = { _type: 'respond'; code: number; body: unknown; }; /** * Session lifetime mode, specified when calling `sse.start()`. * - `'autoClose'`: Close session automatically after handler completes (request-response streaming) * - `'keepAlive'`: Keep session open after handler completes (long-lived connections) */ export type SSESessionMode = 'autoClose' | 'keepAlive'; /** * Possible results from an SSE handler. * - `SSERespondResult`: Send HTTP response before streaming (via sse.respond()) * - `void`: Streaming was started via sse.start(), mode determines what happens next */ export type SSEHandlerResult = SSERespondResult | void; /** * Message format for use with SSESession.sendStream(). * Allows sending typed events through an async iterable. * * @template Events - Event schemas for type-safe event names and data * * @example * ```typescript * async function* generateMessages(): AsyncIterable<SSEStreamMessage<typeof contract.serverSentEventSchemas>> { * yield { event: 'chunk', data: { delta: 'Hello' } } * yield { event: 'chunk', data: { delta: ' world' } } * yield { event: 'done', data: { usage: { total: 2 } } } * } * * await connection.sendStream(generateMessages()) * ``` */ export type SSEStreamMessage<Events extends SSEEventSchemas = SSEEventSchemas> = { [K in keyof Events & string]: { event: K; data: z.input<Events[K]>; id?: string; retry?: number; }; }[keyof Events & string]; /** * Represents an active SSE connection with typed event sending. * * @template Events - Event schemas for type-safe sending * @template Context - Custom context data stored per connection */ export type SSESession<Events extends SSEEventSchemas = SSEEventSchemas, Context = unknown> = { /** Unique identifier for this connection */ id: string; /** The original Fastify request */ request: FastifyRequest; /** The Fastify reply with SSE capabilities from @fastify/sse */ reply: FastifyReply; /** Custom context data for this connection */ context: Context; /** Timestamp when the connection was established */ connectedAt: Date; /** * Type-safe event sender for this connection. * Event names and data are validated against the contract's event schemas. */ send: SSEEventSender<Events>; /** * Check if the SSE connection is still open. * Queries the underlying @fastify/sse connection state. * * @returns true if the connection is still open, false if closed */ isConnected: () => boolean; /** * Get the underlying writable stream for advanced streaming operations. * Useful for piping data directly or using Node.js stream utilities. * * @returns The underlying NodeJS.WritableStream from @fastify/sse * * @example * ```typescript * import { pipeline } from 'node:stream/promises' * * // Pipe data from a readable stream to SSE * const readable = createReadableStream() * await pipeline(readable, connection.getStream()) * ``` */ getStream: () => NodeJS.WritableStream; /** * Send multiple SSE messages from an async iterable with validation. * Each message is validated against the contract's event schemas before sending. * * @param messages - Async iterable of SSE messages to send * @returns Promise that resolves when all messages have been sent * * @example * ```typescript * async function* generateMessages() { * yield { event: 'chunk', data: { delta: 'Hello' } } * yield { event: 'chunk', data: { delta: ' world' } } * yield { event: 'done', data: { usage: { total: 2 } } } * } * * await connection.sendStream(generateMessages()) * ``` */ sendStream: (messages: AsyncIterable<SSEStreamMessage<Events>>) => Promise<void>; /** * Room operations for this connection. * Only available when rooms are enabled in the controller config. * * @example * ```typescript * // Join rooms based on route parameters or business logic * session.rooms.join(`dashboard:${request.params.dashboardId}`) * session.rooms.join(['project:123', 'team:engineering']) * * // Leave rooms when context changes * session.rooms.leave('project:123') * ``` */ rooms: SSERoomOperations; /** * Zod schemas for validating event data. * Map of event name to Zod schema. Used by sendEvent for runtime validation. * @internal */ eventSchemas?: SSEEventSchemas; }; /** * Options for starting an SSE connection. */ export type SSEStartOptions<Context = unknown> = { /** Initial context data for the connection */ context?: Context; }; /** * Context object passed to SSE handlers for deferred header sending. * * This abstraction allows handlers to: * 1. Perform validation before any headers are sent * 2. Return early with HTTP responses (errors, redirects, etc.) before streaming * 3. Explicitly start streaming when ready * * @template Events - Event schemas for type-safe sending * @template ResponseBody - Response body type for early returns * * @example Basic usage in handler * ```typescript * sse: async (request, sse) => { * // Phase 1: Validation (headers NOT sent yet) * const entity = await db.find(request.params.id) * if (!entity) { * return sse.respond(404, { error: 'Entity not found' }) * } * * // Phase 2: Start streaming (sends 200 + SSE headers) * // 'autoClose' = close after handler, 'keepAlive' = keep open for external events * const session = sse.start('autoClose', { context: { entity } }) * * // Phase 3: Stream events * await session.send('data', { item: entity }) * } * ``` * * @example Error handling with try/catch (no return needed) * ```typescript * sse: async (request, sse) => { * try { * const entity = await db.getById(request.params.id) * const session = sse.start('autoClose') * await session.send('data', { item: entity }) * } catch (e) { * // sse.respond() can be called without returning - both patterns work * sse.respond(404, { error: 'Entity not found' }) * } * } * ``` * * @example Passing to internal helper methods * ```typescript * import { SSEContext } from 'opinionated-machine' * import { chatStreamContract } from './contracts' * * class ChatController extends AbstractSSEController<{ chat: typeof chatStreamContract }> { * buildSSERoutes() { * return { * chat: buildHandler(chatStreamContract, { * sse: async (request, sse) => { * // Delegate to internal method with full type safety * await this.handleChatStream(request.body.message, sse) * }, * }), * } * } * * // Type the sse parameter using SSEContext with the contract's event schemas * private async handleChatStream( * message: string, * sse: SSEContext<typeof chatStreamContract.serverSentEventSchemas>, * ) { * const session = sse.start('autoClose') * // session.send() is fully typed based on contract's serverSentEventSchemas * await session.send('chunk', { delta: 'Hello' }) * await session.send('done', { usage: { total: 5 } }) * } * } * ``` */ export type SSEContext<Events extends SSEEventSchemas = SSEEventSchemas, ResponseSchemas extends ResponseSchemasByStatusCode | undefined = undefined> = { /** * Start streaming - sends HTTP 200 + SSE headers, returns typed session. * * After calling this method, you can no longer send HTTP responses. * Use `respond()` before `start()` for early returns. * * @param mode - Session lifetime mode: * - `'autoClose'`: Close session automatically after handler completes (request-response streaming) * - `'keepAlive'`: Keep session open after handler completes (long-lived connections) * @param options - Optional configuration for the session * @returns SSESession for sending events */ start: <Context = unknown>(mode: SSESessionMode, options?: SSEStartOptions<Context>) => SSESession<Events, Context>; /** * Send an HTTP response before streaming starts (early return). * * Use this for any case where you want to return a regular HTTP response * instead of starting an SSE stream: validation errors, not found, redirects, etc. * * Must be called BEFORE `start()`. You can either return the result or just call it * (useful in try/catch blocks). Both patterns work: * * When `responseBodySchemasByStatusCode` is defined in the contract, this method provides * strict typing - the body must match the schema for the given status code. * * @param code - HTTP status code (e.g., 200, 404, 422) * @param body - Response body (strictly typed when status code has a defined schema) * @returns SSERespondResult (can be returned or ignored) * * @example Return pattern (simple cases) * ```typescript * if (!entity) { * return sse.respond(404, { error: 'Entity not found', resourceId: id }) * } * ``` * * @example Strict typing with responseBodySchemasByStatusCode * ```typescript * // Contract defines: responseBodySchemasByStatusCode: { 404: z.object({ error: string, resourceId: string }) } * sse.respond(404, { error: 'Not Found', resourceId: '123' }) // OK * sse.respond(404, { error: 'Not Found' }) // TypeScript error - missing resourceId * ``` */ respond: StrictRespondFunction<ResponseSchemas>; /** * Advanced: send headers without creating a full connection. * * Use this only for advanced streaming scenarios where you need headers * sent early but will manage streaming manually via `sse.reply.sse`. * * Most handlers should use `start()` instead. */ sendHeaders: () => void; /** * Escape hatch to raw Fastify reply if needed. * Use with caution - prefer the typed methods above. */ reply: FastifyReply; }; /** * Async preHandler hook for SSE routes. * * IMPORTANT: SSE route preHandlers MUST return a Promise. This is required * for proper integration with @fastify/sse. Synchronous handlers will cause * connection issues. * * For rejection (auth failure), return the reply after sending: * ```typescript * preHandler: (request, reply) => { * if (!validAuth) { * return reply.code(401).send({ error: 'Unauthorized' }) * } * return Promise.resolve() * } * ``` */ export type FastifySSEPreHandler = (request: FastifyRequest, reply: FastifyReply) => Promise<unknown>; /** * Options for configuring an SSE route. */ export type FastifySSERouteOptions = { /** * Async preHandler hook for authentication/authorization. * Runs BEFORE the SSE connection is established. * * MUST return a Promise - synchronous handlers will cause connection issues. * Return `reply.code(401).send(...)` for rejection, or `Promise.resolve()` for success. * * @see FastifySSEPreHandler for usage examples */ preHandler?: FastifySSEPreHandler; /** * Called when client connects (after SSE handshake). */ onConnect?: (connection: SSESession) => void | Promise<void>; /** * Called when the SSE connection closes for any reason (client disconnect, * network failure, or server explicitly closing via closeConnection()). * * @param connection - The connection that was closed * @param reason - Why the connection was closed: * - 'server': Server explicitly closed (closeConnection() or success('disconnect')) * - 'client': Client closed (EventSource.close(), navigation, network failure) * * Use this for cleanup like unsubscribing from events or removing from tracking. */ onClose?: (connection: SSESession, reason: SSECloseReason) => void | Promise<void>; /** * Handler for Last-Event-ID reconnection. * Return an iterable of events to replay, or handle replay manually. * Supports both sync iterables (arrays, generators) and async iterables. */ onReconnect?: (connection: SSESession, lastEventId: string) => Iterable<SSEMessage> | AsyncIterable<SSEMessage> | void | Promise<void>; /** * Optional logger for SSE route errors. * If not provided, errors will be logged to console.error. * Compatible with CommonLogger from @lokalise/node-core and pino loggers. */ logger?: SSELogger; /** * Custom serializer for SSE message data on this route. * Overrides the global serializer if set. * @default JSON.stringify */ serializer?: (data: unknown) => string; /** * Heartbeat interval in milliseconds for this route. * Overrides the global heartbeat interval if set. * Set to 0 to disable heartbeats. * @default 30000 */ heartbeatInterval?: number; /** * Maps contract metadata to additional Fastify route options. * * Called with the contract's `metadata` field and its return value is merged into the * Fastify route options as a base. It is useful for attaching cross-cutting concerns * (auth, rate limiting, tracing, etc.) driven by metadata defined in the contract rather * than in the route handler. * * @example * ```typescript * buildHandler(myContract, { sse: handler }, { * contractMetadataToRouteMapper: (metadata) => ({ * config: { rateLimit: metadata.rateLimit }, * }), * }) * ``` */ contractMetadataToRouteMapper?: ApiContractMetadataToRouteMapper; }; /** * Route configuration returned by buildSSERoutes(). * * @template Contract - The SSE route definition */ export type FastifySSEHandlerConfig<Contract extends AnySSEContractDefinition> = { /** The SSE route contract */ contract: Contract; /** Handlers object containing the SSE handler */ handlers: SSEOnlyHandlers<Contract['serverSentEventSchemas'], InferOptionalSchema<Contract['requestPathParamsSchema']>, InferOptionalSchema<Contract['requestQuerySchema']>, InferOptionalSchema<Contract['requestHeaderSchema']>, InferOptionalSchema<Contract['requestBodySchema'], undefined>, Contract['responseBodySchemasByStatusCode']>; /** Optional route configuration */ options?: FastifySSERouteOptions; }; /** * Maps SSE contracts to route handler containers for type checking. */ export type BuildFastifySSERoutesReturnType<APIContracts extends Record<string, AnySSEContractDefinition>> = { [K in keyof APIContracts]: SSERouteHandler<APIContracts[K]>; }; /** * Safely infer the output type of an optional Zod schema property. * Since contract schema properties are optional (`Schema | undefined`), * a bare `Contract['schema'] extends z.ZodTypeAny` check always fails * because `ZodObject | undefined` does not extend `ZodTypeAny`. * NonNullable strips the `undefined` before the check. */ type InferOptionalSchema<T, Fallback = unknown> = NonNullable<T> extends z.ZodTypeAny ? z.infer<NonNullable<T>> : Fallback; /** * Infer the FastifyRequest type from an SSE contract. * * Use this to get properly typed request parameters in handlers without * manually spelling out the types. * * @example * ```typescript * const handler = async ( * request: InferSSERequest<typeof chatCompletionContract>, * connection: SSESession, * ) => { * // request.body is typed as { message: string; stream: true } * const { message } = request.body * } * ``` */ export type InferSSERequest<Contract extends AnySSEContractDefinition> = FastifyRequest<{ Params: InferOptionalSchema<Contract['requestPathParamsSchema']>; Querystring: InferOptionalSchema<Contract['requestQuerySchema']>; Headers: InferOptionalSchema<Contract['requestHeaderSchema']>; Body: InferOptionalSchema<Contract['requestBodySchema'], undefined>; }>; /** * Reply object available to sync handlers. * * Unlike the full FastifyReply, this omits `send()` because the framework handles * sending the response after validation. Sync handlers should return the response body * directly instead of calling `reply.send()`. * * Fluent setters (code, status, header, etc.) are overridden to return SyncModeReply * so that chaining `.send()` after them is a compile-time error. * * Use `reply.code()` to set status codes and `reply.header()` to set response headers. */ type FastifyReplyFluentKeys = { [K in keyof FastifyReply]: FastifyReply[K] extends (...args: never[]) => infer R ? [R] extends [FastifyReply] ? K : never : never; }[keyof FastifyReply]; type ReplaceReturn<F, NewReturn> = F extends (...args: infer A) => FastifyReply ? (...args: A) => NewReturn : F; export type SyncModeReply = Omit<FastifyReply, 'send' | FastifyReplyFluentKeys> & { [K in Exclude<FastifyReplyFluentKeys, 'send'>]: ReplaceReturn<FastifyReply[K], SyncModeReply>; }; /** * Handler function for sync (non-streaming) mode. * * The handler should return the response body directly. The framework will validate it * against the contract schema and send it. Use `reply.code()` / `reply.header()` to set * status codes and headers, but do not call `reply.send()`. * * @template Params - Path parameters type * @template Query - Query string parameters type * @template Headers - Request headers type * @template Body - Request body type * @template SyncResponse - Response type that must match contract's successResponseBodySchema */ export type SyncModeHandler<Params = unknown, Query = unknown, Headers = unknown, Body = unknown, SyncResponse = unknown> = (request: FastifyRequest<{ Params: Params; Querystring: Query; Headers: Headers; Body: Body; }>, reply: SyncModeReply) => SyncResponse | Promise<SyncResponse>; /** * Handler function for SSE mode with deferred headers. * * The handler receives an SSEContext object that allows: * 1. Early returns before headers are sent (validation errors, not found, etc.) * 2. Explicit streaming start via `sse.start(mode)` * 3. Type-safe event sending via the returned session * * Note: `sse.respond()` can be called with or without returning the result. * Both patterns work - useful for try/catch blocks where returning is awkward. * * @returns SSEHandlerResult (optional - void is fine if sse.respond() or sse.start() was called) * * @example Request-response streaming (autoClose mode) * ```typescript * sse: async (request, sse) => { * const entity = await db.find(request.params.id) * if (!entity) { * return sse.respond(404, { error: 'Not found' }) * } * * const session = sse.start('autoClose') * await session.send('chunk', { delta: 'Hello' }) * await session.send('done', { usage: { total: 5 } }) * // Session closes automatically after handler returns * } * ``` * * @example Error handling with try/catch (no return needed) * ```typescript * sse: async (request, sse) => { * try { * const entity = await db.getById(request.params.id) * const session = sse.start('autoClose') * await session.send('data', entity) * } catch (e) { * sse.respond(404, { error: 'Not found' }) // No return needed * } * } * ``` * * @example Long-lived session (keepAlive mode) * ```typescript * sse: async (request, sse) => { * const session = sse.start('keepAlive') * this.subscriptions.set(session.id, request.params.userId) * // Session stays open after handler returns * } * ``` * * @template Events - SSE event schemas for type-safe sending * @template Params - Path parameters type * @template Query - Query string parameters type * @template Headers - Request headers type * @template Body - Request body type */ export type SSEModeHandler<Events extends SSEEventSchemas = SSEEventSchemas, Params = unknown, Query = unknown, Headers = unknown, Body = unknown, ResponseSchemas extends ResponseSchemasByStatusCode | undefined = undefined> = (request: FastifyRequest<{ Params: Params; Querystring: Query; Headers: Headers; Body: Body; }>, sse: SSEContext<Events, ResponseSchemas>) => SSEHandlerResult | Promise<SSEHandlerResult>; /** * Combined handlers for dual-mode routes. * * @template Params - Path parameters type * @template Query - Query string parameters type * @template Headers - Request headers type * @template Body - Request body type * @template SyncResponse - Sync response type * @template Events - SSE event schemas * @template ResponseSchemas - Response schemas by status code for strict sse.respond() typing */ export type DualModeHandlers<Params = unknown, Query = unknown, Headers = unknown, Body = unknown, SyncResponse = unknown, Events extends SSEEventSchemas = SSEEventSchemas, ResponseSchemas extends ResponseSchemasByStatusCode | undefined = undefined> = { sync: SyncModeHandler<Params, Query, Headers, Body, SyncResponse>; sse: SSEModeHandler<Events, Params, Query, Headers, Body, ResponseSchemas>; }; /** * Options for configuring a dual-mode route. * Extends SSE route options with JSON-specific options. */ export type FastifyDualModeRouteOptions = FastifySSERouteOptions & { /** * Default mode when Accept header doesn't specify preference. * @default 'json' */ defaultMode?: DualModeType; }; /** * Infer handlers type based on contract type. * All dual-mode contracts use `{ sync: handler, sse: handler }` pattern. * * The sync handler return type includes both: * - The success response type (from successResponseBodySchema) * - Error response types (from responseBodySchemasByStatusCode) * * The SSE handler's `sse.respond()` method is strictly typed when `responseBodySchemasByStatusCode` * is defined - the body must match the schema for the given status code. * * This allows returning error responses without type casting: * ```typescript * sync: (request, reply) => { * if (notFound) { * reply.code(404) * return { error: 'Not found', resourceId: '123' } // No cast needed! * } * return { success: true, data: 'OK' } * } * * sse: (request, sse) => { * if (notFound) { * return sse.respond(404, { error: 'Not found', resourceId: '123' }) // Strictly typed! * } * // ... * } * ``` * * @template Contract - The dual-mode contract definition */ export type InferDualModeHandlers<Contract extends AnyDualModeContractDefinition> = DualModeHandlers<InferOptionalSchema<Contract['requestPathParamsSchema']>, InferOptionalSchema<Contract['requestQuerySchema']>, InferOptionalSchema<Contract['requestHeaderSchema']>, InferOptionalSchema<Contract['requestBodySchema'], undefined>, (Contract['successResponseBodySchema'] extends z.ZodTypeAny ? z.infer<Contract['successResponseBodySchema']> : unknown) | InferErrorResponses<Contract['responseBodySchemasByStatusCode']>, Contract['serverSentEventSchemas'], Contract['responseBodySchemasByStatusCode']>; /** * Handler configuration returned by buildDualModeRoutes(). * * @template Contract - The dual-mode route definition */ export type FastifyDualModeHandlerConfig<Contract extends AnyDualModeContractDefinition> = { /** The dual-mode route contract */ contract: Contract; /** Handlers for sync and SSE modes - type depends on contract style */ handlers: InferDualModeHandlers<Contract>; /** Optional route configuration */ options?: FastifyDualModeRouteOptions; }; /** * Maps dual-mode contracts to route handler containers for type checking. */ export type BuildFastifyDualModeRoutesReturnType<APIContracts extends Record<string, AnyDualModeContractDefinition>> = { [K in keyof APIContracts]: DualModeRouteHandler<APIContracts[K]>; }; /** * SSE-only handler object - just the SSE handler. * Explicitly rejects `sync` property to distinguish from dual-mode handlers. */ export type SSEOnlyHandlers<Events extends SSEEventSchemas = SSEEventSchemas, Params = unknown, Query = unknown, Headers = unknown, Body = unknown, ResponseSchemas extends ResponseSchemasByStatusCode | undefined = undefined> = { sse: SSEModeHandler<Events, Params, Query, Headers, Body, ResponseSchemas>; /** SSE-only contracts do not support sync handlers */ sync?: never; }; /** * Infer the handler type based on contract type. * - SSE-only contracts: `{ sse: handler }` * - Dual-mode contracts: `{ sync: handler, sse: handler }` */ export type InferHandlers<Contract> = Contract extends AnyDualModeContractDefinition ? InferDualModeHandlers<Contract> : Contract extends AnySSEContractDefinition ? SSEOnlyHandlers<Contract['serverSentEventSchemas'], InferOptionalSchema<Contract['requestPathParamsSchema']>, InferOptionalSchema<Contract['requestQuerySchema']>, InferOptionalSchema<Contract['requestHeaderSchema']>, InferOptionalSchema<Contract['requestBodySchema'], undefined>, Contract['responseBodySchemasByStatusCode']> : never; /** * Helper type to infer the correct handlers type based on contract. */ type HandlersForContract<Contract> = Contract extends AnyDualModeContractDefinition ? InferDualModeHandlers<Contract> : Contract extends AnySSEContractDefinition ? SSEOnlyHandlers<Contract['serverSentEventSchemas'], InferOptionalSchema<Contract['requestPathParamsSchema']>, InferOptionalSchema<Contract['requestQuerySchema']>, InferOptionalSchema<Contract['requestHeaderSchema']>, InferOptionalSchema<Contract['requestBodySchema'], undefined>, Contract['responseBodySchemasByStatusCode']> : never; /** * Branded container for SSE route handlers. * Contains the contract, handlers, and optional route configuration. * * @template Contract - The SSE contract definition */ export type SSERouteHandler<Contract extends AnySSEContractDefinition> = { readonly __type: 'SSERouteHandler'; readonly contract: Contract; readonly handlers: SSEOnlyHandlers<Contract['serverSentEventSchemas'], InferOptionalSchema<Contract['requestPathParamsSchema']>, InferOptionalSchema<Contract['requestQuerySchema']>, InferOptionalSchema<Contract['requestHeaderSchema']>, InferOptionalSchema<Contract['requestBodySchema'], undefined>, Contract['responseBodySchemasByStatusCode']>; readonly options?: FastifySSERouteOptions; }; /** * Branded container for dual-mode route handlers. * Contains the contract, handlers, and optional route configuration. * * @template Contract - The dual-mode contract definition */ export type DualModeRouteHandler<Contract extends AnyDualModeContractDefinition> = { readonly __type: 'DualModeRouteHandler'; readonly contract: Contract; readonly handlers: InferDualModeHandlers<Contract>; readonly options?: FastifyDualModeRouteOptions; }; /** * Unified handler builder for both SSE-only and dual-mode contracts. * * Returns a branded container with the contract embedded, eliminating the need * to pass the contract separately when building routes. * * This function provides automatic type inference based on the contract type: * - **SSE-only contracts**: Provide `{ sse: handler }` only * - **Dual-mode contracts**: Provide both `{ sync: handler, sse: handler }` * * ## Handler Signatures * * **Sync handler** (dual-mode only): * ```typescript * sync: (request, reply) => SyncResponse | Promise<SyncResponse> * ``` * * **SSE handler** (both SSE-only and dual-mode): * ```typescript * sse: (request, sse) => SSEHandlerResult | Promise<SSEHandlerResult> * ``` * * The SSE handler receives an SSEContext that allows deferred header sending: * - `sse.respond(code, body)` - Return HTTP response before streaming (early return) * - `sse.start(mode)` - Start streaming (sends 200 + SSE headers), returns session * - `'autoClose'` - Close session after handler completes * - `'keepAlive'` - Keep session open for external events * * @see SSEContext for the sse parameter API * @see SSEHandlerResult for the possible return values * * @example * ```typescript * // SSE-only contract - request-response streaming with early return * const sseHandler = buildHandler(chatStreamContract, { * sse: async (request, sse) => { * const entity = await db.find(request.params.id) * if (!entity) { * return sse.respond(404, { error: 'Not found' }) * } * const session = sse.start('autoClose') * for (const word of request.body.message.split(' ')) { * await session.send('chunk', { delta: word }) * } * await session.send('done', { usage: { total: 5 } }) * }, * }) * * // SSE-only with options (3rd param) * const notificationHandler = buildHandler(notificationsContract, { * sse: async (request, sse) => { * const session = sse.start('keepAlive') * this.subscriptions.set(session.id, request.params.userId) * }, * }, { onConnect: ..., onClose: ... }) * * // Dual-mode contract - supports both sync and SSE responses * const dualModeHandler = buildHandler(chatCompletionContract, { * sync: (request, reply) => { * reply.header('x-custom', 'value') * return { reply: 'Hello', usage: { tokens: 5 } } * }, * sse: async (request, sse) => { * const session = sse.start('autoClose') * await session.send('chunk', { delta: 'Hello' }) * await session.send('done', { usage: { total: 5 } }) * }, * }, { preHandler: authHandler }) * ``` */ export declare function buildHandler<Contract extends AnySSEContractDefinition>(contract: Contract, handlers: HandlersForContract<Contract>, options?: FastifySSERouteOptions): SSERouteHandler<Contract>; export declare function buildHandler<Contract extends AnyDualModeContractDefinition>(contract: Contract, handlers: HandlersForContract<Contract>, options?: FastifyDualModeRouteOptions): DualModeRouteHandler<Contract>; /** * Options for registering SSE routes globally. */ export type RegisterSSERoutesOptions = { /** * Heartbeat interval in milliseconds. * @default 30000 */ heartbeatInterval?: number; /** * Custom serializer for SSE message data. * @default JSON.stringify */ serializer?: (data: unknown) => string; /** * Global preHandler hooks applied to all SSE routes. * Use for authentication that should apply to all SSE endpoints. * * IMPORTANT: Must return a Promise for SSE compatibility. * Synchronous handlers will cause connection issues. */ preHandler?: FastifySSEPreHandler; /** * Rate limit configuration (requires @fastify/rate-limit to be registered). * If @fastify/rate-limit is not registered, this config is ignored. */ rateLimit?: { /** Maximum number of connections */ max: number; /** Time window for rate limiting */ timeWindow: string | number; /** Custom key generator (e.g., for per-user limits) */ keyGenerator?: (request: unknown) => string; }; }; /** * Options for registering dual-mode routes globally. */ export type RegisterDualModeRoutesOptions = { /** * Heartbeat interval in milliseconds for SSE mode. * @default 30000 */ heartbeatInterval?: number; /** * Custom serializer for SSE message data. * @default JSON.stringify */ serializer?: (data: unknown) => string; /** * Global preHandler hooks applied to all dual-mode routes. * Use for authentication that should apply to all endpoints. * * IMPORTANT: Must return a Promise for SSE mode compatibility. * Synchronous handlers will cause connection issues in SSE mode. */ preHandler?: FastifySSEPreHandler; /** * Rate limit configuration (requires @fastify/rate-limit to be registered). * If @fastify/rate-limit is not registered, this config is ignored. */ rateLimit?: { /** Maximum number of requests */ max: number; /** Time window for rate limiting */ timeWindow: string | number; /** Custom key generator (e.g., for per-user limits) */ keyGenerator?: (request: unknown) => string; }; }; export {};