[![npm version](https://img.shields.io/npm/v/@itrocks/compose?logo=npm)](https://www.npmjs.org/package/@itrocks/compose)
[![npm downloads](https://img.shields.io/npm/dm/@itrocks/compose)](https://www.npmjs.org/package/@itrocks/compose)
[![GitHub](https://img.shields.io/github/last-commit/itrocks-ts/compose?color=2dba4e&label=commit&logo=github)](https://github.com/itrocks-ts/compose)
[![issues](https://img.shields.io/github/issues/itrocks-ts/compose)](https://github.com/itrocks-ts/compose/issues)
[![discord](https://img.shields.io/discord/1314141024020467782?color=7289da&label=discord&logo=discord&logoColor=white)](https://25.re/ditr)

# compose

Class compositions via configuration file, enabling mixins addition and module exports replacement.

## Installation

```bash
npm install @itrocks/compose
```

## Usage

The `compose()` function must be called as early as possible, before any configured class is loaded.

In practice: call `compose()` before any `import()` or `require()` of application modules.

A minimal and typical setup can be seen in
[@itrocks/framework](https://github.com/itrocks-ts/framework/blob/main/src/framework.ts).

## Example

Given the [@itrocks/user](https://github.com/itrocks-ts/user/blob/main/src/user.ts) package
providing a `User` class, and an override of this class in a `user-override.ts` file stored at your project root:
```ts
// user-override.ts
import { User } from '@itrocks/user'
export class UserOverride extends User {}
```

Your main entry point may start with:
```ts
// main.ts
import { compose } from '@itrocks/compose'

compose(__dirname, {
	'@itrocks/user': '/user-override'
})

// ... in code using User through dynamic import/require,
// User will be replaced by UserOverride and include any new or overridden features.
```

## Limitations

### CommonJS execution

This package relies on dynamic module loading. In Node.js:
- `require()` is dynamic and can be overridden,
- native ES `import` is static and cannot.

You can write ES `import` syntax in TypeScript, then transpile it to CommonJS, where imports become `require()`.

**Recommended minimal TypeScript configuration:**
```json
{
	"compilerOptions": {
		"module": "nodenext",
		"moduleResolution": "nodenext",
		"target": "ES2022"
	}
}
```

### Static import declarations

Static imports must be declared first in modules. As a result, `compose()` will only affect:
- modules loaded dynamically after it is called,
- static imports declared inside dynamically loaded modules.

## API

### compose(baseDir, config)

```ts
compose(baseDir: string, config: Record<string, string | string[]>): void
```

**Parameters:**
- `baseDir`: a directory used to resolve paths starting with `/`
- `config`: an object where:
  - the **key** is the module (and optional export) to replace
  - the **value** is the replacement module (and optional export), or an array of replacements

### Configuration module export format

Both replaced and replacement module / export apply these rules:

- A module path starting with `/` refers to the JavaScript file path, relative to `baseDir`'s argument.
- A module path not starting with `/` refers to the name of node_modules package or package module.
- The name of the export can be explicitly defined after the module path, separated by ':'.
- If no export is given: the default export is used if set,
  otherwise the first exported value encountered at runtime is used.
- If the `default` export is given but the module has no default export,
  the first exported value encountered at runtime is used.

Note: omitting `:exportName` is strictly equivalent to using `:default`.

### Single replacement

If one replacement is given for a module, it is applied:
- if the replacement inherits the original type, it is selected as the replacement type,
- otherwise, the original type is kept as the base type, and the replacement is applied as a **mixin** via [@itrocks/use](https://github.com/itrocks-ts/use),

### Multiple replacements

If multiple replacements are given for a module, they are applied in the order they are defined:
- the first entry that inherits the original type is selected as the replacement base type,
- if no entry inherits the original type: the original type is kept as the base type,
- the remaining entries are applied as **mixins** via [@itrocks/use](https://github.com/itrocks-ts/use).

### Summary table

| Configuration entry                        | Effect                                                                                                   |
|--------------------------------------------|----------------------------------------------------------------------------------------------------------|
| `'pkg' : 'override'`                       | Replaces the module default export (or first export) with the override default export (or first export). |
| `'pkg:default' : 'override'`               | Same as above, explicit `default` on both sides.                                                         |
| `'pkg:User' : 'override:UserOverride'`     | Replaces the named export `User` with `UserOverride`.                                                    |
| `'pkg' : ['override']`                     | Single replacement: if it inherits the original type → replacement, otherwise applied as a mixin.        |
| `'pkg' : ['override', 'mixinA', 'mixinB']` | First inheriting entry becomes the base type, others are applied as mixins (in order).                   |
| `'pkg' : ['mixinA', 'mixinB']`             | No replacement found: original type kept, all entries applied as mixins.                                 |
| `'/local-module' : 'pkg'`                  | Local module (resolved from `baseDir`) replaces a `node_modules` package.                                |
| `'pkg' : '/local-module'`                  | Package export replaced by a local implementation.                                                       |
| `'pkg' : 'override:mixinOnly'`             | No inheritance detected → original kept, override applied as mixin.                                      |

## Common mistakes

### Calling compose() too late

```ts
import { compose } from '@itrocks/compose'
import { User }    from '@itrocks/user'

compose(__dirname, { '@itrocks/user': '/user-override' }) // too late
```

If a module is already loaded, its exports are fixed.\
`compose()` will not retroactively replace anything.

✔️ Always call `compose()` before loading any module you want to affect.

### Using native ES modules at runtime

Running Node.js in native ESM mode means:
- `import` is static
- module loading cannot be intercepted

In that case, `compose()` cannot work.

✔️ Use TypeScript with CommonJS output (even if you write import syntax).

### Expecting static imports to be replaced

```ts
import { User } from '@itrocks/user'
```

Static imports are resolved before any runtime code runs.

✔️ Only modules loaded:
- dynamically (`require`, or `import` transpiled to `require` at runtime)
- or statically inside dynamically loaded modules

can be affected.

### Forgetting that :default is implicit

`'@itrocks/user': '/user-override'`

This is strictly equivalent to:

`'@itrocks/user:default': '/user-override:default'`

✔️ If a module has no default export, `compose()` will use the first exported value it finds.

### Assuming an override always replaces the base type

If a replacement does not inherit the original type:
- it is not used as the base
- it is applied as a mixin instead

✔️ To fully replace a type, the replacement must extend or inherit from it.

### Mixing up path resolution rules

'@itrocks/user': 'user-override'   // node resolution
'@itrocks/user': '/user-override'  // resolved from baseDir

✔️ Paths starting with `/` are resolved from `baseDir`.\
✔️ Others are resolved via Node’s module resolution (`node_modules`).
