Skip to content

Commit dfae38d

Browse files
authored
feat(di): token-based dependency injection behind the Yok facade (phase 1) (#6099)
[skip ci]
1 parent 3881f30 commit dfae38d

34 files changed

Lines changed: 2808 additions & 333 deletions

contracts/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"main": "../lib/contracts/index.js",
3+
"types": "../lib/contracts/index.d.ts"
4+
}

dependency-injection.md

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
Dependency Injection
2+
====================
3+
4+
The NativeScript CLI is migrating from name-based dependency injection (the
5+
`$injector` global, where a constructor parameter named `$doctorService`
6+
resolves the service registered under the string `"doctorService"`) to a typed,
7+
token-based container. Both APIs are backed by **one container**, so they can be
8+
mixed freely: a service registered under a legacy string name is resolvable
9+
through its typed token and vice versa. The legacy `$injector` surface remains
10+
fully supported, is marked `@deprecated` in-editor, and its usage is traced at
11+
runtime so removal can be staged over releases.
12+
13+
Inside the CLI, import from `lib/common/di`. Extension and hook authors import
14+
the same API from the `nativescript/contracts` subpath (see
15+
[For extension and hook authors](#for-extension-and-hook-authors)).
16+
17+
At a glance
18+
-----------
19+
20+
```ts
21+
import { inject, DoctorService } from "nativescript/contracts";
22+
23+
class PlatformChecker {
24+
private doctorService = inject(DoctorService); // typed, no decorators needed
25+
26+
async check(projectDir: string): Promise<boolean> {
27+
return this.doctorService.canExecuteLocalBuild({ projectDir });
28+
}
29+
}
30+
```
31+
32+
Services that have no typed token yet remain reachable by their registry name —
33+
`inject("logger")` — but that is the migration bridge, not the API: prefer the
34+
token wherever one exists, and mint a token rather than a new string name.
35+
36+
Tokens: `@Contract`
37+
-------------------
38+
39+
A token is an abstract class annotated with `@Contract`. The class is both the
40+
compile-time type and the runtime lookup key; the decorator records the token's
41+
canonical string name:
42+
43+
```ts
44+
import { Contract } from "nativescript/contracts";
45+
46+
@Contract({ name: "doctorService" }) // canonical form, no `$`
47+
export abstract class DoctorService {
48+
abstract canExecuteLocalBuild(configuration?: {
49+
platform?: string;
50+
projectDir?: string;
51+
}): Promise<boolean>;
52+
}
53+
```
54+
55+
Rules:
56+
57+
- **The name is an explicit string literal** — never derived from `class.name`,
58+
which changes under minification.
59+
- Names are minted at this single choke point: declaring two contracts with the
60+
same name **throws at load time**, because a duplicate would silently alias
61+
two tokens.
62+
- The options object leaves room for future fields without changing call sites.
63+
- Implementations do not become tokens by extending or implementing a contract;
64+
only the decorated class itself is a token.
65+
66+
Resolving: `inject()` and `Injector`
67+
------------------------------------
68+
69+
`inject(token)` returns the singleton for a token from the current injection
70+
context. It is **synchronous by design** and valid only:
71+
72+
- in field initializers,
73+
- in constructor bodies,
74+
- in provider factories,
75+
- inside an explicit `runInInjectionContext(injector, fn)`.
76+
77+
It is **not** valid after an `await`. For late or conditional lookups,
78+
self-inject the `Injector` and use `get()`:
79+
80+
```ts
81+
import { inject, Injector } from "nativescript/contracts";
82+
83+
class EnvironmentChecker {
84+
private injector = inject(Injector);
85+
86+
async check(projectDir: string) {
87+
await somethingAsync();
88+
return this.injector.get(DoctorService); // fine after await
89+
}
90+
}
91+
```
92+
93+
`Injector.get()` accepts a contract class, a string name, or a `$`-prefixed
94+
string name — all three return the same instance. The string forms exist for
95+
interoperability with the legacy registry; use the class token whenever one
96+
exists.
97+
98+
Both `inject()` and `get()` take Angular-shaped options as their second
99+
argument:
100+
101+
```ts
102+
inject(DoctorService, { optional: true }); // DoctorService | null — no throw
103+
inject("logger", { skipSelf: true }); // start at the parent: escapes a
104+
// child scope's shadowing entry
105+
inject("options", { self: true }); // this level only — no fallthrough
106+
```
107+
108+
`optional` covers not-found only; a found-but-misconfigured provider still
109+
throws. `self` and `skipSelf` cannot be combined. There is deliberately no
110+
`host` option: it is an Angular component-tree concept with no analog in the
111+
CLI's injector hierarchy.
112+
113+
Registering: providers
114+
----------------------
115+
116+
```ts
117+
import { provide, provideLazy, Injector } from "nativescript/contracts";
118+
119+
const injector = new Injector([
120+
// eager class binding; type-checked: the impl must satisfy the token
121+
provide(DoctorService, DoctorServiceImpl),
122+
123+
// deferred loading: the module is require()d on first resolution only
124+
provideLazy(DoctorService, () => require("./doctor-service").DoctorServiceImpl),
125+
126+
{ provide: Config, useValue: { DISABLE_HOOKS: false } },
127+
{ provide: Dispatcher, useFactory: () => createDispatcher(), shared: false },
128+
]);
129+
130+
// registration is also allowed after construction; re-registering a token
131+
// updates the existing record in place
132+
injector.register(provide(ProjectNameService, ProjectNameServiceImpl));
133+
```
134+
135+
Provider kinds:
136+
137+
| Kind | Shape | Notes |
138+
|---|---|---|
139+
| Class | `provide(Token, Impl)` / `{ provide, useClass }` | constructed with `new Impl()` inside an injection context, so `inject()` works in its fields |
140+
| Lazy class | `provideLazy(Token, () => Impl)` / `{ provide, useLazyClass }` | loader runs on first `get()` only — keeps startup lazy |
141+
| 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 |
142+
| Factory | `{ provide, useFactory }` | called inside an injection context |
143+
144+
`shared: false` makes a provider transient: every resolution constructs a fresh
145+
instance. Transient instances are still retained by the container so
146+
`dispose()` reaches them.
147+
148+
String keys are accepted anywhere a token is (`{ provide: "logger", useValue }`)
149+
— that is how the legacy facade registers, and how per-call overrides address
150+
not-yet-migrated dependencies. New registrations should mint a `@Contract`
151+
token instead of a new string name.
152+
153+
For per-call construction with overrides (a fresh instance of a class with some
154+
dependencies replaced), use `createInstance`:
155+
156+
```ts
157+
const debugService = injector.createInstance(IOSDeviceDebugService, [
158+
{ provide: "device", useValue: device },
159+
]);
160+
```
161+
162+
Overrides shadow **one level deep only** — the direct dependencies of the class
163+
being constructed. Nested dependencies are constructed by the injector that
164+
owns them and never see the per-call providers.
165+
166+
Resolution semantics
167+
--------------------
168+
169+
- Lookup is **class object first, token name on a miss**, checked per injector
170+
level before delegating to the parent. Both keys index the same provider
171+
record, so re-registering a service by its string name (as plugins are
172+
documented to do with `$logger`) stays visible to `inject(Logger)` consumers.
173+
- A leading `$` is stripped from string tokens: `get("$fs")` and `get("fs")`
174+
are the same registration.
175+
- The name fallback also makes **duplicated contract copies interchangeable**:
176+
if an extension's dependency tree carries its own copy of a contract class,
177+
that copy resolves to the same provider by name. "Works locally, breaks when
178+
installed" is not a failure mode of this design.
179+
- Cyclic dependencies fail with the full resolution path
180+
(`Cyclic dependency detected on dependency 'a'. Resolution path: a -> b -> a`).
181+
182+
Child scopes
183+
------------
184+
185+
`injector.createChild(providers)` creates a scope that shadows its parent for
186+
the given tokens and falls through for everything else. Sibling scopes are
187+
isolated. Scopes are how per-invocation data (hook payloads, per-call
188+
overrides) is layered over the shared singletons without ever entering the
189+
root container.
190+
191+
`forwardRef`
192+
------------
193+
194+
Provider arrays are evaluated at module load. When a token is declared later in
195+
the same file (TDZ) or reached through a circular import, wrap the reference in
196+
a thunk; it is read only when the injector processes the provider:
197+
198+
```ts
199+
import { forwardRef } from "nativescript/contracts";
200+
201+
const providers = [
202+
{ provide: forwardRef(() => DoctorService), useClass: DoctorServiceImpl },
203+
];
204+
```
205+
206+
`forwardRef` defers *references*, not construction — it cannot break an
207+
instantiation cycle between two services. For that, self-inject the `Injector`
208+
and resolve late (see above).
209+
210+
Working alongside the legacy `$injector`
211+
----------------------------------------
212+
213+
The `Yok` facade (`global.$injector`) IS an `Injector` — the class extends the
214+
token-based container — so the new API works on it directly:
215+
216+
```ts
217+
$injector.resolve("doctorService") === $injector.get(DoctorService); // true
218+
$injector.register(provide(DoctorService, DoctorServiceImpl));
219+
runInInjectionContext($injector, () => inject(DoctorService));
220+
```
221+
222+
- Legacy string names are permanent: a contract's token name is its interop
223+
identity, used by hooks, plugins, and the public API. Nothing is deleted
224+
per-service.
225+
- Every legacy member (`resolve`, `register`, `require*`, the command-registry
226+
surface) carries `@deprecated` JSDoc naming its replacement.
227+
- Legacy usage at the external entry points (param-name hooks, require-time
228+
extension registration, help templating) is reported through a deprecation
229+
tracer. It logs at trace level today; set `NS_DEPRECATIONS=warn` or
230+
`NS_DEPRECATIONS=error` to preview the stricter stages that later releases
231+
will default to.
232+
233+
For extension and hook authors
234+
------------------------------
235+
236+
Depend on `nativescript` itself (as a `peerDependency`, plus a `devDependency`
237+
for local development) and import from the `contracts` subpath:
238+
239+
```ts
240+
import { inject, DoctorService } from "nativescript/contracts";
241+
```
242+
243+
- The subpath resolves through a directory `package.json` — the CLI's
244+
`package.json` deliberately has **no `exports` map**, so any deep `require()`
245+
paths you already use keep working.
246+
- The entry point is side-effect-free: importing it never boots a CLI runtime,
247+
even from a duplicated copy in your dependency tree.
248+
- The existing `$injector`-based extension and hook mechanisms keep working
249+
unchanged; the typed API is additive.
250+
251+
Available contracts
252+
-------------------
253+
254+
The first tranche, growing as services migrate:
255+
256+
| Token | Legacy name |
257+
|---|---|
258+
| `DoctorService` | `doctorService` |
259+
| `ProjectNameService` | `projectNameService` |
260+
261+
Legacy → new quick reference
262+
----------------------------
263+
264+
`di` below is any `Injector` you hold — including `$injector` itself, which
265+
extends `Injector`.
266+
267+
| Legacy (`$injector`) | New |
268+
|---|---|
269+
| `resolve("name")` | `inject(Token)` in an injection context, or `di.get(Token)` |
270+
| `resolve(SomeClass)` / `resolve(SomeClass, { dep })` | `di.createInstance(SomeClass, [{ provide: "dep", useValue }])` |
271+
| `register("name", Impl)` | `di.register(provide(Token, Impl))` |
272+
| `register("name", instance)` | `di.register({ provide: Token, useValue: instance })` |
273+
| `register("name", Impl, false)` | `di.register({ provide: Token, useClass: Impl, shared: false })` |
274+
| `require("name", "./path")` | `provideLazy(Token, () => require("./path").Impl)` |
275+
| constructor param `$name` | `inject(Token)` field initializer |

extending-cli.md

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,29 @@ Execute Hooks In-Process
7777
7878
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.
7979
80-
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).
80+
The CLI assumes that this is a CommonJS module and calls its single exported function.
81+
82+
## Writing a hook
83+
84+
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.
85+
86+
```JavaScript
87+
const { inject, DoctorService } = require("nativescript/contracts");
88+
89+
module.exports = function (hookArgs) {
90+
const doctorService = inject(DoctorService);
91+
return doctorService.canExecuteLocalBuild();
92+
};
93+
```
94+
95+
* `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.
96+
* `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.
97+
* 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.
98+
* 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.
99+
* If you build your hook in TypeScript, add `nativescript` as a `devDependency` and import the same names: `import { inject, DoctorService } from "nativescript/contracts"`.
100+
101+
## The hook contract
81102

82-
Parameter | Type | Description
83-
---|---|---
84-
`$logger` | ILogger | Use the members of this class to show messages to the user cooperating with the CLI internal state.
85-
`$projectData` | IProjectData | Contains data about the project, such as project directory, ID, dependencies, etc.
86-
`$usbLiveSyncService` | ILiveSyncService | Use this variable to check whether a LiveSync or normal build is in progress.
87-
`hookArgs` | Any | Contains all the parameters of the original function in the CLI which is being hooked.
88-
89103
The hook must return a Promise. If the hook succeeds, it must fullfil the promise, but the fullfilment value is ignored.
90104
The hook can also reject the promise with an instance of Error. The returned error can have two optional members controlling the CLI.
91105

@@ -95,8 +109,19 @@ Member | Type | Description
95109
`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.
96110

97111
If these two members are not set, the CLI prints the returned error colored as fatal error and stops executing the current command.
98-
99-
Furthermore, the global variable `$injector` of type `IInjector` provides access to the CLI Dependency Injector, through which all code services are available.
112+
113+
## Legacy: parameter-name injection
114+
115+
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`).
116+
117+
Parameter | Type | Description
118+
---|---|---
119+
`$logger` | ILogger | Use the members of this class to show messages to the user cooperating with the CLI internal state.
120+
`$projectData` | IProjectData | Contains data about the project, such as project directory, ID, dependencies, etc.
121+
`$usbLiveSyncService` | ILiveSyncService | Use this variable to check whether a LiveSync or normal build is in progress.
122+
`hookArgs` | Any | Contains all the parameters of the original function in the CLI which is being hooked.
123+
124+
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.
100125

