agentsclimarketplace

Ditsmod extensions

Skill ditsmod/agent-skills/skills/ditsmod-extensions

Practical guidance for creating, registering, ordering, exporting, grouping, and overriding Ditsmod extensions. Use when writing custom extensions with stage1/stage2/stage3 hooks, injecting ExtensionManager to retrieve same-module or app-wide data (handling delay), dynamically registering providers, configuring module extensions metadata, or resolving cyclic extension dependencies.From its SKILL.md

Install
npx -y skills add ditsmod/agent-skills --skill ditsmod-extensions

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

20.2 KB, ~4.1k tokens by cl100k_base, as published. Nobody here has run it

Ditsmod Extensions

Mental Model

Extensions run after Ditsmod collects static metadata from decorators but before request handlers are created. Think of them as "infrastructure setup" hooks that operate on the fully assembled module graph.

  • Extensions operate strictly during the application initialization stage to set up infrastructure and do not participate directly in request processing.
  • Extensions prepare infrastructure: dynamically add interceptors/providers, build route metadata, set up OpenAPI docs, initialize DB connections.
  • Extensions can be async and depend on each other through ExtensionManager.
  • Module Scope: An extension is instantiated and executed only in modules where it is explicitly registered in the extensions array (or imported via export: true / exportOnly: true). It does not automatically execute in modules that do not register or import it.

Implementing An Extension

An extension is a class decorated with @injectable() that implements the Extension<T> interface, where T specifies the payload return type of stage1() (and no other stage method). All three stage methods are optional — implement only the stages you need.

[!IMPORTANT] The stages stage1, stage2, and stage3 are framework lifecycle hooks that are always executed sequentially in that order during the application bootstrap process. They are never skipped or executed out of order. Therefore, you can rely on state populated in stage2 (like a module injector instance) to always be available in stage3.

import { injectable, inject, PROVIDERS_PER_APP } from '@ditsmod/core';
import type { Extension, ExtensionManager, Injector, Provider } from '@ditsmod/core';

@injectable()
export class MyExtension implements Extension<MyPayload | void> {
  constructor(
    private extensionManager: ExtensionManager,
    @inject(PROVIDERS_PER_APP) protected providersPerApp: Provider[],
  ) {}

  async stage1(isLastModule: boolean): Promise<MyPayload | void> {
    // Stage 1: called once per module while providers are being dynamically collected.
    // isLastModule === true when this is the last module that imports this extension.
    // Return a payload (e.g., collected metadata) that other extensions can consume
    // via ExtensionManager.stage1(). Return void if no payload is produced.
  }

  async stage2(injectorPerMod: Injector): Promise<void> {
    // Stage 2: called after stage1 finishes for ALL modules.
    // Receives a ready module-level injector.
  }

  async stage3(): Promise<void> {
    // Stage 3: called after stage2 finishes for ALL modules.
    // No strict role — use for final post-processing.
  }
}

Constructor Injection Constraints

  • The injector for the extension constructor is created before any extension runs.
  • You can only inject providers statically registered (module-level or app-level) before extensions execute, or default extension providers exported via defaultExtensionProviders from @ditsmod/core.
  • You cannot inject providers that are dynamically added by other extensions.
  • The extension and application injectors belong to separate hierarchy trees. The extension injector is short-lived and destroyed once the extensions finish initializing. Because any providers instantiated within the extension's constructor belong to this temporary injector, they are not shared with the main application.

isLastModule Parameter

stage1(isLastModule: boolean) receives true only for the final module that imports this extension. Use this flag to defer aggregation work (e.g., finalizing a data structure) until all modules that import this extension have contributed.

Accessing Extension Metadata (extensionsMeta)

Extension-specific configurations passed via @featureModule({ extensionsMeta: { ... } }) or dynamic module options (DynamicModule.withParams({ extensionsMeta: { ... } })) are normalized by ModuleNormalizer into normalizedModuleMeta.extensionsMeta.

