UNPKG

opinionated-machine

Version:

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

142 lines (141 loc) 5.8 kB
import type { FastifyRequest } from 'fastify'; import type { SSELogger } from '../sseTypes.js'; /** * Immutable context available to every resolver during evaluation. * Replaced (never mutated) on connect and refresh. */ export type SubscriptionContext<TUserContext> = Readonly<{ /** Unique connection identifier */ connectionId: string; /** The original HTTP request (headers, auth, params) */ request: FastifyRequest; /** Application-defined per-user data (userId, roles, cached memberships, etc.) */ userContext: Readonly<TUserContext>; /** All rooms this connection is currently in (union across all resolvers) */ rooms: ReadonlySet<string>; }>; /** * Event being evaluated by the resolver pipeline. * * @template TMetadata - Discriminated union of event metadata shapes */ export type IncomingEvent<TMetadata extends Record<string, unknown> = Record<string, unknown>> = { /** Event name (maps to SSE `event` field) */ eventName: string; /** Event payload (delivered to client as SSE data) */ data: unknown; /** Rooms the event targets (used for pre-filtering and cross-node propagation) */ targetRooms?: string[]; /** Typed event metadata for resolver filtering decisions (not delivered to clients) */ metadata: TMetadata; }; /** * A resolver's decision about whether an event should reach a connection. * * - `allow`: This resolver approves delivery (subsequent resolvers can still deny) * - `deny`: Short-circuits — event is NOT delivered to this connection * - `defer`: This resolver has no opinion — continue to the next resolver */ export type FilterVerdict = { action: 'allow'; } | { action: 'deny'; reason?: string; } | { action: 'defer'; }; /** * Terminal subscription policy — what the manager does when no resolver * issues a definitive verdict. Used by `defaultPolicy` and any code path * that needs to fall back to allow-or-deny without a `defer` option. */ export type SubscriptionPolicy = 'allow' | 'deny'; /** * Returned by onConnect() and refresh() — declares updated user context * and room requirements. The manager diffs rooms and joins/leaves as needed. */ export type ResolverResult<TUserContext> = { /** Updated (immutable) user context — full replacement, not a merge */ userContext: TUserContext; /** Rooms this resolver requires. Manager diffs against previous set. */ rooms?: string[]; }; /** * A single filter in the resolver pipeline. * * Resolvers are stateless — all per-connection state lives in `userContext`. * They are evaluated in array order. First `deny` short-circuits. * * @template TUserContext - Application-defined per-connection context * @template TMetadata - Discriminated union of event metadata shapes */ export interface SubscriptionResolver<TUserContext = unknown, TMetadata extends Record<string, unknown> = Record<string, unknown>> { /** Unique resolver name (used in logging and debugging) */ readonly name: string; /** * Called once when a connection is established. * Hydrate user context and declare initial room memberships. * * Resolvers run in array order. Each resolver receives the accumulated * `userContext` from all prior resolvers — use spread to preserve it. * * If this method throws, `handleConnect()` rejects and the connection * is not tracked by the subscription manager. */ onConnect?(ctx: SubscriptionContext<TUserContext>): ResolverResult<TUserContext> | Promise<ResolverResult<TUserContext>>; /** * Evaluate whether an event should be delivered to this connection. * Must be fast — runs for every (event × connection) pair. */ evaluate(ctx: SubscriptionContext<TUserContext>, event: IncomingEvent<TMetadata>): FilterVerdict | Promise<FilterVerdict>; /** * Re-hydrate user context and room memberships. * Called when external state changes (e.g., user updates preferences). * Manager diffs the returned rooms against the previous set. * * If this method throws, the error is logged and this resolver keeps * its previous state (rooms + context). Other resolvers continue refreshing. */ refresh?(ctx: SubscriptionContext<TUserContext>): ResolverResult<TUserContext> | Promise<ResolverResult<TUserContext>>; } /** * Configuration for the SSE Subscription Manager. * * @template TUserContext - Application-defined per-connection context * @template TMetadata - Discriminated union of event metadata shapes */ export type SSESubscriptionManagerConfig<TUserContext, TMetadata extends Record<string, unknown> = Record<string, unknown>> = { /** * Extract initial user context from the HTTP request. * Runs once per connection before resolvers' onConnect. */ resolveUserContext: (request: FastifyRequest) => Promise<TUserContext>; /** * Ordered resolver pipeline. Evaluated in array order. * First `deny` short-circuits. If all `defer`, defaultPolicy applies. */ resolvers: SubscriptionResolver<TUserContext, TMetadata>[]; /** * What happens when all resolvers return `defer`. * @default 'deny' */ defaultPolicy?: SubscriptionPolicy; /** * Extract userId from user context. Required for `refreshUser()`. * If not provided, `refreshUser()` throws. */ resolveUserId?: (userContext: TUserContext) => string; /** * Optional logger for resolver verdicts and room operations. */ logger?: SSELogger; }; /** * Result of a publish operation. */ export type PublishResult = { /** Number of connections the event was delivered to */ delivered: number; /** Number of connections that were filtered out by resolvers */ filtered: number; };