101126
Commands with Hooking Support
102127
==============================
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Contract } from "../di/contract";
2+
import type { ICommand } from "../definitions/commands";
3+
4+
/**
5+
* The command-registry face of the injector facade. Transitional contract: it
6+
* mirrors what consumers call today, so that extracting the registry from the
7+
* facade later is a provider swap for this token, not a consumer migration.
8+
* Members slated for replacement keep their deprecation markers.
9+
*/
10+
@Contract({ name: "commandRegistry" })
11+
export abstract class CommandRegistry {
12+
/**
13+
* @deprecated Path-based command registration; slated for replacement by
14+
* manifest-declared commands.
15+
*/
16+
abstract requireCommand(names: string | string[], file: string): void;
17+
abstract registerCommand(names: string | string[], resolver: any): void;
18+
abstract resolveCommand(name: string): ICommand;
19+
abstract getRegisteredCommandsNames(includeDev: boolean): string[];
20+
abstract getChildrenCommandsNames(commandName: string): string[];
21+
abstract buildHierarchicalCommand(
22+
parentCommandName: string,
23+
commandLineArguments: string[],
24+
): any;
25+
/** Side-effecting: fails with help output on a bad subcommand. */
26+
abstract isValidHierarchicalCommand(
27+
commandName: string,
28+
commandArguments: string[],
29+
): Promise<boolean>;
30+
abstract isDefaultCommand(commandName: string): boolean;
31+
}

