opinionated-machine
Version:
Very opinionated DI framework for fastify, built on top of awilix
443 lines • 21.1 kB
JavaScript
import { InternalError } from '@lokalise/node-core';
import { ZodObject } from 'zod';
import { isErrorLike } from "../errorUtils.js";
import { createSSEContext, determineMode, extractPathTemplate, handleSSEError, hasHttpStatusCode, } from "./fastifyRouteUtils.js";
// Re-export for convenience
export { extractPathTemplate };
/**
* Build the SSE config object for route options.
* Returns true for basic SSE support, or an object with custom serializer/heartbeat.
*/
function buildSSEConfig(options) {
if (!options?.serializer && options?.heartbeatInterval === undefined) {
return true;
}
const sseConfig = {};
if (options.serializer) {
sseConfig.serializer = options.serializer;
}
if (options.heartbeatInterval !== undefined) {
sseConfig.heartbeatInterval = options.heartbeatInterval;
}
return sseConfig;
}
/**
* Validate response body against the successResponseBodySchema (for 2xx success responses).
*
* Only validates if the contract defines a successResponseBodySchema.
* Validation errors are not exposed to clients - only logged internally.
*
* @param contract - The dual-mode contract containing the successResponseBodySchema
* @param response - The response body to validate
* @throws {InternalError} When validation fails with errorCode 'RESPONSE_VALIDATION_FAILED'
*/
function validateSyncResponseBody(contract, response) {
const schema = contract.successResponseBodySchema;
if (!schema)
return;
const result = schema.safeParse(response);
if (!result.success) {
throw new InternalError({
message: 'Internal Server Error',
errorCode: 'RESPONSE_VALIDATION_FAILED',
details: { validationError: result.error.message },
});
}
}
/**
* Validate response body against responseSchemasByStatusCode for a specific HTTP status code.
*
* Used for non-2xx responses in dual-mode sync handlers and for sse.respond() in SSE handlers.
* Typically used for error responses, but can validate any status code with a defined schema.
* Only validates if the contract defines a schema for the given status code.
* Validation errors are not exposed to clients - only logged internally.
*
* @param responseSchemasByStatusCode - Map of HTTP status codes to Zod schemas (e.g., { 400: z.object(...), 404: z.object(...) })
* @param statusCode - The HTTP status code of the response
* @param response - The response body to validate
* @throws {InternalError} When validation fails with errorCode 'RESPONSE_VALIDATION_FAILED' and statusCode in details
*
* @example
* ```typescript
* // In a contract definition:
* const contract = buildContract({
* responseBodySchemasByStatusCode: {
* 400: z.object({ error: z.string(), details: z.array(z.string()) }),
* 404: z.object({ error: z.string(), resourceId: z.string() }),
* },
* // ... other contract properties
* })
*
* // In a handler returning a 404:
* sync: (request, reply) => {
* reply.code(404)
* return { error: 'Not Found', resourceId: 'item-123' } // Validated against 404 schema
* }
* ```
*/
function validateResponseByStatusCode(responseSchemasByStatusCode, statusCode, response) {
if (!responseSchemasByStatusCode)
return;
// Access the schema - keys may be stored as strings due to JavaScript object behavior
const schema = responseSchemasByStatusCode[String(statusCode)];
if (!schema)
return;
const result = schema.safeParse(response);
if (!result.success) {
throw new InternalError({
message: 'Internal Server Error',
errorCode: 'RESPONSE_VALIDATION_FAILED',
details: { statusCode, validationError: result.error.message },
});
}
}
/**
* Validate response headers against the responseHeaders schema.
* Throws InternalError with generic message - validation details are in the error details, not exposed to clients.
*/
function validateResponseHeaders(responseHeadersSchema, reply) {
if (!responseHeadersSchema)
return;
if (!('shape' in responseHeadersSchema))
return;
// Build object with all schema keys (including missing ones) so Zod can validate required fields
const schemaKeys = Object.keys(responseHeadersSchema.shape);
const headersToValidate = {};
for (const key of schemaKeys) {
headersToValidate[key] = reply.getHeader(key);
}
const result = responseHeadersSchema.safeParse(headersToValidate);
if (!result.success) {
throw new InternalError({
message: 'Internal Server Error',
errorCode: 'RESPONSE_HEADERS_VALIDATION_FAILED',
details: { validationError: result.error.message },
});
}
}
/**
* Handle sync mode request.
*/
async function handleSyncMode(contract, handlers,
// biome-ignore lint/suspicious/noExplicitAny: Request types are validated by Fastify schema
request, reply) {
// biome-ignore lint/suspicious/noExplicitAny: Handler type depends on contract
const response = await handlers.sync(request, reply);
// If the handler already called reply.send() directly (e.g. reply.status(200).send(data)),
// the reply is already sent. Skip validation and sending to avoid errors.
// Note: this means response validation is bypassed when handlers send replies directly.
// Handlers should return the response body and let the framework send it.
if (reply.sent) {
request.log.warn({
msg: 'Sync handler sent response directly, bypassing response validation',
tag: 'response_sent_directly',
method: request.method,
url: request.url,
routePath: request.routeOptions?.url,
reqId: request.id,
});
return;
}
// Get the status code that was set by the handler (defaults to 200)
const statusCode = reply.statusCode ?? 200;
// Validate response based on status code:
// - 2xx success codes: use successResponseBodySchema
// - Other codes: use responseBodySchemasByStatusCode if defined
try {
if (statusCode >= 200 && statusCode < 300) {
validateSyncResponseBody(contract, response);
}
else {
validateResponseByStatusCode(contract.responseBodySchemasByStatusCode, statusCode, response);
}
}
catch (err) {
// Reset status code to 500 for validation errors
// This is needed because the handler may have set a different status code (e.g., 404)
// and Fastify would use that status code when sending the error response
reply.code(500);
throw err;
}
// Explicitly set content-type to override SSE default (from sse: true option)
reply.type('application/json');
validateResponseHeaders(contract.responseHeaderSchema, reply);
return reply.send(response);
}
/**
* Process SSE handler result and manage connection lifecycle.
*/
async function processSSEHandlerResult(responseData, controller, connectionId, connectionClosed, reply, mode, responseSchemasByStatusCode) {
// Check if handler called sse.respond() (early return before streaming started)
if (responseData) {
// Validate sse.respond() body against responseSchemasByStatusCode if defined
validateResponseByStatusCode(responseSchemasByStatusCode, responseData.code, responseData.body);
// Send HTTP response (early return before streaming started).
// Clean up SSE-specific headers that @fastify/sse sets early in the lifecycle.
// Not strictly necessary, but avoids confusing headers on JSON responses.
reply.removeHeader('cache-control');
reply.removeHeader('x-accel-buffering');
// Critical: override content-type from text/event-stream to application/json,
// otherwise the zod serializer compiler won't serialize the body correctly.
reply.type('application/json').code(responseData.code).send(responseData.body);
return;
}
// Streaming was started, mode determines what happens next
if (mode === 'autoClose') {
// Request-response streaming: close session after handler completes
if (connectionId) {
controller.closeConnection(connectionId);
}
}
else if (mode === 'keepAlive') {
// Long-lived session: wait for client to disconnect
await connectionClosed;
}
}
/**
* Handle SSE mode request for dual-mode routes.
*/
async function handleSSEMode(controller, contract, handlers,
// biome-ignore lint/suspicious/noExplicitAny: Request types are validated by Fastify schema
request, reply, options) {
const contextResult = createSSEContext(controller, request, reply, contract.serverSentEventSchemas, options, 'dual-mode SSE');
try {
// biome-ignore lint/suspicious/noExplicitAny: SSEContext types are validated by FastifyDualModeHandlerConfig
await handlers.sse(request, contextResult.sseContext);
// Check for forgotten start() detection
// Handler must either start streaming OR call sse.respond()
// Note: With autoClose mode, handlers return void after start(), which is valid
if (!contextResult.isStarted() && !contextResult.hasResponse()) {
throw new Error('SSE handler must either send a response (sse.respond()) ' +
'or start streaming (sse.start()). Handler returned without doing either.');
}
// Process the result
await processSSEHandlerResult(contextResult.getResponseData(), controller, contextResult.getConnectionId(), contextResult.connectionClosed, reply, contextResult.getMode(), contract.responseBodySchemasByStatusCode);
}
catch (err) {
// If streaming was started, send error event to client and re-throw for logging
if (contextResult.isStarted()) {
const connectionId = contextResult.getConnectionId();
if (connectionId) {
await handleSSEError(contextResult.sseReply, controller, connectionId, err, options?.logger);
}
// Re-throw for Fastify's onError hooks (status can't change after headers sent)
throw err;
}
// Streaming not started - explicitly send HTTP error response
// We must handle this ourselves because the zod serializer compiler
// interferes with Fastify's default error handler for SSE routes,
// causing thrown errors to return 200 with empty SSE response instead of 500
const message = isErrorLike(err) ? err.message : 'Internal Server Error';
// Respect httpStatusCode from errors like PublicNonRecoverableError
const statusCode = hasHttpStatusCode(err) ? err.httpStatusCode : 500;
const statusText = statusCode >= 500 ? 'Internal Server Error' : 'Error';
reply.code(statusCode).type('application/json').send({
statusCode,
error: statusText,
message,
});
}
}
/**
* Build a Fastify route configuration for a dual-mode endpoint.
*
* This function creates a route that handles both JSON and SSE responses
* based on the Accept header, integrating with @fastify/sse for SSE mode
* and the AbstractDualModeController for connection management.
*
* @param controller - The dual-mode controller instance
* @param config - The dual-mode handler configuration
* @returns Fastify route options
*/
function buildDualModeRouteInternal(controller, config) {
const { contract, handlers, options } = config;
const defaultMode = options?.defaultMode ?? 'json';
// Extract Fastify path template from pathResolver
// Runtime guard: extractPathTemplate requires a ZodObject to access .shape for parameter names
if (!(contract.requestPathParamsSchema instanceof ZodObject)) {
throw new InternalError({
message: `Route params schema must be a ZodObject for path template extraction, got ${contract.requestPathParamsSchema?.constructor.name ?? 'undefined'}`,
errorCode: 'INVALID_PARAMS_SCHEMA',
});
}
const url = extractPathTemplate(contract.pathResolver, contract.requestPathParamsSchema);
const routeOptions = {
...(options?.contractMetadataToRouteMapper?.(contract.metadata) ?? {}),
method: contract.method,
url,
sse: buildSSEConfig(options), // Enable SSE support with optional per-route config
schema: {
params: contract.requestPathParamsSchema,
querystring: contract.requestQuerySchema,
headers: contract.requestHeaderSchema,
...(contract.requestBodySchema && { body: contract.requestBodySchema }),
// Note: response schema for sync mode could be added here
},
handler: async (request, reply) => {
// Determine mode based on Accept header
const mode = determineMode(request.headers.accept, defaultMode);
if (mode === 'json') {
return await handleSyncMode(contract, handlers, request, reply);
}
return await handleSSEMode(controller, contract, handlers, request, reply, options);
},
};
// Add preHandler hooks for authentication
if (options?.preHandler) {
routeOptions.preHandler = options.preHandler;
}
return routeOptions;
}
/**
* Build a Fastify route configuration for an SSE endpoint.
*
* This function creates a route that integrates with @fastify/sse
* and the AbstractSSEController connection management.
*
* @param controller - The SSE controller instance
* @param config - The SSE handler configuration
* @returns Fastify route options
*/
function buildSSERouteInternal(controller, config) {
const { contract, handlers, options } = config;
// Runtime guard: extractPathTemplate requires a ZodObject to access .shape for parameter names
if (!(contract.requestPathParamsSchema instanceof ZodObject)) {
throw new InternalError({
message: `Route params schema must be a ZodObject for path template extraction, got ${contract.requestPathParamsSchema?.constructor.name ?? 'undefined'}`,
errorCode: 'INVALID_PARAMS_SCHEMA',
});
}
const url = extractPathTemplate(contract.pathResolver, contract.requestPathParamsSchema);
const routeOptions = {
...(options?.contractMetadataToRouteMapper?.(contract.metadata) ?? {}),
method: contract.method,
url,
sse: buildSSEConfig(options), // Enable SSE support with optional per-route config
schema: {
params: contract.requestPathParamsSchema,
querystring: contract.requestQuerySchema,
headers: contract.requestHeaderSchema,
...(contract.requestBodySchema && { body: contract.requestBodySchema }),
},
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Core SSE route handler must coordinate context, error handling, and result processing
handler: async (request, reply) => {
// Create SSE context for deferred header sending
const contextResult = createSSEContext(controller, request, reply, contract.serverSentEventSchemas, options, 'SSE');
// Call user handler with (request, sse) signature
// Handler can call sse.respond() without returning it, or return it - both work
try {
// biome-ignore lint/suspicious/noExplicitAny: Request and SSEContext types are validated by FastifySSEHandlerConfig
await handlers.sse(request, contextResult.sseContext);
// Check for forgotten start() detection
// Handler must either start streaming OR call sse.respond()
// Note: With autoClose mode, handlers return void after start(), which is valid
if (!contextResult.isStarted() && !contextResult.hasResponse()) {
throw new Error('SSE handler must either send a response (sse.respond()) ' +
'or start streaming (sse.start()). Handler returned without doing either.');
}
// Process the result
await processSSEHandlerResult(contextResult.getResponseData(), controller, contextResult.getConnectionId(), contextResult.connectionClosed, reply, contextResult.getMode(), contract.responseBodySchemasByStatusCode);
}
catch (err) {
// If streaming was started, send error event to client and re-throw for logging
if (contextResult.isStarted()) {
const connectionId = contextResult.getConnectionId();
if (connectionId) {
await handleSSEError(contextResult.sseReply, controller, connectionId, err, options?.logger);
}
// Re-throw for Fastify's onError hooks (status can't change after headers sent)
throw err;
}
// Streaming not started - explicitly send HTTP error response
// We must handle this ourselves because the zod serializer compiler
// interferes with Fastify's default error handler for SSE routes,
// causing thrown errors to return 200 with empty SSE response instead of 500
const message = isErrorLike(err) ? err.message : 'Internal Server Error';
// Respect httpStatusCode from errors like PublicNonRecoverableError
const statusCode = hasHttpStatusCode(err) ? err.httpStatusCode : 500;
const statusText = statusCode >= 500 ? 'Internal Server Error' : 'Error';
reply.code(statusCode).type('application/json').send({
statusCode,
error: statusText,
message,
});
}
},
};
// Add preHandler hooks for authentication
if (options?.preHandler) {
routeOptions.preHandler = options.preHandler;
}
return routeOptions;
}
/**
* Build a Fastify route configuration for SSE or dual-mode endpoints.
*
* This unified function creates routes that integrate with @fastify/sse. The handler type
* determines the behavior:
*
* - **SSE route handlers**: Creates SSE-only routes that stream events
* - **Dual-mode route handlers**: Creates routes that branch on Accept header
* - `Accept: application/json` → Sync response
* - `Accept: text/event-stream` → SSE streaming
*
* @example
* ```typescript
* // SSE-only route with deferred headers (can return early)
* const sseHandler = buildHandler(notificationsContract, {
* 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('keepAlive')
* await session.send('notification', { message: 'Hello!' })
* },
* }, { onConnect: ..., onClose: ... })
*
* // Dual-mode route
* const dualModeHandler = buildHandler(chatCompletionContract, {
* sync: (request, reply) => {
* return { reply: 'Hello', usage: { tokens: 1 } }
* },
* sse: async (request, sse) => {
* const session = sse.start('autoClose')
* await session.send('chunk', { delta: 'Hello' })
* await session.send('done', { usage: { total: 1 } })
* },
* }, { preHandler: authHandler })
*
* // Register with Fastify
* app.route(buildFastifyRoute(notificationsController, sseHandler))
* app.route(buildFastifyRoute(chatController, dualModeHandler))
* ```
*/
export function buildFastifyRoute(controller, handler) {
if (handler.__type === 'DualModeRouteHandler') {
const dualModeHandler = handler;
return buildDualModeRouteInternal(controller, {
contract: dualModeHandler.contract,
handlers: dualModeHandler.handlers,
options: dualModeHandler.options,
});
}
if (handler.__type === 'SSERouteHandler') {
const sseHandler = handler;
return buildSSERouteInternal(controller, {
contract: sseHandler.contract,
handlers: sseHandler.handlers,
options: sseHandler.options,
});
}
// Unknown handler type - throw descriptive error
const unknownHandler = handler;
const handlerType = unknownHandler.__type ?? 'undefined';
const handlerIdentity = typeof unknownHandler.contract?.pathResolver === 'function'
? `contract with path "${unknownHandler.contract.pathResolver({})}"`
: 'unknown handler';
throw new Error(`buildFastifyRoute received unexpected handler.__type: "${handlerType}" for ${handlerIdentity}. ` +
`Expected "DualModeRouteHandler" (for use with AbstractDualModeController and buildDualModeRouteInternal) ` +
`or "SSERouteHandler" (for use with AbstractSSEController and buildSSERouteInternal). ` +
`Ensure the handler was created using buildHandler().`);
}
//# sourceMappingURL=fastifyRouteBuilder.js.map