An extension retrieves extensionsMeta from normalizedModuleMeta depending on how it receives module metadata:

  1. Via ResolvedModuleMeta in constructor:

    const myOptions = this.resolvedModuleMeta.normalizedModuleMeta.extensionsMeta[MY_EXTENSION];
    
  2. Via RouteExtensionMeta from ExtensionManager:

    const myOptions = routeExtensionMeta.normalizedModuleMeta.extensionsMeta[MY_EXTENSION];
    

In all cases, extensionsMeta is accessed directly as a property of normalizedModuleMeta (normalizedModuleMeta.extensionsMeta). Keep each extension's metadata stored under its own dedicated key. For full runnable class implementations and patterns for passing and consuming extensionsMeta, see references/REFERENCE.md.


Registering An Extension

Add extensions to the extensions array of any module decorator (@featureModule, @restModule, etc.).

[!IMPORTANT] Extensions are strictly module-scoped. Registering an extension in a module (including the root module) executes it only within that specific module instance, unless it is exported to importing modules using export: true or exportOnly: true.

Direct Class Registration

Simplest form — runs only in the current module with no ordering constraints:

@restModule({
  extensions: [MyExtension],
})
export class AppModule {}

Config Object Registration

Use the config object form for ordering, exporting, grouping, or overriding:

@restModule({
  extensions: [
    {
      extension: MyExtension,
      beforeExtensions: [ConsumerExtension], // MyExtension runs before ConsumerExtension
      afterExtensions: [ProducerExtension], // MyExtension runs after ProducerExtension
      groups: [RestRouteExtension], // MyExtension joins the RestRouteExtension group
      export: true, // also runs in every module that imports this module
    },
  ],
})
export class FeatureModule {}

export and exportOnly are mutually exclusive. beforeExtensions, afterExtensions, and groups are not available when using overrideExtension (see below).


Configuration Properties Reference

Ordering

PropertyMeaning
beforeExtensionsThis extension runs before each listed class
afterExtensionsThis extension runs after each listed class

Both accept an array of ExtensionClass values.

Export Behaviour

PropertyRuns in host module?Runs in importing modules?
(default)YesNo
export: trueYesYes
exportOnly: trueNoYes

[!TIP] When registering extensions that are only intended to run in importing modules (such as in wrapper, library, or shared modules), always prefer exportOnly: true over export: true. This prevents the extension from executing unnecessarily in the host module itself, which can lead to redundant execution or runtime errors due to missing configuration/routes in the host.

Overriding

Replace an imported extension with a custom one. The override form only accepts extension and overrideExtensionbeforeExtensions, afterExtensions, groups, export, and exportOnly are not allowed in this form:

extensions: [{ extension: MyExtension, overrideExtension: ExternalExtension }];

MyExtension is registered under the token ExternalExtension, so all existing consumers transparently receive MyExtension.


Extension Groups

An extension group allows multiple extensions to contribute data under the class token of a lead extension. Analogous to HTTP interceptor groups, an extension group represents a specific type of work performed over application metadata.

extensions: [
  {
    extension: OpenApiRouteExtension,
    groups: [RestRouteExtension], // joins the RestRouteExtension group
    export: true,
  },
];

[!NOTE] An extension group does not require a special token class or dedicated group entity. Any ordinary extension class (such as RestRouteExtension) acts as the group token simply when other extensions list it in their groups array.

Key Rules & Requirements for Extension Groups

1. Shared Base Interface Contract (Critical Requirement)

All extensions belonging to a specific group must return data from stage1() that adheres to a shared base interface.

  • Contract Guarantee: Consumers that call extensionManager.stage1(LeadExtension) rely on the base interface shape to safely iterate and process elements in groupData.
  • Extensibility: Group members can extend the base interface with additional properties (e.g., OpenApiRouteExtension returning extended route metadata with OpenAPI schemas), but must never narrow or break the base interface contract.

2. Automatic Execution Ordering via Group Membership

