UNPKG

opinionated-machine

Version:

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

319 lines 14.1 kB
import { AwilixManager } from 'awilix-manager'; import { merge } from 'ts-deepmerge'; import { mergeConfigAndDependencyOverrides } from './configUtils.js'; import { buildGatewayManifestFrom, } from './gateway/index.js'; import { buildFastifyRoute, } from './routes/index.js'; export class DIContext { options; awilixManager; diContainer; // biome-ignore lint/suspicious/noExplicitAny: all controllers are controllers controllerResolvers; // SSE controller dependency names (resolved from container to preserve singletons) sseControllerNames; // Dual-mode controller dependency names (resolved from container to preserve singletons) dualModeControllerNames; // ApiContract controller dependency names (resolved from container to preserve singletons) apiControllerNames; appConfig; constructor(diContainer, options, appConfig, awilixManager) { this.options = options; this.diContainer = diContainer; this.appConfig = appConfig; this.awilixManager = awilixManager ?? new AwilixManager({ asyncDispose: true, asyncInit: true, diContainer, eagerInject: true, strictBooleanEnforced: true, }); this.controllerResolvers = []; this.sseControllerNames = []; this.dualModeControllerNames = []; this.apiControllerNames = []; } registerControllers( // biome-ignore lint/suspicious/noExplicitAny: controller resolver properties are duck-typed controllers, targetDiConfig) { for (const [name, resolver] of Object.entries(controllers)) { if (resolver.isDualModeController) { this.dualModeControllerNames.push(name); // @ts-expect-error we can't really ensure type-safety here targetDiConfig[name] = resolver; } else if (resolver.isSSEController) { this.sseControllerNames.push(name); // @ts-expect-error we can't really ensure type-safety here targetDiConfig[name] = resolver; } else if (resolver.isApiController) { this.apiControllerNames.push(name); // @ts-expect-error we can't really ensure type-safety here targetDiConfig[name] = resolver; } else { this.controllerResolvers.push({ name, resolver: resolver }); } } } registerModule(module, targetDiConfig, externalDependencies, resolveControllers, isPrimaryModule) { const resolvedDIConfig = module.resolveDependencies(this.options, externalDependencies); for (const key in resolvedDIConfig) { // @ts-expect-error we can't really ensure type-safety here if (isPrimaryModule || resolvedDIConfig[key].public) { // @ts-expect-error we can't really ensure type-safety here targetDiConfig[key] = resolvedDIConfig[key]; } } if (isPrimaryModule && resolveControllers) { const controllers = module.resolveControllers(this.options); this.registerControllers(controllers, targetDiConfig); } } registerDependencies(params, externalDependencies, resolveControllers = true) { const mergedOverrides = mergeConfigAndDependencyOverrides(this.appConfig, params.configDependencyId ?? 'config', params.configOverrides, params.dependencyOverrides ?? {}); const targetDiConfig = {}; for (const primaryModule of params.modules) { this.registerModule(primaryModule, targetDiConfig, externalDependencies, resolveControllers, true); } if (params.secondaryModules) { for (const secondaryModule of params.secondaryModules) { this.registerModule(secondaryModule, targetDiConfig, externalDependencies, resolveControllers, false); } } this.diContainer.register(targetDiConfig); // append dependency overrides // @ts-expect-error FixMe check this later for (const [dependencyKey, _dependencyValue] of Object.entries(mergedOverrides)) { const dependencyValue = { ..._dependencyValue }; // preserve lifetime from original resolver const originalResolver = this.diContainer.getRegistration(dependencyKey); // @ts-expect-error if (dependencyValue.lifetime !== originalResolver.lifetime) { // @ts-expect-error dependencyValue.lifetime = originalResolver.lifetime; } this.diContainer.register(dependencyKey, dependencyValue); } } // biome-ignore lint/suspicious/noExplicitAny: we don't care about what instance we get here registerRoutes(app) { for (const { resolver } of this.controllerResolvers) { // biome-ignore lint/suspicious/noExplicitAny: any controller works here const controller = resolver.resolve(this.diContainer); const routes = controller.buildRoutes(); for (const route of Object.values(routes)) { // Cast needed: GET/DELETE routes have body:undefined, POST/PATCH have body:unknown // The union is incompatible with app.route() due to handler contravariance app.route(route); } } for (const controllerName of this.apiControllerNames) { // biome-ignore lint/suspicious/noExplicitAny: any api controllers works here const controller = this.diContainer.resolve(controllerName); for (const route of Object.values(controller.routes)) { app.route(route); } } } /** * Build a vendor-neutral gateway manifest from all registered REST and * api-contract controllers. Routes carrying gateway metadata (attached via * `withGatewayMetadata()`) get that metadata merged with controller-level * `gatewayDefaults` and the `defaults` passed here. Routes without any * metadata still appear in the manifest with empty metadata. * * The returned object is JSON-serializable; pass it to a generator package * like `@opinionated-machine/gateway-envoy` or * `@opinionated-machine/gateway-krakend` to produce a config. * * SSE and dual-mode controllers are not included in v1. * * @example * ```ts * const manifest = context.buildGatewayManifest({ * service: 'users-api', * defaults: { cors: { origins: ['https://app.example.com'] } }, * }) * const envoy = renderEnvoyConfig(manifest, { listenPort: 8080, clusters: { 'users-service': { hosts: ['users:8081'] } } }) * writeFileSync('envoy.yaml', envoy.yaml) * ``` */ buildGatewayManifest(options) { const collected = []; for (const { name, resolver } of this.controllerResolvers) { // biome-ignore lint/suspicious/noExplicitAny: any controller works here const controller = resolver.resolve(this.diContainer); collected.push({ name, kind: 'rest', controller }); } for (const name of this.apiControllerNames) { // biome-ignore lint/suspicious/noExplicitAny: any api controller works here const controller = this.diContainer.resolve(name); collected.push({ name, kind: 'api', controller }); } return buildGatewayManifestFrom(collected, options); } /** * Check if any SSE controllers are registered. * Use this to conditionally call registerSSERoutes(). */ hasSSEControllers() { return this.sseControllerNames.length > 0; } /** * Check if any dual-mode controllers are registered. * Use this to conditionally call registerDualModeRoutes(). */ hasDualModeControllers() { return this.dualModeControllerNames.length > 0; } /** * Register SSE routes with the Fastify app. * * Must be called separately from registerRoutes(). * Requires @fastify/sse plugin to be registered on the app. * * @param app - Fastify instance with @fastify/sse registered * @param options - Optional configuration for SSE routes * * @example * ```typescript * // Register @fastify/sse plugin first * await app.register(fastifySSE, { heartbeatInterval: 30000 }) * * // Then register SSE routes * context.registerSSERoutes(app) * ``` */ registerSSERoutes( // biome-ignore lint/suspicious/noExplicitAny: Fastify instance types are complex app, options) { if (!this.hasSSEControllers()) { return; } for (const controllerName of this.sseControllerNames) { // Resolve from container to use the singleton instance const sseController = this.diContainer.resolve(controllerName); const sseRoutes = sseController.buildSSERoutes(); for (const routeConfig of Object.values(sseRoutes)) { const route = buildFastifyRoute(sseController, routeConfig); this.applySSERouteOptions(route, options); app.route(route); } } } /** * Register dual-mode routes with the Fastify app. * * Dual-mode routes handle both SSE streaming and JSON responses on the * same path, automatically branching based on the `Accept` header. * * Must be called separately from registerRoutes() and registerSSERoutes(). * Requires @fastify/sse plugin to be registered on the app. * * @param app - Fastify instance with @fastify/sse registered * @param options - Optional configuration for dual-mode routes * * @example * ```typescript * // Register @fastify/sse plugin first * await app.register(fastifySSE, { heartbeatInterval: 30000 }) * * // Then register dual-mode routes * context.registerDualModeRoutes(app) * ``` */ registerDualModeRoutes( // biome-ignore lint/suspicious/noExplicitAny: Fastify instance types are complex app, options) { if (!this.hasDualModeControllers()) { return; } for (const controllerName of this.dualModeControllerNames) { // Resolve from container to use the singleton instance const dualModeController = this.diContainer.resolve(controllerName); const dualModeRoutes = dualModeController.buildDualModeRoutes(); for (const routeConfig of Object.values(dualModeRoutes)) { const route = buildFastifyRoute(dualModeController, routeConfig); this.applyDualModeRouteOptions(route, options); app.route(route); } } } applyDualModeRouteOptions(route, options) { if (options?.preHandler) { this.applyPreHandlers(route, options.preHandler); } if (options?.rateLimit) { this.applyRateLimit(route, options.rateLimit); } // Apply SSE-specific options (heartbeatInterval, serializer) for SSE mode if (options?.heartbeatInterval !== undefined || options?.serializer !== undefined) { // biome-ignore lint/suspicious/noExplicitAny: config types vary by plugins const routeWithConfig = route; routeWithConfig.config = merge(routeWithConfig.config || {}, { sse: { ...(options.heartbeatInterval !== undefined && { heartbeatInterval: options.heartbeatInterval, }), ...(options.serializer !== undefined && { serializer: options.serializer }), }, }); } } applySSERouteOptions(route, options) { if (options?.preHandler) { this.applyPreHandlers(route, options.preHandler); } if (options?.rateLimit) { this.applyRateLimit(route, options.rateLimit); } // Apply SSE-specific options (heartbeatInterval, serializer) if (options?.heartbeatInterval !== undefined || options?.serializer !== undefined) { // biome-ignore lint/suspicious/noExplicitAny: config types vary by plugins const routeWithConfig = route; routeWithConfig.config = merge(routeWithConfig.config || {}, { sse: { ...(options.heartbeatInterval !== undefined && { heartbeatInterval: options.heartbeatInterval, }), ...(options.serializer !== undefined && { serializer: options.serializer }), }, }); } } applyPreHandlers(route, globalPreHandler) { const existingPreHandler = route.preHandler; if (!existingPreHandler) { route.preHandler = globalPreHandler; return; } // biome-ignore lint/suspicious/noExplicitAny: preHandler types are complex const handlers = Array.isArray(existingPreHandler) ? existingPreHandler : [existingPreHandler]; // biome-ignore lint/suspicious/noExplicitAny: preHandler types are complex const globalHandlers = Array.isArray(globalPreHandler) ? globalPreHandler : [globalPreHandler]; route.preHandler = [...globalHandlers, ...handlers]; } applyRateLimit(route, rateLimit) { // biome-ignore lint/suspicious/noExplicitAny: config types vary by plugins const routeWithConfig = route; routeWithConfig.config = { ...(routeWithConfig.config || {}), rateLimit, }; } async destroy() { await this.awilixManager.executeDispose(); await this.diContainer.dispose(); } async init() { await this.awilixManager.executeInit(); } } //# sourceMappingURL=DIContext.js.map