UNPKG

opinionated-machine

Version:

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

371 lines 16.2 kB
import { randomUUID } from 'node:crypto'; import { ContractNoBody, getSseSchemaByEventName, hasAnySuccessSseResponse, isAnyOfResponses, isBlobResponse, isSseResponse, isTextResponse, mapApiContractToPath, SUCCESSFUL_HTTP_STATUS_CODES, } from '@lokalise/api-contracts'; import { InternalError } from '@lokalise/node-core'; import { isErrorLike } from "../errorUtils.js"; import { attachGatewayMetadata } from "../gateway/withGatewayMetadata.js"; import { determineMode, hasHttpStatusCode } from "../routes/fastifyRouteUtils.js"; function isSuccessResponseDual(value) { if (value === ContractNoBody || isTextResponse(value) || isBlobResponse(value)) return true; if (!isSseResponse(value) && !isAnyOfResponses(value)) return true; if (isAnyOfResponses(value)) { return value.responses.some((response) => !isSseResponse(response)); } return false; } function getContractResponseMode(contract) { if (!hasAnySuccessSseResponse(contract)) return 'non-sse'; for (const code of SUCCESSFUL_HTTP_STATUS_CODES) { const value = contract.responsesByStatusCode[code]; if (value && isSuccessResponseDual(value)) return 'dual'; } return 'sse'; } function buildSSERouteConfig(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; } // ============================================================================ // Internal Helpers — Sync Route // ============================================================================ function getSchemaForStatusCode(contract, status) { const entry = contract.responsesByStatusCode[status]; if (!entry || entry === ContractNoBody || isSseResponse(entry) || isTextResponse(entry) || isBlobResponse(entry)) { return null; } if (isAnyOfResponses(entry)) { for (const anyResponse of entry.responses) { if (isSseResponse(anyResponse) || isTextResponse(anyResponse) || isBlobResponse(anyResponse)) { continue; } return anyResponse; } return null; } else { return entry; } } function validateApiResponseHeaders(contract, reply) { const schema = contract.responseHeaderSchema; if (!schema) { return; } const result = schema.safeParse(reply.getHeaders()); if (!result.success) { throw new InternalError({ message: 'Internal Server Error', errorCode: 'RESPONSE_HEADERS_VALIDATION_FAILED', details: { validationError: result.error.message }, }); } } async function handleApiSyncRoute(contract, // biome-ignore lint/suspicious/noExplicitAny: Handler types are validated by InferApiHandler at the call site handler, // biome-ignore lint/suspicious/noExplicitAny: Request types are validated by Fastify schema request, reply) { const { status, body } = await handler(request, reply); 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, }); return; } try { const schema = getSchemaForStatusCode(contract, status); if (schema) { const result = schema.safeParse(body); if (!result.success) { throw new InternalError({ message: 'Internal Server Error', errorCode: 'RESPONSE_VALIDATION_FAILED', details: { validationError: result.error.message }, }); } } } catch (err) { reply.code(500); throw err; } validateApiResponseHeaders(contract, reply); if (!reply.hasHeader('content-type')) { reply.type('application/json'); } return reply.code(status).send(body); } // ============================================================================ // Internal Helpers — SSE Route (no controller, uses reply.sse directly) // ============================================================================ function buildApiSSEContext( // biome-ignore lint/suspicious/noExplicitAny: Request types are validated by Fastify schema request, reply, eventSchemas, options) { let started = false; let responseData; const sseReply = reply; const sseContext = { start: (mode, startOptions) => { started = true; if (mode === 'keepAlive') { sseReply.sse.keepAlive(); } // sendHeaders() calls writeHead(200) but only queues headers in the buffer. // flushHeaders() forces them onto the wire so the client's fetch() returns. sseReply.sse.sendHeaders(); reply.raw.flushHeaders(); const connectionId = randomUUID(); const send = async (eventName, data, sendOptions) => { const schema = eventSchemas[eventName]; if (schema) { const result = schema.safeParse(data); if (!result.success) { throw new InternalError({ message: `SSE event validation failed for event "${eventName}": ${result.error.message}`, errorCode: 'RESPONSE_VALIDATION_FAILED', }); } } try { await sseReply.sse.send({ event: eventName, data, id: sendOptions?.id, retry: sendOptions?.retry, }); return true; } catch { return false; } }; const session = { id: connectionId, request, reply, context: (startOptions?.context ?? {}), connectedAt: new Date(), // biome-ignore lint/suspicious/noExplicitAny: SSEEventSender generic is satisfied at handler call site send: send, isConnected: () => sseReply.sse.isConnected, getStream: () => sseReply.sse.stream(), sendStream: async (messages) => { for await (const message of messages) { await send(message.event, message.data, { id: message.id, retry: message.retry }); } }, rooms: { join: () => { }, leave: () => { } }, eventSchemas, }; if (options?.onConnect) { void Promise.resolve(options.onConnect(session)).catch(() => { }); } if (options?.onClose) { const onClose = options.onClose; sseReply.sse.onClose(() => { void Promise.resolve(onClose(session, 'client')).catch(() => { }); }); } if (options?.onReconnect && sseReply.sse.lastEventId) { const onReconnect = options.onReconnect; const lastEventId = sseReply.sse.lastEventId; void sseReply.sse.replay(async () => { const replay = await onReconnect(session, lastEventId); if (replay) { for await (const msg of replay) { await sseReply.sse.send(msg); } } }); } return session; }, respond: ((code, body) => { if (started) { throw new Error('Cannot call sse.respond() after sse.start() — the SSE stream is already open.'); } responseData = { code, body }; return { _type: 'respond', code, body }; // biome-ignore lint/suspicious/noExplicitAny: respond typing is enforced by contract at call site }), sendHeaders: () => { sseReply.sse.sendHeaders(); }, reply, }; return { sseContext, isStarted: () => started, hasResponse: () => responseData !== undefined, getResponseData: () => responseData, }; } // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Core SSE handler coordinates context, error handling, and lifecycle async function handleApiSseRoute( // biome-ignore lint/suspicious/noExplicitAny: SSE handler types are validated by InferApiHandler at call site sseHandler, eventSchemas, options, // biome-ignore lint/suspicious/noExplicitAny: Request types are validated by Fastify schema request, reply) { const { sseContext, isStarted, hasResponse, getResponseData } = buildApiSSEContext(request, reply, eventSchemas, options); try { await sseHandler(request, sseContext); if (!isStarted() && !hasResponse()) { throw new Error('SSE handler must either send a response (sse.respond()) ' + 'or start streaming (sse.start()). Handler returned without doing either.'); } const responseData = getResponseData(); if (responseData) { // Early HTTP response (sse.respond() was called before streaming) reply.removeHeader('cache-control'); reply.removeHeader('x-accel-buffering'); reply.type('application/json').code(responseData.code).send(responseData.body); } // If started, @fastify/sse manages the rest of the connection lifecycle } catch (err) { if (isStarted()) { // Headers already sent — can't change status code; try to send error event const sseReply = reply; if (sseReply.sse.isConnected) { try { await sseReply.sse.send({ event: 'error', data: { message: isErrorLike(err) ? err.message : 'Internal Server Error' }, }); } catch { // Ignore send failures during error handling } } throw err; } // Streaming not started — send HTTP error response const message = isErrorLike(err) ? err.message : 'Internal Server Error'; 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 }); } } // ============================================================================ // Internal Helpers — Schema // ============================================================================ function buildResponseSchemas(contract) { return Object.keys(contract.responsesByStatusCode).reduce((acc, statusCode) => { const schema = getSchemaForStatusCode(contract, Number(statusCode)); if (schema) { acc[Number(statusCode)] = schema; } return acc; }, {}); } function buildBaseSchema(contract) { const schema = {}; if (contract.requestPathParamsSchema) schema.params = contract.requestPathParamsSchema; if (contract.requestQuerySchema) schema.querystring = contract.requestQuerySchema; if (contract.requestHeaderSchema) schema.headers = contract.requestHeaderSchema; if (contract.requestBodySchema !== undefined && contract.requestBodySchema !== ContractNoBody) { schema.body = contract.requestBodySchema; } schema.response = buildResponseSchemas(contract); return schema; } // ============================================================================ // Public API // ============================================================================ /** * Build a Fastify `RouteOptions` object from an `ApiContract` + handler. * * The handler shape is inferred from the contract's response mode: * - `'non-sse'` — bare async function returning `{ status, body }` * - `'sse'` — bare async function calling `sse.start(...)` / `sse.respond(...)` * - `'dual'` — `{ nonSse, sse }` object branched by the `Accept` header * * The optional `options` argument carries: * - any Fastify route field (`preHandler`, `onRequest`, `config`, `bodyLimit`, …) * minus the ones the contract provides (`method`, `url`, `schema`, `handler`, `sse`), * - SSE lifecycle hooks (`onConnect`, `onClose`, `onReconnect`, `serializer`, * `heartbeatInterval`) — applied for `'sse'` and `'dual'` contracts only, * - `defaultMode` for `'dual'` contracts when the `Accept` header is ambiguous, * - `gatewayMetadata` — per-route gateway policy with header / query keys * narrowed to the contract; equivalent to wrapping the result with * `withGatewayMetadata`. See `ApiRouteOptions` for full details. * * @returns Fastify `RouteOptions` ready to pass to `app.route()` */ export function buildApiRoute(contract, handler, options) { // Separate SSE-specific options (not in Fastify RouteOptions) and gateway // metadata (stamped via Symbol, not spread) from passthrough options. const { defaultMode, contractMetadataToRouteMapper, gatewayMetadata, serializer: _serializer, heartbeatInterval: _heartbeatInterval, onConnect: _onConnect, onClose: _onClose, onReconnect: _onReconnect, logger: _logger, ...fastifyOptions } = options ?? {}; const url = mapApiContractToPath(contract); const mode = getContractResponseMode(contract); const eventSchemas = getSseSchemaByEventName(contract) ?? {}; const baseSchema = buildBaseSchema(contract); const contractMetadata = contractMetadataToRouteMapper?.(contract.metadata) ?? {}; const finalize = (route) => gatewayMetadata !== undefined ? attachGatewayMetadata(route, gatewayMetadata) : route; if (mode === 'non-sse') { // biome-ignore lint/suspicious/noExplicitAny: handler shape validated by InferApiHandler at call site const syncHandler = handler; return finalize({ ...fastifyOptions, ...contractMetadata, method: contract.method, url, schema: baseSchema, handler: async (request, reply) => handleApiSyncRoute(contract, syncHandler, request, reply), }); } if (mode === 'dual') { const resolvedDefaultMode = defaultMode ?? 'json'; // biome-ignore lint/suspicious/noExplicitAny: handler shape validated by InferApiHandler at call site const dualHandlers = handler; return finalize({ ...fastifyOptions, ...contractMetadata, method: contract.method, url, sse: buildSSERouteConfig(options), schema: baseSchema, handler: (request, reply) => { const responseMode = determineMode(request.headers.accept, resolvedDefaultMode); if (responseMode === 'json') { return handleApiSyncRoute(contract, dualHandlers.nonSse, request, reply); } return handleApiSseRoute(dualHandlers.sse, eventSchemas, options, request, reply); }, }); } // SSE-only // biome-ignore lint/suspicious/noExplicitAny: handler shape validated by InferApiHandler at call site const sseHandler = handler; return finalize({ ...fastifyOptions, ...contractMetadata, method: contract.method, url, sse: buildSSERouteConfig(options), schema: baseSchema, handler: async (request, reply) => handleApiSseRoute(sseHandler, eventSchemas, options, request, reply), }); } //# sourceMappingURL=apiRouteBuilder.js.map