Skip to content

Commit 7ff482a

Browse files
committed
docs: document the token-based injection API
dependency-injection.md covers tokens (@contract), inject()/Injector, provider kinds, resolution semantics, forwardRef, coexistence with the legacy $injector, and a legacy-to-new quick reference. extending-cli.md leads with the recommended hook pattern - inject() works directly in hook bodies because they run in an injection context, pinned by a new compat test - and demotes parameter-name injection to a labeled legacy section. String-token lookups are framed as the migration bridge, not a co-equal API. The hook deprecation tracer now flags only hooks that actually use param-name service injection; a hookArgs-only signature follows the recommended pattern and stays silent.
1 parent 3f4ab6f commit 7ff482a

4 files changed

Lines changed: 332 additions & 15 deletions

File tree

dependency-injection.md

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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+
Registering: providers
99+
----------------------
100+
101+
```ts
102+
import { provide, provideLazy, Injector } from "nativescript/contracts";
103+
104+
const injector = new Injector([
105+
// eager class binding; type-checked: the impl must satisfy the token
106+
provide(DoctorService, DoctorServiceImpl),
107+
108+
// deferred loading: the module is require()d on first resolution only
109+
provideLazy(DoctorService, () => require("./doctor-service").DoctorServiceImpl),
110+
111+
{ provide: Config, useValue: { DISABLE_HOOKS: false } },
112+
{ provide: Dispatcher, useFactory: () => createDispatcher(), shared: false },
113+
]);
114+
115+
// registration is also allowed after construction; re-registering a token
116+
// updates the existing record in place
117+
injector.register(provide(ProjectNameService, ProjectNameServiceImpl));
118+
```
119+
120+
Provider kinds:
121+
122+
| Kind | Shape | Notes |
123+
|---|---|---|
124+
| Class | `provide(Token, Impl)` / `{ provide, useClass }` | constructed with `new Impl()` inside an injection context, so `inject()` works in its fields |
125+
| Lazy class | `provideLazy(Token, () => Impl)` / `{ provide, useLazyClass }` | loader runs on first `get()` only — keeps startup lazy |
126+
| Value | `{ provide, useValue }` | registered instance; re-registering replaces the cached instance |
127+
| Factory | `{ provide, useFactory }` | called inside an injection context |
128+
129+
`shared: false` makes a provider transient: every resolution constructs a fresh
130+
instance. Transient instances are still retained by the container so
131+
`dispose()` reaches them.
132+
133+
String keys are accepted anywhere a token is (`{ provide: "logger", useValue }`)
134+
— that is how the legacy facade registers, and how per-call overrides address
135+
not-yet-migrated dependencies. New registrations should mint a `@Contract`
136+
token instead of a new string name.
137+
138+
For per-call construction with overrides (a fresh instance of a class with some
139+
dependencies replaced), use `createInstance`:
140+
141+
```ts
142+
const debugService = injector.createInstance(IOSDeviceDebugService, [
143+
{ provide: "device", useValue: device },
144+
]);
145+
```
146+
147+
Overrides shadow **one level deep only** — the direct dependencies of the class
148+
being constructed. Nested dependencies are constructed by the injector that
149+
owns them and never see the per-call providers.
150+
151+
Resolution semantics
152+
--------------------
153+
154+
- Lookup is **class object first, token name on a miss**, checked per injector
155+
level before delegating to the parent. Both keys index the same provider
156+
record, so re-registering a service by its string name (as plugins are
157+
documented to do with `$logger`) stays visible to `inject(Logger)` consumers.
158+
- A leading `$` is stripped from string tokens: `get("$fs")` and `get("fs")`
159+
are the same registration.
160+
- The name fallback also makes **duplicated contract copies interchangeable**:
161+
if an extension's dependency tree carries its own copy of a contract class,
162+
that copy resolves to the same provider by name. "Works locally, breaks when
163+
installed" is not a failure mode of this design.
164+
- Cyclic dependencies fail with the full resolution path
165+
(`Cyclic dependency detected on dependency 'a'. Resolution path: a -> b -> a`).
166+
167+
Child scopes
168+
------------
169+
170+
`injector.createChild(providers)` creates a scope that shadows its parent for
171+
the given tokens and falls through for everything else. Sibling scopes are
172+
isolated. Scopes are how per-invocation data (hook payloads, per-call
173+
overrides) is layered over the shared singletons without ever entering the
174+
root container.
175+
176+
`forwardRef`
177+
------------
178+
179+
Provider arrays are evaluated at module load. When a token is declared later in
180+
the same file (TDZ) or reached through a circular import, wrap the reference in
181+
a thunk; it is read only when the injector processes the provider:
182+
183+
```ts
184+
import { forwardRef } from "nativescript/contracts";
185+
186+
const providers = [
187+
{ provide: forwardRef(() => DoctorService), useClass: DoctorServiceImpl },
188+
];
189+
```
190+
191+
`forwardRef` defers *references*, not construction — it cannot break an
192+
instantiation cycle between two services. For that, self-inject the `Injector`
193+
and resolve late (see above).
194+
195+
Working alongside the legacy `$injector`
196+
----------------------------------------
197+
198+
The `Yok` facade (`global.$injector`) delegates to the token-based container,
199+
exposed as `$injector.di`. Everything is one registry:
200+
201+
```ts
202+
$injector.resolve("doctorService") === $injector.di.get(DoctorService); // true
203+
```
204+
205+
- Legacy string names are permanent: a contract's token name is its interop
206+
identity, used by hooks, plugins, and the public API. Nothing is deleted
207+
per-service.
208+
- Every legacy member (`resolve`, `register`, `require*`, the command-registry
209+
surface) carries `@deprecated` JSDoc naming its replacement.
210+
- Legacy usage at the external entry points (param-name hooks, require-time
211+
extension registration, help templating) is reported through a deprecation
212+
tracer. It logs at trace level today; set `NS_DEPRECATIONS=warn` or
213+
`NS_DEPRECATIONS=error` to preview the stricter stages that later releases
214+
will default to.
215+
216+
For extension and hook authors
217+
------------------------------
218+
219+
Depend on `nativescript` itself (as a `peerDependency`, plus a `devDependency`
220+
for local development) and import from the `contracts` subpath:
221+
222+
```ts
223+
import { inject, DoctorService } from "nativescript/contracts";
224+
```
225+
226+
- The subpath resolves through a directory `package.json` — the CLI's
227+
`package.json` deliberately has **no `exports` map**, so any deep `require()`
228+
paths you already use keep working.
229+
- The entry point is side-effect-free: importing it never boots a CLI runtime,
230+
even from a duplicated copy in your dependency tree.
231+
- The existing `$injector`-based extension and hook mechanisms keep working
232+
unchanged; the typed API is additive.
233+
234+
Available contracts
235+
-------------------
236+
237+
The first tranche, growing as services migrate:
238+
239+
| Token | Legacy name |
240+
|---|---|
241+
| `DoctorService` | `doctorService` |
242+
| `ProjectNameService` | `projectNameService` |
243+
244+
Legacy → new quick reference
245+
----------------------------
246+
247+
`di` below is the token-based container backing the facade (`$injector.di`),
248+
or any `Injector` you hold directly.
249+
250+
| Legacy (`$injector`) | New |
251+
|---|---|
252+
| `resolve("name")` | `inject(Token)` in an injection context, or `di.get(Token)` |
253+
| `resolve(SomeClass)` / `resolve(SomeClass, { dep })` | `di.createInstance(SomeClass, [{ provide: "dep", useValue }])` |
254+
| `register("name", Impl)` | `di.register(provide(Token, Impl))` |
255+
| `register("name", instance)` | `di.register({ provide: Token, useValue: instance })` |
256+
| `register("name", Impl, false)` | `di.register({ provide: Token, useClass: Impl, shared: false })` |
257+
| `require("name", "./path")` | `provideLazy(Token, () => require("./path").Impl)` |
258+
| 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)`, 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
==============================

lib/common/services/hooks-service.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -246,11 +246,19 @@ export class HooksService implements IHooksService {
246246
projectDataHookArg;
247247
}
248248

249-
reportDeprecation({
250-
api: "hooks.param-name-signature",
251-
detail: hook.fullPath,
252-
logger: this.$logger,
253-
});
249+
// Only param-name *service* injection is on the deprecation track; a
250+
// hook declaring nothing but `hookArgs` (or no parameters) already
251+
// follows the recommended pattern and must not be flagged.
252+
const usesParamNameInjection = (<string[]>(
253+
hookEntryPoint.$inject.args
254+
)).some((argument) => argument !== this.hookArgsName);
255+
if (usesParamNameInjection) {
256+
reportDeprecation({
257+
api: "hooks.param-name-signature",
258+
detail: hook.fullPath,
259+
logger: this.$logger,
260+
});
261+
}
254262

255263
const maybePromise = this.$injector.resolve(
256264
hookEntryPoint,

test/compat/legacy-hooks.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ describe("legacy hook contract", () => {
138138
});
139139

140140
assert.deepEqual(args, ["assembleDebug", "--offline"]);
141+
// A hookArgs-only signature is the recommended pattern and must not be
142+
// reported as param-name injection.
143+
assert.notInclude(logger().traceOutput, "hooks.param-name-signature");
141144
});
142145

143146
it("treats a rejection carrying stopExecution + errorAsWarning as a warning, not a failure", async () => {
@@ -261,6 +264,29 @@ describe("legacy hook contract", () => {
261264
assert.isUndefined(capture.originalRan);
262265
});
263266

267+
it("runs hook bodies in an injection context, so inject() resolves services", async () => {
268+
const diPath = require.resolve("../../lib/common/di");
269+
writeHook(
270+
projectDir,
271+
"before-case-inject",
272+
`const { inject, Injector } = require(${JSON.stringify(diPath)});
273+
module.exports = function (hookArgs) {
274+
global.__hookCapture.logger = inject("logger");
275+
global.__hookCapture.container = inject(Injector);
276+
global.__hookCapture.hookArgs = hookArgs;
277+
};`,
278+
);
279+
280+
const payload = { sample: true };
281+
await hooksService().executeBeforeHooks("case-inject", {
282+
hookArgs: payload,
283+
});
284+
285+
assert.strictEqual(capture.logger, testInjector.resolve("logger"));
286+
assert.strictEqual(capture.container, (<any>testInjector).di);
287+
assert.strictEqual(capture.hookArgs, payload);
288+
});
289+
264290
it("@hook falls back to the global injector when the class has neither $hooksService nor $injector", async () => {
265291
writeHook(
266292
projectDir,

0 commit comments

Comments
 (0)