UNPKG

opinionated-machine

Version:

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

334 lines (333 loc) 14 kB
import type { AllContractEventNames, AnySSEContractDefinition, ExtractEventSchema } from '@lokalise/api-contracts'; import type { z } from 'zod'; import type { BuildFastifySSERoutesReturnType, SSESession } from '../routes/fastifyRouteTypes.ts'; import type { SSERoomBroadcaster } from './rooms/SSERoomBroadcaster.ts'; import type { SSERoomManager } from './rooms/SSERoomManager.ts'; import type { RoomBroadcastOptions } from './rooms/types.ts'; import { SSESessionSpy } from './SSESessionSpy.ts'; import type { SSEControllerConfig, SSEMessage } from './sseTypes.ts'; export type { BuildFastifySSERoutesReturnType, FastifySSEHandlerConfig, FastifySSEPreHandler, FastifySSERouteOptions, InferSSERequest, SSEContext, SSEHandlerResult, SSERespondResult, SSESession, SSESessionMode, SSEStartOptions, } from '../routes/fastifyRouteTypes.ts'; export type { SSESessionEvent } from './SSESessionSpy.ts'; export { SSESessionSpy } from './SSESessionSpy.ts'; export type { AllContractEventNames, AllContractEvents, ExtractEventSchema, SSEControllerConfig, SSEEventSchemas, SSEEventSender, SSELogger, SSEMessage, } from './sseTypes.ts'; /** * Abstract base class for SSE controllers. * * Provides connection management, broadcasting, and lifecycle hooks. * Extend this class to create SSE controllers that handle real-time * streaming connections. * * @template APIContracts - Map of route names to SSE route definitions * * @example * ```typescript * class NotificationsSSEController extends AbstractSSEController<typeof contracts> { * public static contracts = { * notifications: buildSseContract({ ... }), * } as const * * public buildSSERoutes() { * return { * notifications: this.handleNotifications, * } * } * * private handleNotifications = buildHandler( * NotificationsSSEController.contracts.notifications, * { * sse: async (request, sse) => { * const session = sse.start('autoClose') * await session.send('notification', { message: 'Hello!' }) * // Connection closes automatically when handler returns * }, * }, * ) * } * ``` */ export declare abstract class AbstractSSEController<APIContracts extends Record<string, AnySSEContractDefinition>> { /** Map of connection ID to connection object */ protected connections: Map<string, SSESession>; /** Private storage for connection spy */ private readonly _connectionSpy?; /** Room manager for room-based broadcasting (optional) */ private readonly _roomManager?; /** Room broadcaster for decoupled room broadcasting (optional) */ private readonly _roomBroadcaster?; /** * SSE controllers must override this constructor and call super with their * dependencies object and the SSE config. * * @param _dependencies - The dependencies object (cradle proxy in awilix) * @param sseConfig - Optional SSE controller configuration * * @example * ```typescript * class MySSEController extends AbstractSSEController<MyContracts> { * private myService: MyService * * constructor(deps: { myService: MyService }, sseConfig?: SSEControllerConfig) { * super(deps, sseConfig) * this.myService = deps.myService * } * } * ``` */ constructor(_dependencies: object, sseConfig?: SSEControllerConfig); /** * Get the connection spy for testing. * Throws an error if spies are not enabled. * Enable spies by passing `{ enableConnectionSpy: true }` to the constructor. * * @example * ```typescript * // In test, create controller with spy enabled * // Pass dependencies first, then config with enableConnectionSpy * const controller = new MySSEController({}, { enableConnectionSpy: true }) * * // Start connection (async) * connectSSE(baseUrl, '/api/stream') * * // Wait for connection - handles race condition * const connection = await controller.connectionSpy.waitForConnection() * ``` * * @throws Error if connection spy is not enabled */ get connectionSpy(): SSESessionSpy; /** * Get the shared room broadcaster instance. * * The broadcaster is the same singleton that's registered in DI as `sseRoomBroadcaster`. * Domain services should resolve it directly from DI rather than going through the controller. * * @throws Error if rooms are not enabled in the controller config * * **Required DI registrations** (must be registered before the controller): * - `sseRoomManager` — `asValue(new SSERoomManager())` or `asSingletonFunction((): SSERoomManager => new SSERoomManager(config))` * - `sseRoomBroadcaster` — `asSingletonClass(SSERoomBroadcaster)` * * @example * ```typescript * // In a domain service — resolve broadcaster directly from DI * class MetricsService { * private readonly broadcaster: SSERoomBroadcaster * constructor(deps: { sseRoomBroadcaster: SSERoomBroadcaster }) { * this.broadcaster = deps.sseRoomBroadcaster * } * } * ``` */ get roomBroadcaster(): SSERoomBroadcaster; /** * Build and return SSE route configurations. * Similar pattern to AbstractController.buildRoutes(). */ abstract buildSSERoutes(): BuildFastifySSERoutesReturnType<APIContracts>; /** * Controller-level hook called when any connection is established. * Override this method to add global connection handling logic. * This is called AFTER the connection is registered and route-level onConnect. * * @param connection - The newly established connection */ protected onConnectionEstablished?(connection: SSESession): void; /** * Controller-level hook called when any connection is closed. * Override this method to add global disconnect handling logic. * This is called BEFORE the connection is unregistered and route-level onClose. * * @param connection - The connection being closed */ protected onConnectionClosed?(connection: SSESession): void; /** * Send an event to a specific connection. * * This is a private method used internally by broadcast methods and sendEventInternal. * Handlers should use the type-safe `connection.send` method instead of calling * this method directly. * * Event data is validated against the Zod schema defined in the contract's `events` field * if the connection has event schemas attached (which happens automatically when routes * are built using buildFastifyRoute). * * @param connectionId - The connection to send to * @param message - The SSE message to send * @returns true if sent successfully, false if connection not found or closed * @throws Error if event data fails validation against the contract schema */ private sendEvent; /** * Raw internal method for the route builder to send events. * This is used by the route builder to create the typed `send` function. * External code should use `sendEventInternal` instead for type safety. * @internal */ _sendEventRaw<T>(connectionId: string, message: SSEMessage<T>): Promise<boolean>; /** * Get the room manager for use by route utilities. * Returns undefined if rooms are not enabled. * @internal */ get _internalRoomManager(): SSERoomManager | undefined; /** * Send an event to a connection with type-safe event names and data. * * This method provides autocomplete and type checking for event names and data * that match any event defined in the controller's contracts. Use this for * external event sources (subscriptions, timers, message queues) when you * don't have access to the handler's `send` function. * * For best type safety in handlers, use the `send` parameter instead. * For external sources, you can also store the `send` function for per-route typing. * * @example * ```typescript * // External event source (subscription callback) * this.messageQueue.onMessage((msg) => { * this.sendEventInternal(connectionId, { * event: 'notification', // autocomplete shows all valid events * data: { id: msg.id, message: msg.text } // typed based on event * }) * }) * ``` * * @param connectionId - The connection to send to * @param message - The event message with typed event name and data * @returns true if sent successfully, false if connection not found */ sendEventInternal<EventName extends AllContractEventNames<APIContracts>>(connectionId: string, message: { event: EventName; data: z.input<ExtractEventSchema<APIContracts, EventName>>; id?: string; retry?: number; }): Promise<boolean>; /** * Broadcast an event to all connected clients. * * @param message - The SSE message to broadcast * @returns Number of clients the message was sent to */ protected broadcast<T>(message: SSEMessage<T>): Promise<number>; /** * Broadcast an event to connections matching a predicate. * * @param message - The SSE message to broadcast * @param predicate - Function to filter connections * @returns Number of clients the message was sent to */ protected broadcastIf<T>(message: SSEMessage<T>, predicate: (connection: SSESession) => boolean): Promise<number>; /** * Get all active connections. */ protected getConnections(): SSESession[]; /** * Get the number of active connections. */ protected getConnectionCount(): number; /** * Close a specific connection. * * This gracefully ends the SSE stream by calling the underlying `reply.sse.close()`. * All previously sent data is flushed to the client before the connection terminates. * * Called automatically by the route builder when handler returns `success('disconnect')`. * Can also be called manually for scenarios like external triggers or timeouts. * * @param connectionId - The connection to close * @returns true if connection was found and closed */ closeConnection(connectionId: string): boolean; /** * Close all active connections. * Called during graceful shutdown via asyncDispose. */ closeAllConnections(): void; /** * Register a connection (called internally by route builder). * Triggers the onConnectionEstablished hook and spy if defined. * @internal */ registerConnection(connection: SSESession): void; /** * Unregister a connection (called internally by route builder). * Triggers the onConnectionClosed hook and spy if defined. * This method is idempotent - calling it multiple times for the same * connection ID has no effect after the first call. * @internal */ unregisterConnection(connectionId: string): void; /** * Get the room manager for this controller. * Throws an error if rooms are not enabled. * * @throws Error if rooms are not enabled in the controller config */ protected get roomManager(): SSERoomManager; /** * Check if rooms are enabled for this controller. */ protected get roomsEnabled(): boolean; /** * Broadcast a type-safe event to all connections in one or more rooms. * * Event names and data are validated against the controller's contract schemas * at compile time, ensuring only valid events can be broadcast. * * When broadcasting to multiple rooms, connections in multiple rooms * only receive the message once (de-duplicated). * * @param room - Room name or array of room names * @param eventName - Event name (must be defined in one of the controller's contracts) * @param data - Event data (must match the schema for the event) * @param options - Broadcast options (local, id, retry) * @returns Number of local connections the message was sent to * * @example * ```typescript * // Broadcast to a single room (type-safe) * await this.broadcastToRoom('dashboard:123', 'metricsUpdate', { * cpu: 45.2, memory: 72.1 * }) * * // Broadcast to multiple rooms (no duplicates) * await this.broadcastToRoom(['premium', 'beta-testers'], 'featureFlag', { * flag: 'new-ui', enabled: true * }) * ``` */ protected broadcastToRoom<EventName extends AllContractEventNames<APIContracts>>(room: string | string[], eventName: EventName, data: ExtractEventSchema<APIContracts, EventName> extends z.ZodTypeAny ? z.input<ExtractEventSchema<APIContracts, EventName>> : never, options?: RoomBroadcastOptions & { id?: string; retry?: number; }): Promise<number>; /** * Join a connection to one or more rooms. * Prefer using `session.rooms.join()` in handlers instead. * * @param connectionId - The connection to add to rooms * @param room - Room name or array of room names */ protected joinRoom(connectionId: string, room: string | string[]): void; /** * Remove a connection from one or more rooms. * Prefer using `session.rooms.leave()` in handlers instead. * * @param connectionId - The connection to remove from rooms * @param room - Room name or array of room names */ protected leaveRoom(connectionId: string, room: string | string[]): void; /** * Get all connection IDs in a room. * * @param room - The room to query * @returns Array of connection IDs */ protected getConnectionsInRoom(room: string): string[]; /** * Get the number of connections in a room. * * @param room - The room to query * @returns Number of connections */ protected getConnectionCountInRoom(room: string): number; }