When an extension joins a group via groups: [LeadExtension], it automatically inherits the group's positioning relative to other extensions in the execution graph:

  • If LeadExtension was declared with beforeExtensions: [TargetExtension], any extension joining groups: [LeadExtension] is automatically placed in the execution queue after LeadExtension and before TargetExtension (for example, since RestRouteExtension runs before DispatcherExtension, any extension joining groups: [RestRouteExtension] will automatically run after RestRouteExtension and before DispatcherExtension).
  • You do not need to explicitly repeat beforeExtensions: [TargetExtension] on member extensions. This allows third-party modules to seamlessly integrate into existing extension pipelines without manual ordering configuration.

3. Group Lookup Behavior & Asymmetry in ExtensionManager

Calling extensionManager.stage1() with an extension class yields different results depending on whether it is queried as a lead extension or as a specific member:

  • Lead Extension (Group) Lookup: await extensionManager.stage1(LeadExtension) returns aggregated groupData containing the payload from LeadExtension and all member extensions registered in its group (groups: [LeadExtension]).
  • Direct Member Lookup: await extensionManager.stage1(SpecificMemberExtension) returns groupData containing only the payload from SpecificMemberExtension.
  • Index Alignment: Elements in groupData map 1-to-1 by array index to debug entries in groupDebugMeta:
    groupData[i] === groupDebugMeta[i]?.payload;
    

4. Group Configuration Rules

  • Multiple Groups: An extension can belong to multiple groups by passing multiple class tokens: groups: [ExtensionA, ExtensionB].
  • Override Constraint: If an extension is registered using overrideExtension, the groups property (along with beforeExtensions, afterExtensions, export, and exportOnly) is not allowed.

For a detailed step-by-step example demonstrating group formation, lead extension inclusion, and expansion, see Extension Group Formation & Lead Extension Membership Example.


Using ExtensionManager

ExtensionManager manages extension dependencies, caches stage1 results, and detects cyclic dependencies.

[!WARNING] An extension cannot request its own stage1 data via ExtensionManager.stage1() (e.g., await this.extensionManager.stage1(CurrentExtension) or await this.extensionManager.stage1(CurrentExtension, this)). Requesting itself creates a self-referential cyclic dependency and will throw a cyclic dependency error.

Inject it in the constructor:

constructor(private extensionManager: ExtensionManager) {}

Same-Module Data

Retrieves the stage1 output of TargetExtension (or its group) within the current module only:

const meta = await this.extensionManager.stage1(TargetExtension);
// meta.groupData — array of T values returned by each extension in the group/class
// meta.delay     — always false for same-module calls

[!NOTE] If TargetExtension is not imported into the current module, this expression will not return data.

App-Wide Data (Cross-Module)

Pass this as the second argument to receive aggregated data from modules where TargetExtension is registered:

const meta = await this.extensionManager.stage1(TargetExtension, this);

A separate instance of your extension is created for each module where it is imported. Passing this instructs the framework to accumulate stage1 data across modules where TargetExtension is imported (e.g., if an application has 10 modules and TargetExtension is imported in only 2 of them, data is accumulated from those 2 modules).

Handling delay

When requesting app-wide data you must check meta.delay. If true, not all modules that import TargetExtension have completed stage1 yet — return early; the framework will call your extension again later:

const meta = await this.extensionManager.stage1(TargetExtension, this);
if (meta.delay) {
  return; // not ready yet — framework will re-invoke this extension
}

// meta.groupDataPerApp: AppExtensionGroupMeta<T>[]
//   Each entry = result from one module that registered TargetExtension
meta.groupDataPerApp.forEach((perModMeta) => {
  // perModMeta.groupData: T[]  — payloads from that module
  perModMeta.groupData.forEach((payload) => {
    // process payload
  });
});

For the full ExtensionGroupMeta type shape, see references/REFERENCE.md.


Dynamic Provider Registration

Extensions can dynamically push into provider arrays that are still being assembled. Providers can be pushed to:

  1. Application-level metadata (providersPerApp injected via @inject(PROVIDERS_PER_APP)): Affects the entire application across all modules.
  2. Module-level metadata (normalizedModuleMeta.providersPerMod, providersPerRou, providersPerReq): Affects the entire current module.
  3. Controller/Route-level metadata (controllersMeta[i].providersPerRou or controllersMeta[i].providersPerReq): Affects only the specific controller or route (e.g. selectively adding interceptors as in BodyParserExtension).
