From 668d524960772e17bc8e23a8c9a10963a00dcc40 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Thu, 12 Mar 2026 12:53:49 -0700 Subject: [PATCH] feat: add loopback-core AI agent skill for skills.sh Add a reusable AI agent skill covering LoopBack 4 core patterns including IoC container, dependency injection, extension points, interceptors, life cycle observers, and component-based architecture. Installable via `npx skills add loopbackio/loopback-next`. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Raymond Feng --- skills/loopback-core/SKILL.md | 199 +++++++++++ .../references/advanced-recipes.md | 189 ++++++++++ .../loopback-core/references/configuration.md | 123 +++++++ .../references/context-and-bindings.md | 330 ++++++++++++++++++ .../references/dependency-injection.md | 147 ++++++++ .../references/extension-points.md | 223 ++++++++++++ .../references/interceptors-and-observers.md | 202 +++++++++++ 7 files changed, 1413 insertions(+) create mode 100644 skills/loopback-core/SKILL.md create mode 100644 skills/loopback-core/references/advanced-recipes.md create mode 100644 skills/loopback-core/references/configuration.md create mode 100644 skills/loopback-core/references/context-and-bindings.md create mode 100644 skills/loopback-core/references/dependency-injection.md create mode 100644 skills/loopback-core/references/extension-points.md create mode 100644 skills/loopback-core/references/interceptors-and-observers.md diff --git a/skills/loopback-core/SKILL.md b/skills/loopback-core/SKILL.md new file mode 100644 index 000000000000..caa0c3e6ee44 --- /dev/null +++ b/skills/loopback-core/SKILL.md @@ -0,0 +1,199 @@ +--- +name: loopback-core +description: + Build large-scale, extensible Node.js applications and frameworks using + LoopBack 4 core patterns. Use when building TypeScript/Node.js projects that + need IoC containers, dependency injection, extension point/extension patterns, + interceptors, life cycle observers, or component-based architecture. Triggers + on tasks involving @loopback/core, @loopback/context, Context, Binding, + @inject, @injectable, @extensionPoint, @extensions, LifeCycleObserver, + Interceptor, or Component patterns. Also use when the user asks about + structuring large-scale Node.js projects for extensibility and composability. +--- + +# Build Large-Scale Node.js Projects with LoopBack 4 Core + +LoopBack 4 core provides an IoC container and DI framework in TypeScript +designed for async-first, large-scale Node.js applications. Import from +`@loopback/core` (not `@loopback/context`). This skill covers only +`@loopback/core` — not REST, repositories, or other LoopBack modules. + +## Architecture Decision Tree + +1. **Need to manage artifacts and their dependencies?** -> Context & Bindings + ([context-and-bindings.md](references/context-and-bindings.md)) +2. **Need loose coupling between artifact construction and behavior?** -> + Dependency Injection + ([dependency-injection.md](references/dependency-injection.md)) +3. **Need a pluggable system where others can add capabilities?** -> Extension + Point/Extension ([extension-points.md](references/extension-points.md)) +4. **Need cross-cutting concerns (caching, logging, tracing)?** -> Interceptors + ([interceptors-and-observers.md](references/interceptors-and-observers.md)) +5. **Need to hook into app start/stop?** -> Life Cycle Observers + ([interceptors-and-observers.md](references/interceptors-and-observers.md)) +6. **Need runtime-configurable artifacts?** -> Configuration + ([configuration.md](references/configuration.md)) +7. **Need custom decorators, parameterized classes, or advanced patterns?** -> + Advanced Recipes ([advanced-recipes.md](references/advanced-recipes.md)) + +## Quick Start: Minimal Extensible Application + +```ts +import { + Application, + BindingKey, + Component, + Binding, + createBindingFromClass, + extensionPoint, + extensions, + BindingTemplate, + extensionFor, + injectable, + Getter, + config, +} from '@loopback/core'; + +// 1. Define the extension contract +export interface Greeter { + language: string; + greet(name: string): string; +} + +export const GREETER_EXTENSION_POINT_NAME = 'greeters'; + +export const asGreeter: BindingTemplate = binding => { + extensionFor(GREETER_EXTENSION_POINT_NAME)(binding); + binding.tag({namespace: 'greeters'}); +}; + +// 2. Define the extension point +@extensionPoint(GREETER_EXTENSION_POINT_NAME) +export class GreetingService { + constructor( + @extensions() private getGreeters: Getter, + @config() public readonly options?: {color: string}, + ) {} + async greet(language: string, name: string): Promise { + const greeters = await this.getGreeters(); + const greeter = greeters.find(g => g.language === language); + return greeter ? greeter.greet(name) : `Hello, ${name}!`; + } +} + +// 3. Implement extensions +@injectable(asGreeter) +export class EnglishGreeter implements Greeter { + language = 'en'; + greet(name: string) { + return `Hello, ${name}!`; + } +} + +@injectable(asGreeter) +export class ChineseGreeter implements Greeter { + language = 'zh'; + greet(name: string) { + return `${name},你好!`; + } +} + +// 4. Bundle into a component +export const GREETING_SERVICE = BindingKey.create( + 'services.GreetingService', +); + +export class GreetingComponent implements Component { + bindings: Binding[] = [ + createBindingFromClass(GreetingService, {key: GREETING_SERVICE}), + createBindingFromClass(EnglishGreeter), + createBindingFromClass(ChineseGreeter), + ]; +} + +// 5. Compose components via nesting +export class CoreComponent implements Component { + // services: auto-registered service/provider classes + services = [SomeUtilityService]; +} + +export class AppComponent implements Component { + // components: nested components (registered recursively) + components = [CoreComponent, GreetingComponent]; + // services: additional services for this layer + services = [AppSpecificService]; +} + +// 6. Wire up the application +export class MyApp extends Application { + constructor() { + super({shutdown: {signals: ['SIGINT']}}); + this.component(AppComponent); + } + async main() { + const svc = await this.get(GREETING_SERVICE); + console.log(await svc.greet('en', 'World')); + } +} +``` + +## Core Patterns Summary + +| Pattern | Key APIs | When to Use | +| ----------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------- | +| Context & Binding | `Context`, `bind()`, `toClass()`, `toDynamicValue()`, `toProvider()` | Managing artifacts and their dependencies | +| DI | `@inject()`, `@inject.getter()`, `@inject.view()` | Decoupling construction from behavior | +| Extension Point | `@extensionPoint()`, `@extensions()`, `@extensions.list()`, `extensionFor()`, `@injectable()` | Pluggable, open-ended feature sets | +| Interceptor | `@injectable(asGlobalInterceptor())`, `Provider` | Cross-cutting concerns | +| Observer | `@lifeCycleObserver('group')`, `LifeCycleObserver` | Startup/shutdown hooks (group controls order) | +| Configuration | `@config()`, `@config.view()`, `app.configure()` | Runtime-configurable behavior | +| Component | `Component`, `components[]`, `services[]`, `bindings[]` | Composable packaging of artifacts | + +## Key Rules + +- Always import from `@loopback/core`, not `@loopback/context` +- Use `BindingKey.create()` for strongly-typed keys +- Extension injection: `@extensions()` returns `Getter` (lazy, picks up + dynamic additions); `@extensions.list()` returns `T[]` (eager, simpler when + extensions are static at startup) +- Use `@config()` for artifact configuration, `app.configure(key).to(value)` to + set it +- Use `@injectable(bindingTemplate)` to decorate extension classes — can combine + scope and extension: + `@injectable({scope: BindingScope.SINGLETON}, extensionFor(POINT))` +- Use `createBindingFromClass()` to create bindings that respect `@injectable` + metadata +- Compose components hierarchically: `components[]` for nesting, `services[]` + for auto-registration, `bindings[]` for custom bindings +- Use `BindingScope.SINGLETON` for shared stateful services +- Use `CoreBindings.APPLICATION_INSTANCE` to inject the Application itself +- Use `ContextTags.KEY` to tag bindings with a stable key: + `tags: {[ContextTags.KEY]: MY_KEY}` +- Lifecycle observer groups are sorted alphabetically — use numbered prefixes + (e.g., `'03-setup'`, `'10-app'`) to control startup order + +## References + +- **Context & Bindings**: + [references/context-and-bindings.md](references/context-and-bindings.md) — + creating contexts, binding types, scopes, finding bindings, context hierarchy, + views, components +- **Dependency Injection**: + [references/dependency-injection.md](references/dependency-injection.md) — + constructor/property/method injection, getters, views, custom decorators, + custom injectors +- **Extension Points**: + [references/extension-points.md](references/extension-points.md) — defining + contracts, extension point classes, implementing/registering extensions, + configuration +- **Interceptors & Observers**: + [references/interceptors-and-observers.md](references/interceptors-and-observers.md) + — global interceptors, interceptor proxies, life cycle observers, dynamic + config via ContextView +- **Configuration**: [references/configuration.md](references/configuration.md) + — `@config()`, `@config.view()`, dynamic config, custom resolvers, sync vs + async +- **Advanced Recipes**: + [references/advanced-recipes.md](references/advanced-recipes.md) — custom + decorators, custom injectors, parameterized class factories, application + scaffolding diff --git a/skills/loopback-core/references/advanced-recipes.md b/skills/loopback-core/references/advanced-recipes.md new file mode 100644 index 000000000000..c8cbb785b4bd --- /dev/null +++ b/skills/loopback-core/references/advanced-recipes.md @@ -0,0 +1,189 @@ +# Advanced Recipes + +## Table of Contents + +- [TypeScript Decorator Configuration](#typescript-decorator-configuration) +- [Custom Decorators](#custom-decorators) +- [Custom Injectors](#custom-injectors) +- [Parameterized Class Factories](#parameterized-class-factories) +- [Explicit Context DI in Interceptors](#explicit-context-di-in-interceptors) +- [Sync vs Async Resolution](#sync-vs-async-resolution) +- [Application Scaffolding Pattern](#application-scaffolding-pattern) + +## TypeScript Decorator Configuration + +LoopBack 4 decorators (`@inject`, `@injectable`, `@lifeCycleObserver`, etc.) +require these tsconfig settings: + +```jsonc +{ + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + }, +} +``` + +- **`experimentalDecorators`** — enables `@decorator` syntax (required for all + LoopBack DI) +- **`emitDecoratorMetadata`** — emits type metadata at runtime so LoopBack can + resolve injection targets by type + +Without these, decorators either cause compile errors or silently fail at +runtime. + +## Custom Decorators + +Create a sugar decorator wrapping `@inject`: + +```ts +import {BindingKey, inject} from '@loopback/core'; + +const CURRENT_USER = BindingKey.create('currentUser'); + +function whoAmI() { + return inject(CURRENT_USER); +} + +class Greeter { + constructor(@whoAmI() private userName: string) {} + hello() { + return `Hello, ${this.userName}`; + } +} +``` + +Create a decorator using `DecoratorFactory`: + +See `@loopback/example-context/src/custom-inject-decorator.ts`. + +## Custom Injectors + +Inject with custom resolve logic: + +```ts +export function env(name: string) { + return inject('', {resolve: () => process.env[name]}); +} + +class MyService { + constructor(@env('DATABASE_URL') private dbUrl: string) {} +} +``` + +## Parameterized Class Factories + +When top-level decorators can't reference variables, use a class factory: + +```ts +import { + BindingAddress, + BindingTag, + Constructor, + Context, + createBindingFromClass, + inject, + injectable, +} from '@loopback/core'; + +interface Greeter { + hello(): string; +} + +function createClassWithDecoration( + bindingKeyForName: BindingAddress, + ...tags: BindingTag[] +): Constructor { + @injectable({tags}) + class GreeterTemplate implements Greeter { + constructor(@inject(bindingKeyForName) private userName: string) {} + hello() { + return `Hello, ${this.userName}`; + } + } + return GreeterTemplate; +} + +// Usage: +const ctx = new Context(); +ctx.bind('name1').to('John'); +const MyGreeter = createClassWithDecoration('name1', {tags: {prefix: '1'}}); +ctx.add(createBindingFromClass(MyGreeter, {key: 'greeter1'})); +const greeter = await ctx.get('greeter1'); +``` + +## Explicit Context DI in Interceptors + +Use `instantiateClass` to trigger DI within interceptors or any context where +you need to create a class instance with injection: + +```ts +import {inject, instantiateClass} from '@loopback/core'; + +class InjectionHelper { + constructor(@inject('services.Logger') public readonly logger: Logger) {} +} + +const interceptor: Interceptor = async (invocationCtx, next) => { + const helper = await instantiateClass(InjectionHelper, invocationCtx); + helper.logger.info('intercepting...'); + return next(); +}; +``` + +## Sync vs Async Resolution + +- `Context.getSync()` — resolves synchronously (throws if any dependency is + async) +- `Context.get()` — resolves asynchronously (returns Promise) + +When `ValueOrPromise` is used, the framework auto-detects: returns a plain value +if all deps are sync, otherwise returns a `Promise`. + +## Application Scaffolding Pattern + +Standalone application with shutdown handling: + +```ts +import {Application, ApplicationConfig} from '@loopback/core'; + +export class MyApplication extends Application { + constructor(config?: ApplicationConfig) { + super({shutdown: {signals: ['SIGINT']}, ...config}); + this.component(CoreComponent); + } + + async main() { + await this.start(); + const service = await this.get(GREETING_SERVICE); + console.log(await service.greet('en', 'World')); + } +} +``` + +Layered application with conditional components: + +```ts +export class BaseApplication extends Application { + constructor(config?: ApplicationConfig) { + super({shutdown: {signals: ['SIGINT']}, ...config}); + this.component(BaseComponent); + } +} + +export class MessagingApplication extends BaseApplication { + constructor() { + super(); + this.component(MessagingComponent); + } +} + +// Entry point +export async function main() { + const app = new MessagingApplication(); + app.component(PluginComponent); // add dynamically + app.configure(MY_SERVICE_KEY).to({port: 3000}); // configure before start + await app.start(); + return app; +} +``` diff --git a/skills/loopback-core/references/configuration.md b/skills/loopback-core/references/configuration.md new file mode 100644 index 000000000000..ae5c67398799 --- /dev/null +++ b/skills/loopback-core/references/configuration.md @@ -0,0 +1,123 @@ +# Configuration + +## Table of Contents + +- [Basic Configuration Injection](#basic-configuration-injection) +- [Configuring Bindings](#configuring-bindings) +- [Dynamic Configuration](#dynamic-configuration) +- [Configuration Views](#configuration-views) +- [Custom Configuration Resolver](#custom-configuration-resolver) +- [Sync vs Async Configuration](#sync-vs-async-configuration) + +## Basic Configuration Injection + +Use `@config()` to inject configuration for a bound class: + +```ts +import {config, Context} from '@loopback/core'; + +type GreeterConfig = { + prefix?: string; + includeDate?: boolean; +}; + +class Greeter { + constructor(@config() private settings: GreeterConfig = {}) {} + + greet(name: string) { + const prefix = this.settings.prefix ? `${this.settings.prefix}` : ''; + const date = this.settings.includeDate + ? `[${new Date().toISOString()}]` + : ''; + return `${date} ${prefix}: Hello, ${name}`; + } +} +``` + +## Configuring Bindings + +```ts +const ctx = new Context(); +ctx.configure('greeter').to({prefix: '>>>', includeDate: true}); +ctx.bind('greeter').toClass(Greeter); + +const greeter = await ctx.get('greeter'); +``` + +## Dynamic Configuration + +Supply configuration asynchronously: + +```ts +app + .configure('greeters.ChineseGreeter') + .toDynamicValue(async () => ({nameFirst: false})); +``` + +**Note:** When configuration is async, `getSync()` will throw. Use `get()` +instead. + +## Configuration Views + +Use `@config.view()` to get a live view that emits `refresh` events on changes: + +```ts +constructor( + @config.view() + private optionsView: ContextView, +) { + optionsView.on('refresh', () => { + this.restart(); + }); +} + +async getTTL() { + const options = await this.optionsView.singleValue(); + return options?.ttl ?? 5000; +} +``` + +## Custom Configuration Resolver + +Override how configuration is resolved (e.g., from environment variables): + +```ts +import { + ConfigurationResolver, + Context, + ContextBindings, + DefaultConfigurationResolver, + inject, +} from '@loopback/core'; + +class EnvConfigResolver + extends DefaultConfigurationResolver + implements ConfigurationResolver +{ + constructor(@inject.context() public readonly context: Context) { + super(context); + } + + getConfigAsValueOrPromise(key, configPath, resolutionOptions) { + const envVal = process.env[key.toString().toUpperCase()]; + if (envVal != null) { + try { + return JSON.parse(envVal); + } catch { + return envVal; + } + } + return super.getConfigAsValueOrPromise(key, configPath, resolutionOptions); + } +} + +// Register: +ctx.bind(ContextBindings.CONFIGURATION_RESOLVER).toClass(EnvConfigResolver); +``` + +## Sync vs Async Configuration + +- `@config()` with `.to()` — resolvable both sync and async +- `@config()` with `.toDynamicValue(async () => ...)` — async only (`getSync` + throws) +- Use `@config.view()` when you need to react to runtime config changes diff --git a/skills/loopback-core/references/context-and-bindings.md b/skills/loopback-core/references/context-and-bindings.md new file mode 100644 index 000000000000..e9a68457d69d --- /dev/null +++ b/skills/loopback-core/references/context-and-bindings.md @@ -0,0 +1,330 @@ +# Context and Bindings + +## Table of Contents + +- [Creating a Context](#creating-a-context) +- [Registering Artifacts](#registering-artifacts) +- [Binding Value Types](#binding-value-types) +- [Binding Scopes](#binding-scopes) +- [Finding and Resolving Bindings](#finding-and-resolving-bindings) +- [Context Hierarchy](#context-hierarchy) +- [Context Views and Observation](#context-views-and-observation) +- [Strongly-Typed Binding Keys](#strongly-typed-binding-keys) +- [Components](#components) + +## Creating a Context + +```ts +import {Context} from '@loopback/core'; +const ctx = new Context(); +``` + +## Registering Artifacts + +```ts +import {Context} from '@loopback/core'; +import {GreetingController} from './controllers'; +import {CACHING_SERVICE, GREETING_SERVICE} from './keys'; +import {CachingService} from './caching-service'; +import {GreetingService} from './greeting-service'; + +const ctx = new Context(); +ctx.bind('controllers.GreetingController').toClass(GreetingController); +ctx.bind(CACHING_SERVICE).toClass(CachingService); +ctx.bind(GREETING_SERVICE).toClass(GreetingService); +``` + +## Binding Value Types + +### 1. Constant value + +```ts +ctx.bind('currentUser').to('John'); +``` + +### 2. Factory function (dynamic value) + +```ts +ctx.bind('currentDate').toDynamicValue(() => new Date()); +``` + +### 3. Class instantiation + +```ts +ctx.bind('greeter').toClass(Greeter); +``` + +### 4. Provider class (factory with DI support) + +```ts +class RequestIdProvider implements Provider { + constructor(@inject('url') private url: string) {} + value() { + return `${this.url}#${Date.now()}`; + } +} +ctx.bind('requestId').toProvider(RequestIdProvider); +``` + +### 5. Alias + +```ts +ctx.bind('hello').toAlias(GREETER); +``` + +## Binding Scopes + +- **Transient** — new instance per resolution (default) +- **Singleton** — single shared instance +- **Context** — one instance per context in the hierarchy + +```ts +import {BindingScope, injectable} from '@loopback/core'; + +@injectable({scope: BindingScope.SINGLETON}) +export class CachingService { + /* ... */ +} + +// Or via binding API: +ctx + .bind('services.CachingService') + .toClass(CachingService) + .inScope(BindingScope.SINGLETON); +``` + +## Finding and Resolving Bindings + +```ts +// By key +const greeter = await ctx.get('greeter'); +const greeterSync = ctx.getSync('greeter'); + +// By binding key object +const greeter = await ctx.get(GREETING_SERVICE); + +// Find by pattern +const bindings = ctx.find('*.EnglishGreeter'); +const bindings = ctx.find(/\w+\.EnglishGreeter$/); + +// Find by tag +const greeters = ctx.findByTag('greeter'); + +// Find by filter function +import {filterByTag} from '@loopback/core'; +const greeters = ctx.find(filterByTag('greeter')); + +// Custom filter +const greeterFilter: BindingFilter = binding => + binding.tagMap['greeter'] != null; +const greeters = ctx.find(greeterFilter); +``` + +## Context Hierarchy + +Parent-child contexts enable scoped resolution. Child contexts inherit bindings +from parents but can override them. + +```ts +const appCtx = new Context('app'); +const requestCtx = new Context(appCtx, 'request'); + +// Binding in appCtx is visible from requestCtx +appCtx.bind('prefix').to('app'); + +// requestCtx can override +requestCtx.bind('prefix').to('request'); +``` + +### Custom Context Subclasses + +Extend `Context` to add domain-specific metadata and behavior: + +```ts +import {Context} from '@loopback/core'; + +export class UserContext extends Context { + readonly userId: string; + lastUsed: number; + readonly createdAt: number; + + constructor(parent: Context, userId: string) { + super(parent, `user:${userId}`); + this.userId = userId; + this.createdAt = Date.now(); + this.lastUsed = Date.now(); + } + + touch(): void { + this.lastUsed = Date.now(); + } + isInactive(timeoutMs: number): boolean { + return Date.now() - this.lastUsed > timeoutMs; + } +} +``` + +Use custom contexts for per-user or per-request isolation. Bindings in the +parent (e.g., Application) are inherited; bindings in the child can shadow them. + +### Injecting the Application + +Use `CoreBindings.APPLICATION_INSTANCE` to inject the Application context +itself: + +```ts +import {CoreBindings, inject} from '@loopback/core'; + +class MyRegistry { + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) + private app: Application, + ) {} +} +``` + +### Tag-Based Discovery + +Use `ContextTags.KEY` to tag bindings with a stable key for lookup: + +```ts +import {BindingScope, ContextTags, injectable} from '@loopback/core'; + +@injectable({ + scope: BindingScope.SINGLETON, + tags: {[ContextTags.KEY]: MY_SERVICE_KEY}, +}) +export class MyService { + /* ... */ +} +``` + +Discover bindings by custom tags: + +```ts +const MY_TAG = 'my:service'; + +// Tag a binding +ctx + .bind(key) + .to(value) + .tag(MY_TAG) + .tag({[MY_ID_TAG]: id}); + +// Discover by tag +const bindings = ctx.findByTag(MY_TAG); +``` + +## Context Views and Observation + +Watch for binding additions/removals dynamically: + +```ts +import {filterByTag, filterByKey} from '@loopback/core'; + +// Subscribe to events +appCtx.subscribe({ + filter: filterByTag('greeter'), + observe: (eventType, binding) => { + console.log('%s %s', eventType, binding.key); + }, +}); + +// Create a view (auto-refreshes) +const greetersView = ctx.createView(filterByKey(/^greeters\./)); +greetersView.on('refresh', () => { + console.log( + 'bindings changed:', + greetersView.bindings.map(b => b.key), + ); +}); +``` + +## Strongly-Typed Binding Keys + +```ts +import {BindingKey} from '@loopback/core'; +import {GreetingService} from './greeting-service'; + +export const GREETING_SERVICE = BindingKey.create( + 'services.GreetingService', +); +``` + +## Components + +A component bundles related artifacts. Register via `app.component()`. + +### Component Properties + +```ts +import { + Binding, + Component, + Class, + ServiceOrProviderClass, + createBindingFromClass, +} from '@loopback/core'; + +export class MyComponent implements Component { + // Nested components — registered recursively (order matters) + components?: Class[]; + // Service/provider classes — auto-registered by class name + services?: ServiceOrProviderClass[]; + // Custom bindings — for fine-grained control + bindings?: Binding[]; +} +``` + +### Hierarchical Composition + +Build layered applications by nesting components: + +```ts +// Layer 1: Core services +export class CoreComponent implements Component { + services = [LoggingService, ConfigService]; +} + +// Layer 2: Feature-specific +export class FeatureComponent implements Component { + services = [GreetingService]; + bindings = [ + createBindingFromClass(EnglishGreeter), + createBindingFromClass(ChineseGreeter), + ]; +} + +// Layer 3: Composed application component +export class AppComponent implements Component { + components = [CoreComponent, FeatureComponent]; + services = [AppSpecificService]; +} + +// Application wires up the top-level component +export class MyApp extends Application { + constructor() { + super(); + this.component(AppComponent); + } +} +``` + +When `app.component(AppComponent)` is called, LoopBack recursively registers all +nested `components`, then `services`, then `bindings`. + +### Conditional Composition + +Add components dynamically based on runtime conditions: + +```ts +export class MyApp extends Application { + constructor() { + super(); + this.component(CoreComponent); + if (process.env.ENABLE_MESSAGING) { + this.component(MessagingComponent); + } + } +} +``` diff --git a/skills/loopback-core/references/dependency-injection.md b/skills/loopback-core/references/dependency-injection.md new file mode 100644 index 000000000000..2c1a3f2885c9 --- /dev/null +++ b/skills/loopback-core/references/dependency-injection.md @@ -0,0 +1,147 @@ +# Dependency Injection + +## Table of Contents + +- [Three Types of DI](#three-types-of-di) +- [Constructor Injection](#constructor-injection) +- [Property Injection](#property-injection) +- [Method Parameter Injection](#method-parameter-injection) +- [Getter Injection](#getter-injection) +- [View Injection](#view-injection) +- [Optional Injection](#optional-injection) +- [Custom Decorators](#custom-decorators) +- [Custom Injectors](#custom-injectors) +- [Invoking Methods with Injection](#invoking-methods-with-injection) + +## Three Types of DI + +1. **Constructor injection** — `@inject()` on constructor parameters +2. **Property injection** — `@inject()` on class properties +3. **Method injection** — `@inject()` on method parameters (used with + `invokeMethod`) + +## Constructor Injection + +```ts +import {inject} from '@loopback/core'; + +export class CacheObserver implements LifeCycleObserver { + constructor( + @inject(CACHING_SERVICE) private cachingService: CachingService, + ) {} +} +``` + +## Property Injection + +```ts +class Greeter { + @inject('currentDate') + private created: Date; + + @inject('requestId') + private requestId: string; +} +``` + +## Method Parameter Injection + +```ts +class GreetingService { + async greet( + @inject('currentLanguage') language: string, + @inject('currentUser') userName: string, + ) { + // ... + } +} +// Call with injection: +const result = await invokeMethod(greetingService, 'greet', ctx); +``` + +## Getter Injection + +Inject a getter function instead of a value — always returns the latest value on +each call: + +```ts +import {Getter, inject} from '@loopback/core'; + +class GreetingService { + constructor( + @inject.getter('currentDate') + private now: Getter, + ) {} + + async doWork() { + const date = await this.now(); // always fresh + } +} +``` + +## View Injection + +Inject a live view of bindings matching a filter: + +```ts +import {ContextView, filterByTag, inject} from '@loopback/core'; + +class GreetingService { + constructor( + @inject.view(filterByTag('greeter')) + private greetersView: ContextView, + ) {} + + async listGreeters() { + return this.greetersView.values(); // always current + } +} +``` + +## Optional Injection + +```ts +@inject(CACHING_SERVICE, {optional: true}) +private cachingService?: CachingService; +``` + +## Custom Decorators + +Wrap `@inject` to create domain-specific decorators: + +```ts +import {BindingKey, inject} from '@loopback/core'; + +const CURRENT_USER = BindingKey.create('currentUser'); + +function whoAmI() { + return inject(CURRENT_USER); +} + +class Greeter { + constructor(@whoAmI() private userName: string) {} +} +``` + +## Custom Injectors + +Create injection decorators with custom resolve logic: + +```ts +export function env(name: string) { + return inject('', {resolve: () => process.env[name]}); +} + +class MyService { + constructor(@env('DATABASE_URL') private dbUrl: string) {} +} +``` + +## Invoking Methods with Injection + +```ts +import {invokeMethod} from '@loopback/core'; + +// Method parameters are resolved from context +const result = await invokeMethod(greetingService, 'greet', ctx); +``` diff --git a/skills/loopback-core/references/extension-points.md b/skills/loopback-core/references/extension-points.md new file mode 100644 index 000000000000..4cfae6eab1b5 --- /dev/null +++ b/skills/loopback-core/references/extension-points.md @@ -0,0 +1,223 @@ +# Extension Points and Extensions + +## Table of Contents + +- [Overview](#overview) +- [Define the Extension Contract](#define-the-extension-contract) +- [Define the Extension Point Class](#define-the-extension-point-class) +- [Implement Extensions](#implement-extensions) +- [Register Extension Points](#register-extension-points) +- [Register Extensions](#register-extensions) +- [Configure Extension Points](#configure-extension-points) +- [Configure Extensions](#configure-extensions) + +## Overview + +The Extension Point/Extension pattern enables loose coupling and extensibility. +An extension point declares a contract (interface); extensions implement that +contract. The extension point discovers extensions at runtime via DI. + +## Define the Extension Contract + +```ts +import {BindingTemplate, extensionFor} from '@loopback/core'; + +export interface Greeter { + language: string; + greet(name: string): string; +} + +export const GREETER_EXTENSION_POINT_NAME = 'greeters'; + +// Inline binding template +export const asGreeter: BindingTemplate = binding => { + extensionFor(GREETER_EXTENSION_POINT_NAME)(binding); + binding.tag({namespace: 'greeters'}); +}; +``` + +### Binding Template Factory Functions + +For reusable templates, wrap in a factory function: + +```ts +export function asGreeter(): BindingTemplate { + return binding => { + binding.apply(extensionFor(GREETER_EXTENSION_POINT_NAME)); + }; +} + +// Usage: +@injectable(asGreeter()) +export class EnglishGreeter implements Greeter { + /* ... */ +} +``` + +You can combine scope and extension registration in `@injectable`: + +```ts +@injectable({scope: BindingScope.SINGLETON}, extensionFor(MY_EXTENSION_POINT)) +export class MyExtension implements MyContract { + /* ... */ +} +``` + +## Define the Extension Point Class + +Two injection styles: + +### Lazy injection with `@extensions()` (Getter) + +Returns `Getter` — call it each time to get the latest extensions (picks up +dynamic additions): + +```ts +import {config, extensionPoint, extensions, Getter} from '@loopback/core'; + +@extensionPoint(GREETER_EXTENSION_POINT_NAME) +export class GreetingService { + constructor( + @extensions() + private getGreeters: Getter, + @config() + public readonly options?: GreetingServiceOptions, + ) {} + + async greet(language: string, name: string): Promise { + const greeters = await this.getGreeters(); + const greeter = greeters.find(g => g.language === language); + return greeter ? greeter.greet(name) : `Hello, ${name}!`; + } +} +``` + +### Eager injection with `@extensions.list()` (Array) + +Returns `T[]` directly — simpler when extensions are static at startup: + +```ts +import {extensions, injectable, BindingScope} from '@loopback/core'; + +@injectable({scope: BindingScope.SINGLETON}) +export class PluginHost { + constructor( + @extensions.list(PLUGIN_EXTENSIONS) + private plugins: Plugin[] = [], + ) {} + + setup() { + for (const plugin of this.plugins) { + plugin.register(this); + } + } +} +``` + +**When to use which:** + +- `@extensions()` with `Getter` — extensions may be added after + construction +- `@extensions.list()` with `T[]` — extensions are all registered before the + class is instantiated (most common in component-based apps) + +## Implement Extensions + +```ts +import {config, injectable} from '@loopback/core'; +import {asGreeter, Greeter} from '../types'; + +export interface ChineseGreeterOptions { + nameFirst: boolean; +} + +@injectable(asGreeter) +export class ChineseGreeter implements Greeter { + language = 'zh'; + constructor( + @config() + private options: ChineseGreeterOptions = {nameFirst: true}, + ) {} + greet(name: string) { + if (this.options?.nameFirst === false) { + return `你好,${name}!`; + } + return `${name},你好!`; + } +} + +@injectable(asGreeter) +export class EnglishGreeter implements Greeter { + language = 'en'; + greet(name: string) { + return `Hello, ${name}!`; + } +} +``` + +## Register Extension Points + +```ts +import {BindingKey, BindingScope} from '@loopback/core'; + +export const GREETING_SERVICE = BindingKey.create( + 'services.GreetingService', +); + +// Direct binding +app + .bind(GREETING_SERVICE) + .toClass(GreetingService) + .inScope(BindingScope.SINGLETON); + +// Or via component (preferred) +export class GreetingComponent implements Component { + bindings = [ + createBindingFromClass(GreetingService, {key: GREETING_SERVICE}), + createBindingFromClass(EnglishGreeter), + createBindingFromClass(ChineseGreeter), + ]; +} +``` + +## Register Extensions + +Four methods: + +```ts +// Method 1: addExtension helper +import {addExtension} from '@loopback/core'; +addExtension(app, 'greeters', FrenchGreeter); + +// Method 2: Bind with template +app.bind('greeters.FrenchGreeter').toClass(FrenchGreeter).apply(asGreeter); + +// Method 3: createBindingFromClass (auto-applies @injectable metadata) +app.add(createBindingFromClass(FrenchGreeter)); + +// Method 4: Via component +export class GreetingComponent implements Component { + bindings = [ + createBindingFromClass(EnglishGreeter), + createBindingFromClass(ChineseGreeter), + ]; +} +``` + +## Configure Extension Points + +```ts +// 1. Declare @config() in the extension point class (see GreetingService above) + +// 2. Set configuration +app.configure(GREETING_SERVICE).to({color: 'blue'}); +``` + +## Configure Extensions + +```ts +// 1. Declare @config() in the extension class (see ChineseGreeter above) + +// 2. Set configuration +app.configure('greeters.ChineseGreeter').to({nameFirst: false}); +``` diff --git a/skills/loopback-core/references/interceptors-and-observers.md b/skills/loopback-core/references/interceptors-and-observers.md new file mode 100644 index 000000000000..f5f927012100 --- /dev/null +++ b/skills/loopback-core/references/interceptors-and-observers.md @@ -0,0 +1,202 @@ +# Interceptors and Life Cycle Observers + +## Table of Contents + +- [Interceptors Overview](#interceptors-overview) +- [Global Interceptors](#global-interceptors) +- [Interceptor Proxy](#interceptor-proxy) +- [Life Cycle Observers](#life-cycle-observers) +- [Dynamic Configuration via ContextView](#dynamic-configuration-via-contextview) + +## Interceptors Overview + +Interceptors provide aspect-oriented logic around method invocations. They +follow a chain-of-responsibility pattern with pre- and post-invocation hooks. + +## Global Interceptors + +Register an interceptor that applies to all invocations: + +```ts +import { + asGlobalInterceptor, + inject, + injectable, + Interceptor, + InvocationContext, + InvocationResult, + Provider, + ValueOrPromise, +} from '@loopback/core'; +import {CachingService} from '../caching-service'; +import {CACHING_SERVICE} from '../keys'; + +@injectable(asGlobalInterceptor('caching')) +export class CachingInterceptor implements Provider { + constructor( + @inject(CACHING_SERVICE) private cachingService: CachingService, + ) {} + + value() { + return async ( + ctx: InvocationContext, + next: () => ValueOrPromise, + ) => { + const targetName = ctx.targetName; + const cachingKey = `${targetName}:${JSON.stringify(ctx.args)}`; + const cachedResult = await this.cachingService.get(cachingKey); + if (cachedResult) return cachedResult; + + const result = await next(); + await this.cachingService.set(cachingKey, result); + return result; + }; + } +} + +// Register: +app.add(createBindingFromClass(CachingInterceptor)); +``` + +## Interceptor Proxy + +Apply interceptors between service dependencies: + +```ts +import {AsyncProxy} from '@loopback/core'; + +class Greeter { + @inject(CONVERTER, {asProxyWithInterceptors: true}) + private converter: AsyncProxy; + + async greet(name: string) { + const msg = await this.converter.toUpperCase(name); + return `Hello, ${msg}`; + } +} + +// Or resolve with proxy: +const greeter = await ctx.get(GREETER, {asProxyWithInterceptors: true}); +``` + +## Life Cycle Observers + +Participate in application `start`/`stop` events: + +```ts +import {inject, lifeCycleObserver, LifeCycleObserver} from '@loopback/core'; +import {CachingService} from '../caching-service'; +import {CACHING_SERVICE} from '../keys'; + +@lifeCycleObserver('caching') +export class CacheObserver implements LifeCycleObserver { + constructor( + @inject(CACHING_SERVICE) private cachingService: CachingService, + ) {} + + async start(): Promise { + await this.cachingService.start(); + } + + async stop(): Promise { + await this.cachingService.stop(); + } +} +``` + +### Observer Group Ordering + +The group name (first argument to `@lifeCycleObserver`) controls execution +order. Groups are sorted **alphabetically** during `start()` and +reverse-alphabetically during `stop()`. Use numbered prefixes to enforce order: + +```ts +@lifeCycleObserver('02-config') // starts first +export class ConfigObserver implements LifeCycleObserver { + /* ... */ +} + +@lifeCycleObserver('05-services') // starts second +export class ServiceObserver implements LifeCycleObserver { + /* ... */ +} + +@lifeCycleObserver('10-app') // starts last +export class AppObserver implements LifeCycleObserver { + /* ... */ +} +``` + +### Inline Binding Metadata + +Combine `@lifeCycleObserver` with scope and tags in the second argument: + +```ts +import { + BindingScope, + ContextTags, + lifeCycleObserver, + LifeCycleObserver, +} from '@loopback/core'; + +@lifeCycleObserver('06-mcp', { + scope: BindingScope.SINGLETON, + tags: {[ContextTags.KEY]: MCP_HOST_FACTORY_KEY}, +}) +export class MCPHostFactory implements LifeCycleObserver { + async start(): Promise { + /* initialize */ + } + async stop(): Promise { + /* cleanup */ + } +} +``` + +This is equivalent to separately applying +`@injectable({scope: BindingScope.SINGLETON})` and tagging the binding. + +## Dynamic Configuration via ContextView + +React to configuration changes at runtime: + +```ts +import {BindingScope, config, ContextView, injectable} from '@loopback/core'; + +@injectable({scope: BindingScope.SINGLETON}) +export class CachingService { + private timer: NodeJS.Timer; + private store: Map = new Map(); + + constructor( + @config.view() + private optionsView: ContextView, + ) { + // React to configuration changes + optionsView.on('refresh', () => { + this.restart().catch(err => { + console.error('Cannot restart the caching service.', err); + process.exit(1); + }); + }); + } + + async getTTL() { + const options = await this.optionsView.singleValue(); + return options?.ttl ?? 5000; + } + + async start(): Promise { + await this.clear(); + const ttl = await this.getTTL(); + this.timer = setInterval(() => { + this.sweep().catch(console.warn); + }, ttl); + } + + async stop(): Promise { + if (this.timer) clearInterval(this.timer); + await this.clear(); + } +} +```