opinionated-machine
Version:
Very opinionated DI framework for fastify, built on top of awilix
371 lines • 16.2 kB
JavaScript
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