opinionated-machine
Version:
Very opinionated DI framework for fastify, built on top of awilix
319 lines • 14.1 kB
JavaScript
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