Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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.

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 |
90 changes: 80 additions & 10 deletions extending-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,70 @@ 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 the hook it exports — either a hook definition (see below) or a plain function.

## Writing a hook

Export a hook definition built with `defineHook`. It takes the hook point in the usual naming convention (`before-prepare`, `after-watch`) and a handler that receives a context object.

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

module.exports = defineHook("before-prepare", async (ctx) => {
const doctorService = inject(DoctorService);
await doctorService.canExecuteLocalBuild();
});
```

Services come from `inject()` — the same API used everywhere else (see [dependency-injection.md](dependency-injection.md)):

* `inject()` is valid in the synchronous part of the handler — 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.
* 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 { defineHook, inject, DoctorService } from "nativescript/contracts"`. An `.mjs` hook can `export default defineHook(...)`.

`ctx.payload` holds the parameters of the CLI operation being hooked; its shape depends on the hook point. It is the CLI's own object, so mutating it influences the operation:

```JavaScript
module.exports = defineHook("before-build-task-args", (ctx) => {
ctx.payload.args.push("--offline");
});
```

`ctx.wrap(middleware)` puts a middleware around the hooked method. The middleware receives the method's arguments and a `next` callback; call `next` to continue, or return without calling it to short-circuit the method entirely. Register it from a `before-` hook.

```JavaScript
module.exports = defineHook("before-prepare", (ctx) => {
ctx.wrap(async (args, next) => {
const result = await next(...args);
return result;
});
});
```

`ctx.abort(message)` stops the hook and fails the command. Pass `{ asWarning: true }` to print the message as a warning and let the command continue instead.

```JavaScript
module.exports = defineHook("before-prepare", (ctx) => {
ctx.abort("Nothing to prepare.", { asWarning: true });
});
```

### Plain function hooks

Exporting a plain function is still supported. It runs in an injection context too, so `inject()` works the same way; declare a `hookArgs` parameter if you need the payload.

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

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

## 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 +150,23 @@ 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.

A plain-function hook can also return a function, which the CLI folds into a middleware chain around the hooked method.

With `defineHook` neither convention is needed: `ctx.abort` replaces throwing an error carrying `stopExecution`/`errorAsWarning`, and `ctx.wrap` replaces returning a function.

## 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
Loading