lib/common/contracts/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Internal subsystem contracts of the injector facade. Deliberately NOT
2+
// re-exported from nativescript/contracts: promoting one to the public
3+
// surface is a one-line decision that should be made per contract, not by
4+
// default. Each token resolves to the facade itself until its subsystem is
5+
// physically extracted — at which point the provider is swapped and consumers
6+
// keep working unchanged.
7+
export { CommandRegistry } from "./command-registry";
8+
export { KeyCommandRegistry } from "./key-command-registry";
9+
export { ModuleRegistry } from "./module-registry";
10+
export { PublicApiBuilder } from "./public-api-builder";
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Contract } from "../di/contract";
2+
import type { IKeyCommand, IValidKeyName } from "../definitions/key-commands";
3+
4+
/**
5+
* The key-command face of the injector facade (the `keyCommands.` namespace).
6+
* Kept separate from CommandRegistry because the two registries are redesigned
7+
* on different tracks.
8+
*/
9+
@Contract({ name: "keyCommandRegistry" })
10+
export abstract class KeyCommandRegistry {
11+
abstract requireKeyCommand(name: IValidKeyName, file: string): void;
12+
abstract registerKeyCommand(name: IValidKeyName, resolver: any): void;
13+
abstract resolveKeyCommand(name: string): IKeyCommand;
14+
abstract getRegisteredKeyCommandsNames(): string[];
15+
}

0 commit comments

Comments
 (0)