Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions contracts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"main": "../lib/contracts/index.js",
"types": "../lib/contracts/index.d.ts"
}
275 changes: 275 additions & 0 deletions dependency-injection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
Dependency Injection
====================

The NativeScript CLI is migrating from name-based dependency injection (the
`$injector` global, where a constructor parameter named `$doctorService`
resolves the service registered under the string `"doctorService"`) to a typed,
token-based container. Both APIs are backed by **one container**, so they can be
mixed freely: a service registered under a legacy string name is resolvable
through its typed token and vice versa. The legacy `$injector` surface remains
fully supported, is marked `@deprecated` in-editor, and its usage is traced at
runtime so removal can be staged over releases.

Inside the CLI, import from `lib/common/di`. Extension and hook authors import
the same API from the `nativescript/contracts` subpath (see
[For extension and hook authors](#for-extension-and-hook-authors)).

At a glance
-----------

```ts
import { inject, DoctorService } from "nativescript/contracts";

class PlatformChecker {
private doctorService = inject(DoctorService); // typed, no decorators needed

async check(projectDir: string): Promise<boolean> {
return this.doctorService.canExecuteLocalBuild({ projectDir });
}
}
```

Services that have no typed token yet remain reachable by their registry name —
`inject("logger")` — but that is the migration bridge, not the API: prefer the
token wherever one exists, and mint a token rather than a new string name.

Tokens: `@Contract`
-------------------

A token is an abstract class annotated with `@Contract`. The class is both the
compile-time type and the runtime lookup key; the decorator records the token's
canonical string name:

```ts
import { Contract } from "nativescript/contracts";

@Contract({ name: "doctorService" }) // canonical form, no `$`
export abstract class DoctorService {
abstract canExecuteLocalBuild(configuration?: {
platform?: string;
projectDir?: string;
}): Promise<boolean>;
}
```

Rules:

- **The name is an explicit string literal** — never derived from `class.name`,
which changes under minification.
- Names are minted at this single choke point: declaring two contracts with the
same name **throws at load time**, because a duplicate would silently alias
two tokens.
- The options object leaves room for future fields without changing call sites.
- Implementations do not become tokens by extending or implementing a contract;
only the decorated class itself is a token.

Resolving: `inject()` and `Injector`
------------------------------------

`inject(token)` returns the singleton for a token from the current injection
context. It is **synchronous by design** and valid only:

- in field initializers,
- in constructor bodies,
- in provider factories,
- inside an explicit `runInInjectionContext(injector, fn)`.

It is **not** valid after an `await`. For late or conditional lookups,
self-inject the `Injector` and use `get()`:

```ts
import { inject, Injector } from "nativescript/contracts";

class EnvironmentChecker {
private injector = inject(Injector);

async check(projectDir: string) {
await somethingAsync();
return this.injector.get(DoctorService); // fine after await
}
}
```

`Injector.get()` accepts a contract class, a string name, or a `$`-prefixed
string name — all three return the same instance. The string forms exist for
interoperability with the legacy registry; use the class token whenever one
exists.

Both `inject()` and `get()` take Angular-shaped options as their second
argument:

```ts
inject(DoctorService, { optional: true }); // DoctorService | null — no throw
inject("logger", { skipSelf: true }); // start at the parent: escapes a
// child scope's shadowing entry
inject("options", { self: true }); // this level only — no fallthrough
```

`optional` covers not-found only; a found-but-misconfigured provider still
throws. `self` and `skipSelf` cannot be combined. There is deliberately no
`host` option: it is an Angular component-tree concept with no analog in the
CLI's injector hierarchy.

Registering: providers
----------------------

```ts
import { provide, provideLazy, Injector } from "nativescript/contracts";

const injector = new Injector([
// eager class binding; type-checked: the impl must satisfy the token
provide(DoctorService, DoctorServiceImpl),

// deferred loading: the module is require()d on first resolution only
provideLazy(DoctorService, () => require("./doctor-service").DoctorServiceImpl),

{ provide: Config, useValue: { DISABLE_HOOKS: false } },
{ provide: Dispatcher, useFactory: () => createDispatcher(), shared: false },
]);

// registration is also allowed after construction; re-registering a token
// updates the existing record in place
injector.register(provide(ProjectNameService, ProjectNameServiceImpl));
```

Provider kinds:

| Kind | Shape | Notes |
|---|---|---|
| Class | `provide(Token, Impl)` / `{ provide, useClass }` | constructed with `new Impl()` inside an injection context, so `inject()` works in its fields |
| Lazy class | `provideLazy(Token, () => Impl)` / `{ provide, useLazyClass }` | loader runs on first `get()` only — keeps startup lazy |
| Value | `{ provide, useValue }` | registered instance; re-registering replaces the cached instance. With `shared: false` there is no resolver and `get()` throws — a preserved legacy quirk |
| Factory | `{ provide, useFactory }` | called inside an injection context |

`shared: false` makes a provider transient: every resolution constructs a fresh
instance. Transient instances are still retained by the container so
`dispose()` reaches them.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

String keys are accepted anywhere a token is (`{ provide: "logger", useValue }`)
— that is how the legacy facade registers, and how per-call overrides address
not-yet-migrated dependencies. New registrations should mint a `@Contract`
token instead of a new string name.

For per-call construction with overrides (a fresh instance of a class with some
dependencies replaced), use `createInstance`:

```ts
const debugService = injector.createInstance(IOSDeviceDebugService, [
{ provide: "device", useValue: device },
]);
```

Overrides shadow **one level deep only** — the direct dependencies of the class
being constructed. Nested dependencies are constructed by the injector that
owns them and never see the per-call providers.

Resolution semantics
--------------------

- Lookup is **class object first, token name on a miss**, checked per injector
level before delegating to the parent. Both keys index the same provider
record, so re-registering a service by its string name (as plugins are
documented to do with `$logger`) stays visible to `inject(Logger)` consumers.
- A leading `$` is stripped from string tokens: `get("$fs")` and `get("fs")`
are the same registration.
- The name fallback also makes **duplicated contract copies interchangeable**:
if an extension's dependency tree carries its own copy of a contract class,
that copy resolves to the same provider by name. "Works locally, breaks when
installed" is not a failure mode of this design.
- Cyclic dependencies fail with the full resolution path
(`Cyclic dependency detected on dependency 'a'. Resolution path: a -> b -> a`).

Child scopes
------------

`injector.createChild(providers)` creates a scope that shadows its parent for
the given tokens and falls through for everything else. Sibling scopes are
isolated. Scopes are how per-invocation data (hook payloads, per-call
overrides) is layered over the shared singletons without ever entering the
root container.

`forwardRef`
------------

Provider arrays are evaluated at module load. When a token is declared later in
the same file (TDZ) or reached through a circular import, wrap the reference in
a thunk; it is read only when the injector processes the provider:

```ts
import { forwardRef } from "nativescript/contracts";

const providers = [
{ provide: forwardRef(() => DoctorService), useClass: DoctorServiceImpl },
];
```

`forwardRef` defers *references*, not construction — it cannot break an
instantiation cycle between two services. For that, self-inject the `Injector`
and resolve late (see above).

Working alongside the legacy `$injector`
----------------------------------------

The `Yok` facade (`global.$injector`) IS an `Injector` — the class extends the
token-based container — so the new API works on it directly:

```ts
$injector.resolve("doctorService") === $injector.get(DoctorService); // true
$injector.register(provide(DoctorService, DoctorServiceImpl));
runInInjectionContext($injector, () => inject(DoctorService));
```

- Legacy string names are permanent: a contract's token name is its interop
identity, used by hooks, plugins, and the public API. Nothing is deleted
per-service.
- Every legacy member (`resolve`, `register`, `require*`, the command-registry
surface) carries `@deprecated` JSDoc naming its replacement.
- Legacy usage at the external entry points (param-name hooks, require-time
extension registration, help templating) is reported through a deprecation
tracer. It logs at trace level today; set `NS_DEPRECATIONS=warn` or
`NS_DEPRECATIONS=error` to preview the stricter stages that later releases
will default to.

For extension and hook authors
------------------------------

Depend on `nativescript` itself (as a `peerDependency`, plus a `devDependency`
for local development) and import from the `contracts` subpath:

```ts
import { inject, DoctorService } from "nativescript/contracts";
```

- The subpath resolves through a directory `package.json` — the CLI's
`package.json` deliberately has **no `exports` map**, so any deep `require()`
paths you already use keep working.
- The entry point is side-effect-free: importing it never boots a CLI runtime,
even from a duplicated copy in your dependency tree.
- The existing `$injector`-based extension and hook mechanisms keep working
unchanged; the typed API is additive.

Available contracts
-------------------

The first tranche, growing as services migrate:

| Token | Legacy name |
|---|---|
| `DoctorService` | `doctorService` |
| `ProjectNameService` | `projectNameService` |

Legacy → new quick reference
----------------------------

`di` below is any `Injector` you hold — including `$injector` itself, which
extends `Injector`.

| Legacy (`$injector`) | New |
|---|---|
| `resolve("name")` | `inject(Token)` in an injection context, or `di.get(Token)` |
| `resolve(SomeClass)` / `resolve(SomeClass, { dep })` | `di.createInstance(SomeClass, [{ provide: "dep", useValue }])` |
| `register("name", Impl)` | `di.register(provide(Token, Impl))` |
| `register("name", instance)` | `di.register({ provide: Token, useValue: instance })` |
| `register("name", Impl, false)` | `di.register({ provide: Token, useClass: Impl, shared: false })` |
| `require("name", "./path")` | `provideLazy(Token, () => require("./path").Impl)` |
| constructor param `$name` | `inject(Token)` field initializer |
45 changes: 35 additions & 10 deletions extending-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,29 @@ Execute Hooks In-Process

When your hook is a Node.js script, the CLI executes it in-process. This gives you access to the entire internal state of the CLI and all of its functions.

The CLI assumes that this is a CommonJS module and calls its single exported function with four parameters. The type of the parameters is described in the `.d.ts` files which are part of the CLI source code [here](https://github.com/NativeScript/nativescript-cli/tree/master/lib/definitions) and [here](https://github.com/telerik/mobile-cli-lib/tree/master/definitions).
The CLI assumes that this is a CommonJS module and calls its single exported function.

## Writing a hook

Hooks run inside an injection context, so services come from `inject()` — the same API used everywhere else (see [dependency-injection.md](dependency-injection.md)). Declare a `hookArgs` parameter only if you need the payload of the operation being hooked.

```JavaScript
const { inject, DoctorService } = require("nativescript/contracts");

module.exports = function (hookArgs) {
const doctorService = inject(DoctorService);
return doctorService.canExecuteLocalBuild();
};
```

* `inject()` is valid in the synchronous part of the hook body — not after an `await`. Resolve what you need up front; for late lookups, grab the container first: `const injector = inject(Injector)` (`Injector` is exported from `nativescript/contracts` too), then `injector.get(...)` later.
* `hookArgs` contains the parameters of the CLI function being hooked; its shape depends on the hook point. Declare it only when you need it — a hook may also take no parameters at all. A future typed hook API (`defineHook` with an explicit context object) will replace this parameter; it is the one remaining piece of the legacy convention.
* Tokens resolve by class first and by their canonical name on a miss, so this works even if your dependency tree carries its own copy of `nativescript` — a duplicated token class still resolves to the running CLI's service.
* Only a first tranche of services has typed tokens so far ([dependency-injection.md](dependency-injection.md#available-contracts) lists them); a service without a token is reachable by its registry name — `inject("logger")` — as a migration bridge.
* If you build your hook in TypeScript, add `nativescript` as a `devDependency` and import the same names: `import { inject, DoctorService } from "nativescript/contracts"`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## The hook contract

Parameter | Type | Description
---|---|---
`$logger` | ILogger | Use the members of this class to show messages to the user cooperating with the CLI internal state.
`$projectData` | IProjectData | Contains data about the project, such as project directory, ID, dependencies, etc.
`$usbLiveSyncService` | ILiveSyncService | Use this variable to check whether a LiveSync or normal build is in progress.
`hookArgs` | Any | Contains all the parameters of the original function in the CLI which is being hooked.

The hook must return a Promise. If the hook succeeds, it must fullfil the promise, but the fullfilment value is ignored.
The hook can also reject the promise with an instance of Error. The returned error can have two optional members controlling the CLI.

Expand All @@ -95,8 +109,19 @@ Member | Type | Description
`errorAsWarning` | Boolean | Set this to treat the returned error as warning. The CLI prints the error.message colored as a warning and continues executing the current command.

If these two members are not set, the CLI prints the returned error colored as fatal error and stops executing the current command.

Furthermore, the global variable `$injector` of type `IInjector` provides access to the CLI Dependency Injector, through which all code services are available.

## Legacy: parameter-name injection

Historically, a hook received CLI services by naming them as parameters: the CLI parses the exported function's parameter names and injects the service registered under each name. Existing hooks written this way keep working unchanged, but **new hooks should use the pattern above** — parameter-name service injection is slated for deprecation, and hooks that use it are reported through the CLI's deprecation tracer (visible with `--log trace`, or as warnings with `NS_DEPRECATIONS=warn`).

Parameter | Type | Description
---|---|---
`$logger` | ILogger | Use the members of this class to show messages to the user cooperating with the CLI internal state.
`$projectData` | IProjectData | Contains data about the project, such as project directory, ID, dependencies, etc.
`$usbLiveSyncService` | ILiveSyncService | Use this variable to check whether a LiveSync or normal build is in progress.
`hookArgs` | Any | Contains all the parameters of the original function in the CLI which is being hooked.

The type of the parameters is described in the `.d.ts` files which are part of the CLI source code [here](https://github.com/NativeScript/nativescript-cli/tree/master/lib/definitions). Any registered service name is injectable, not only the ones listed; the global variable `$injector` of type `IInjector` likewise remains available. A parameter the CLI cannot resolve causes the hook to be skipped with a warning.

Commands with Hooking Support
==============================
Expand Down
31 changes: 31 additions & 0 deletions lib/common/contracts/command-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Contract } from "../di/contract";
import type { ICommand } from "../definitions/commands";

/**
* The command-registry face of the injector facade. Transitional contract: it
* mirrors what consumers call today, so that extracting the registry from the
* facade later is a provider swap for this token, not a consumer migration.
* Members slated for replacement keep their deprecation markers.
*/
@Contract({ name: "commandRegistry" })
export abstract class CommandRegistry {
/**
* @deprecated Path-based command registration; slated for replacement by
* manifest-declared commands.
*/
abstract requireCommand(names: string | string[], file: string): void;
abstract registerCommand(names: string | string[], resolver: any): void;
abstract resolveCommand(name: string): ICommand;
abstract getRegisteredCommandsNames(includeDev: boolean): string[];
abstract getChildrenCommandsNames(commandName: string): string[];
abstract buildHierarchicalCommand(
parentCommandName: string,
commandLineArguments: string[],
): any;
/** Side-effecting: fails with help output on a bad subcommand. */
abstract isValidHierarchicalCommand(
commandName: string,
commandArguments: string[],
): Promise<boolean>;
abstract isDefaultCommand(commandName: string): boolean;
}
10 changes: 10 additions & 0 deletions lib/common/contracts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Internal subsystem contracts of the injector facade. Deliberately NOT
// re-exported from nativescript/contracts: promoting one to the public
// surface is a one-line decision that should be made per contract, not by
// default. Each token resolves to the facade itself until its subsystem is
// physically extracted — at which point the provider is swapped and consumers
// keep working unchanged.
export { CommandRegistry } from "./command-registry";
export { KeyCommandRegistry } from "./key-command-registry";
export { ModuleRegistry } from "./module-registry";
export { PublicApiBuilder } from "./public-api-builder";
15 changes: 15 additions & 0 deletions lib/common/contracts/key-command-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Contract } from "../di/contract";
import type { IKeyCommand, IValidKeyName } from "../definitions/key-commands";

/**
* The key-command face of the injector facade (the `keyCommands.` namespace).
* Kept separate from CommandRegistry because the two registries are redesigned
* on different tracks.
*/
@Contract({ name: "keyCommandRegistry" })
export abstract class KeyCommandRegistry {
abstract requireKeyCommand(name: IValidKeyName, file: string): void;
abstract registerKeyCommand(name: IValidKeyName, resolver: any): void;
abstract resolveKeyCommand(name: string): IKeyCommand;
abstract getRegisteredKeyCommandsNames(): string[];
}
Loading