UNPKG

opinionated-machine

Version:

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

518 lines 20.2 kB
import { randomUUID } from 'node:crypto'; import { isErrorLike } from "../errorUtils.js"; /** * Extract Fastify path template from pathResolver. * * This function creates placeholder params with ':paramName' values and calls * the pathResolver to generate a Fastify-compatible path template. * * @example * ```typescript * // pathResolver: (p) => `/users/${p.userId}/posts/${p.postId}` * // paramsSchema: z.object({ userId: z.string(), postId: z.string() }) * // Result: '/users/:userId/posts/:postId' * ``` */ export function extractPathTemplate(pathResolver, paramsSchema) { // Create placeholder params object with ':paramName' values const placeholderParams = {}; for (const key of Object.keys(paramsSchema.shape)) { placeholderParams[key] = `:${key}`; } return pathResolver(placeholderParams); } /** * Check if an error has a valid httpStatusCode property (like PublicNonRecoverableError). * Uses duck typing instead of instanceof for reliability across realms. * Validates the status code is a finite integer within valid HTTP range (100-599). */ export function hasHttpStatusCode(err) { if (typeof err !== 'object' || err === null || !('httpStatusCode' in err)) { return false; } const statusCode = err.httpStatusCode; return (typeof statusCode === 'number' && Number.isFinite(statusCode) && Number.isInteger(statusCode) && statusCode >= 100 && statusCode <= 599); } /** * Send replay events from either sync or async iterables. */ export async function sendReplayEvents(sseReply, replayEvents) { // biome-ignore lint/suspicious/noExplicitAny: checking for iterator symbols const iterable = replayEvents; if (typeof iterable[Symbol.asyncIterator] === 'function') { for await (const event of replayEvents) { await sseReply.sse.send(event); } } else if (typeof iterable[Symbol.iterator] === 'function') { for (const event of replayEvents) { await sseReply.sse.send(event); } } } /** * Handle Last-Event-ID reconnection by replaying missed events. */ export async function handleReconnection(sseReply, connection, lastEventId, options, logPrefix = 'SSE') { if (!options?.onReconnect) return; try { const replayEvents = await options.onReconnect(connection, lastEventId); if (replayEvents) { await sendReplayEvents(sseReply, replayEvents); } } catch (err) { options?.logger?.error({ err, lastEventId }, `Error in ${logPrefix} onReconnect handler`); } } /** * Send error event to client and close connection gracefully. */ export async function handleSSEError(sseReply, controller, connectionId, err, logger) { // Send error event to client (bypasses validation since this is framework-level) try { await sseReply.sse.send({ event: 'error', data: { message: isErrorLike(err) ? err.message : 'Internal server error' }, }); } catch { // Connection might already be closed, ignore } closeSSESession(sseReply, controller, connectionId, logger); } /** * Create room operations object for the session. * If room manager is not available, returns no-op functions. */ function createRoomOperations(connection, controller, logger) { const roomManager = controller._internalRoomManager; if (!roomManager) { // Return no-op operations when rooms are not enabled return { join: () => { }, leave: () => { }, }; } const connectionId = connection.id; const sseReply = connection.reply; return { join: (room) => { // Guard against startup races where the stream is already aborted/destroyed: // joining such session to a room can leave stale members and block room broadcast. if (isSSEConnectionDead(connection)) { logger?.warn?.({ connectionId, room, }, 'Skipping room join for dead SSE session'); closeSSESession(sseReply, controller, connectionId, logger); return; } roomManager.join(connectionId, room, logger); }, leave: (room) => roomManager.leave(connectionId, room, logger), }; } function isSSEConnectionDead(connection) { const rawRequest = connection.request.raw; const rawResponse = connection.reply.raw; return (!connection.isConnected() || rawRequest.destroyed || rawRequest.aborted || rawResponse.destroyed || rawResponse.writableEnded); } /** * Close the underlying SSE stream and unregister the connection from the controller. */ function closeSSESession(sseReply, controller, connectionId, logger) { try { sseReply.sse.close(); } catch (err) { if (sseReply.sse.isConnected) { // Log error if connection closure failed and connection is still live logger?.error({ connectionId, error: isErrorLike(err) ? err.message : 'Internal server error', }, 'Failed to close SSE connection'); } } controller.unregisterConnection(connectionId); } /** * Create an SSE connection object with all helpers. * This is an internal helper used by createSSEContext. * * @internal */ function createSSESessionInternal(connectionId, request, reply, sseReply, eventSchemas, controller, initialContext, reconnectionPromise, logger) { // Create type-safe event sender for the handler // If reconnection is in progress, wait for it before sending to maintain event ordering const send = async (eventName, data, sendOptions) => { if (reconnectionPromise) { await reconnectionPromise; } return controller._sendEventRaw(connectionId, { event: eventName, data, id: sendOptions?.id, retry: sendOptions?.retry, }); }; // Create sendStream function that validates and sends messages from async iterable const sendStream = async (messages) => { for await (const message of messages) { // Validate against schema if available const schema = eventSchemas[message.event]; if (schema) { const result = schema.safeParse(message.data); if (!result.success) { throw new Error(`SSE event validation failed for '${message.event}': ${result.error.message}`); } } // Send the validated message await sseReply.sse.send({ event: message.event, data: message.data, id: message.id, retry: message.retry, }); } }; const connection = { id: connectionId, request, reply, context: (initialContext ?? {}), connectedAt: new Date(), send, isConnected: () => sseReply.sse.isConnected, getStream: () => sseReply.sse.stream(), sendStream, // Don't remove - rooms property is reassigned below as it needs connection object reference rooms: { join: (_room) => { }, leave: (_room) => { }, }, eventSchemas, }; connection.rooms = createRoomOperations(connection, controller, logger); return connection; } /** * Create an SSEContext for deferred header sending. * * This factory creates the `sse` parameter passed to SSE handlers, allowing: * - Validation before headers are sent * - Proper HTTP error responses (404, 422, etc.) * - Explicit streaming start via `sse.start()` * * @param controller - The SSE controller for connection management * @param request - The Fastify request * @param reply - The Fastify reply * @param eventSchemas - Event schemas for type-safe event sending * @param options - Lifecycle hooks and options * @param logPrefix - Prefix for log messages * * @returns SSEContext result with context object and state accessors */ export function createSSEContext(controller, request, reply, eventSchemas, options, logPrefix = 'SSE') { const connectionId = randomUUID(); const sseReply = reply; // State tracking let started = false; let responseSent = false; let headersSent = false; let connection; let sessionMode; let onCloseCalled = false; let responseData; // Helper to call onClose exactly once const callOnClose = async (reason) => { if (onCloseCalled || !connection) return; onCloseCalled = true; try { if (options?.onClose) { await options.onClose(connection, reason); } } catch (err) { options?.logger?.error({ err }, `Error in ${logPrefix} onClose handler`); } }; // Helper to fire onConnect hook (not awaited, errors logged) const fireOnConnect = (conn) => { if (!options?.onConnect) return; try { const maybePromise = options.onConnect(conn); if (maybePromise && typeof maybePromise.catch === 'function') { maybePromise.catch((err) => { options?.logger?.error({ err }, `Error in ${logPrefix} onConnect handler`); }); } } catch (err) { options?.logger?.error({ err }, `Error in ${logPrefix} onConnect handler`); } }; // Create a promise that will resolve when the connection closes const connectionClosed = new Promise((resolve) => { request.socket.on('close', async () => { // Call onClose for client-initiated closures (if not already called by server close) await callOnClose('client'); if (connection) { controller.unregisterConnection(connectionId); } resolve(); }); }); // The SSE context object passed to handlers const sseContext = { start: (mode, startOptions) => { if (started) { throw new Error('SSE streaming already started. Cannot call start() multiple times.'); } if (responseSent) { throw new Error('Cannot start streaming after sending a response.'); } started = true; sessionMode = mode; // Register callback for when server explicitly closes via reply.sse.close() sseReply.sse.onClose(async () => { await callOnClose('server'); }); // Send headers if not already sent via sendHeaders() if (!headersSent) { // Tell @fastify/sse to keep the connection open after handler returns sseReply.sse.keepAlive(); // Send headers and flush them to establish the stream sseReply.sse.sendHeaders(); reply.raw.flushHeaders(); headersSent = true; } // Handle reconnection with Last-Event-ID // Create a deferred promise so we can pass it to the connection before starting reconnection const lastEventId = request.headers['last-event-id']; let reconnectionResolve; const reconnectionPromise = lastEventId && options?.onReconnect ? new Promise((resolve) => { reconnectionResolve = resolve; }) : undefined; // Create connection with the reconnection promise // The send() method will await this promise to ensure event ordering connection = createSSESessionInternal(connectionId, request, reply, sseReply, eventSchemas, controller, startOptions?.context, reconnectionPromise, options?.logger); // Register connection with controller controller.registerConnection(connection); // Now that connection exists, handle reconnection with a valid SSESession if (lastEventId && options?.onReconnect && reconnectionResolve) { // Start reconnection asynchronously - connection.send() will wait for it ; (async () => { try { const replayEvents = await options.onReconnect?.(connection, lastEventId); if (replayEvents) { await sendReplayEvents(sseReply, replayEvents); } } catch (err) { options?.logger?.error({ err, lastEventId }, `Error in ${logPrefix} onReconnect handler`); } finally { reconnectionResolve?.(); } })(); } // Fire onConnect hook (not awaited per plan) fireOnConnect(connection); return connection; }, respond: (code, body) => { if (started) { throw new Error('Cannot send response after streaming has started.'); } responseSent = true; responseData = { code, body }; return { _type: 'respond', code, body }; }, sendHeaders: () => { if (headersSent) { throw new Error('Headers already sent. Cannot call sendHeaders() multiple times.'); } if (started) { throw new Error('Headers already sent via start().'); } if (responseSent) { throw new Error('Cannot send headers after sending a response.'); } sseReply.sse.keepAlive(); sseReply.sse.sendHeaders(); reply.raw.flushHeaders(); headersSent = true; }, reply, }; return { sseContext, connectionClosed, sseReply, getConnection: () => connection, getConnectionId: () => (started ? connectionId : undefined), isStarted: () => started, hasResponse: () => responseSent, getResponseData: () => responseData, getMode: () => sessionMode, }; } /** * Setup an SSE connection with all the boilerplate: * - Create connection object with typed event sender * - Register with controller * - Setup disconnect handler * - Initialize SSE reply (keepAlive, sendHeaders, flushHeaders) * - Handle reconnection * - Call onConnect hook * * @deprecated Use createSSEContext for new code. This function is kept for backwards compatibility. * * @returns Connection setup result with connection object and closed promise */ export async function setupSSESession(controller, request, reply, eventSchemas, options, logPrefix = 'SSE') { // Use the new context-based approach internally const result = createSSEContext(controller, request, reply, eventSchemas, options, logPrefix); // Auto-start the connection (old behavior - keepAlive by default) const connection = result.sseContext.start('keepAlive'); // Wait for onConnect to complete (old behavior was awaited) // Note: In the new API, onConnect is not awaited, but for backwards compat we simulate it // by giving it a tick to run await new Promise((resolve) => setImmediate(resolve)); return { connectionId: connection.id, connection, connectionClosed: result.connectionClosed, sseReply: result.sseReply, }; } /** * Determine response mode from Accept header. * * Parses the Accept header and determines whether to use JSON or SSE mode. * Supports quality values (q=) for content negotiation. * * @param accept - The Accept header value * @param defaultMode - Mode to use when no preference is specified * @returns The determined response mode */ export function determineMode(accept, defaultMode = 'json') { if (!accept) return defaultMode; // Split by comma and parse each media type with quality value const mediaTypes = accept .split(',') .map((part) => { const [mediaType, ...params] = part.trim().split(';'); let quality = 1.0; for (const param of params) { const [key, value] = param.trim().split('='); if (key === 'q' && value) { quality = Number.parseFloat(value); } } return { mediaType: (mediaType ?? '').trim().toLowerCase(), quality }; }) // Filter out rejected types (quality <= 0) .filter((entry) => entry.quality > 0); // Sort by quality (highest first) mediaTypes.sort((a, b) => b.quality - a.quality); // Find the first matching type for (const { mediaType } of mediaTypes) { if (mediaType === 'text/event-stream') { return 'sse'; } if (mediaType === 'application/json') { return 'json'; } } // If */* is present with highest priority, use default if (mediaTypes.some((m) => m.mediaType === '*/*')) { return defaultMode; } return defaultMode; } /** * Determine sync format from Accept header for content negotiation. * * Parses the Accept header and determines which format to use. * Supports quality values (q=) for content negotiation and subtype wildcards * (e.g., "application/*", "text/*"). * * Matching priority: * 1. text/event-stream (SSE mode) * 2. Exact matches against supportedFormats * 3. Subtype wildcards (e.g., "text/*" matches first "text/..." in supportedFormats) * 4. Full wildcard (*\/*) uses fallback format * 5. Fallback to defaultFormat or first supported format * * @param accept - The Accept header value * @param supportedFormats - Array of Content-Types that the route supports * @param defaultFormat - Format to use when no preference is specified (default: first supported format) * @returns The determined format or 'sse' mode indicator */ export function determineSyncFormat(accept, supportedFormats, defaultFormat) { const fallbackFormat = defaultFormat ?? supportedFormats[0] ?? 'application/json'; if (!accept) { return { mode: 'sync', contentType: fallbackFormat }; } // Split by comma and parse each media type with quality value const mediaTypes = accept .split(',') .map((part) => { const [mediaType, ...params] = part.trim().split(';'); let quality = 1.0; for (const param of params) { const [key, value] = param.trim().split('='); if (key === 'q' && value) { quality = Number.parseFloat(value); } } return { mediaType: (mediaType ?? '').trim().toLowerCase(), quality }; }) // Filter out rejected types (quality <= 0) .filter((entry) => entry.quality > 0); // Sort by quality (highest first) mediaTypes.sort((a, b) => b.quality - a.quality); // Find the first matching type for (const { mediaType } of mediaTypes) { // SSE takes priority if requested if (mediaType === 'text/event-stream') { return { mode: 'sse' }; } // Check exact match against supported formats if (supportedFormats.includes(mediaType)) { return { mode: 'sync', contentType: mediaType }; } // Check subtype wildcard (e.g., "application/*", "text/*") if (mediaType.endsWith('/*')) { const mainType = mediaType.slice(0, -2); // Extract "application" from "application/*" const matchedFormat = supportedFormats.find((format) => format.startsWith(`${mainType}/`)); if (matchedFormat) { return { mode: 'sync', contentType: matchedFormat }; } } } // If */* is present, use default format if (mediaTypes.some((m) => m.mediaType === '*/*')) { return { mode: 'sync', contentType: fallbackFormat }; } // Default to first supported format return { mode: 'sync', contentType: fallbackFormat }; } //# sourceMappingURL=fastifyRouteUtils.js.map