opinionated-machine
Version:
Very opinionated DI framework for fastify, built on top of awilix
334 lines (333 loc) • 14 kB
TypeScript
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;
}