StageAllowed provider levels
stage1Any: app, module, route, request
stage2Only below module: route, request

Typical Pattern: Reading Config, Pushing Interceptors

[!IMPORTANT] If you need to read statically declared module-level configuration in stage1, you must build a temporary injector. This requires injecting the PROVIDERS_PER_APP array in the constructor via @inject(PROVIDERS_PER_APP) so you can resolve it.

async stage1(): Promise<void> {
  const meta = await this.extensionManager.stage1(RestRouteExtension);

  meta.groupData.forEach((routeExtensionMeta) => {
    const { providersPerMod } = routeExtensionMeta.normalizedModuleMeta;

    // Pushing to controller/route-level metadata specifically:
    routeExtensionMeta.controllersMeta.forEach(({ providersPerRou, providersPerReq }) => {
      // Build a temporary injector hierarchy to inspect config
      const injectorPerApp = Injector.resolveAndCreate(this.providersPerApp, 'App');
      const injectorPerMod = injectorPerApp.resolveAndCreateChild(providersPerMod);
      const config = injectorPerMod.get(MyConfig, null);

      if (config?.enableFeature) {
        // Pushing to individual controller/route providersPerReq array (controller-scoped)
        providersPerReq.push({
          token: HTTP_INTERCEPTORS,
          useClass: MyInterceptor,
          multi: true,
        });
      }
    });
  });
}

Always ensure your extension is ordered after the extension that produces the metadata (e.g., after RestRouteExtension) and before the extension that consumes it (e.g., before DispatcherExtension).

Transferring State & Resources to the Application (ValueProvider)

Since the extension's internal injector is temporary and destroyed post-bootstrap, any state or runtime resource initialized by an extension (e.g., database connection pools, SDK clients, external service configurations, compiled caches) must be transferred to the application's long-lived DI hierarchy.

To pass a ready object or resource instance to the application:

  1. Initialize or connect to the resource asynchronously inside stage1() or stage2().
  2. Push a ValueProvider ({ token: 'some-token', useValue: initializedInstance }) into the appropriate provider hierarchy array (providersPerApp, providersPerMod, providersPerRou, or providersPerReq).
@injectable()
export class DbExtension implements Extension<void> {
  constructor(@inject(PROVIDERS_PER_APP) protected providersPerApp: Provider[]) {}

  async stage1(): Promise<void> {
    // 1. Asynchronously create/initialize the resource during bootstrap
    const dbClient = await createDbConnection({/* config */});

    // 2. Register the existing instance into application DI via ValueProvider
    this.providersPerApp.push({ token: DbClient, useValue: dbClient });
  }
}

Any application service or controller can then inject DbClient via standard DI.


Registration Decision Guide

GoalConfiguration
Run only in this moduleextensions: [MyExtension]
Control execution orderObject form with beforeExtensions / afterExtensions
Run in this module AND importersexport: true
Run in importers only (not host)exportOnly: true
Contribute to an existing data pipelinegroups: [TargetExtension]
Replace an imported extension{ extension: MyExt, overrideExtension: OriginalExt }

Troubleshooting

SymptomFix
Extension does not run in importing moduleAdd export: true or exportOnly: true in host module
Extension runs too earlyAdd afterExtensions: [ProducerExtension]
Extension runs too lateAdd beforeExtensions: [ConsumerExtension]
Group data is empty or missingVerify groups: [TargetExtension] uses the correct class token
groupDataPerApp is incompleteEnsure this is passed as second arg to extensionManager.stage1() and delay is handled
Circular dependency errorCheck the error log — ExtensionManager prints the full loop path; use afterExtensions to break the cycle
UndeclaredExtensionDependency errorExtension A called extensionManager.stage1(B) but B was not listed in A's afterExtensions

Further Reading

For full type definitions (Extension<T>, ExtensionGroupMeta<T>, AppExtensionGroupMeta<T>, ExtensionConfig union), see references/REFERENCE.md.

What ships with it: 1 file

10.2 KB alongside SKILL.md

references/

Keep looking

Skills are one crate of 326,861. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.