Skip to content

Commit ef68d67

Browse files
authored
feat(hooks): defineHook - typed hook authoring with ctx payload/wrap/fail/skip (#6100)
* feat(hooks): add defineHook with typed ctx (wrap/abort/payload) Hook authors can now export a definition built with defineHook instead of a plain function whose shape the CLI has to infer. The handler takes a context object with the operation payload, an explicit wrap() for middleware around the hooked method, and abort() for stopping the hook as either a failure or a warning. Definitions are marked with Symbol.for("nativescript:cli:hookDefinition") so a duplicated CLI copy in an extension's dependency tree still recognizes them. The definition path skips parameter-name resolution, the projectData promotion hack and the deprecation report; plain function hooks keep running through the existing path unchanged. lib/common/define-hook.ts is import-free so a hook can load it without booting a second runtime, and it is re-exported from nativescript/contracts. * refactor(hooks): the facade is the injection context Yok extends Injector on the base branch, so definitions run in runInInjectionContext(this.$injector, ...) directly and inject(Injector) inside a hook returns the facade itself. * fix(hooks): make the defineHook surface validate and refuse silent no-ops Drops the `I` prefix from the new hook types, makes `run` the handler field with `defineHook({ name, run })` canonical and the positional call kept as sugar, and validates the definition at define time: a missing or non-string name, a non-function run, and unknown fields all throw naming the definition and both accepted forms. The definition marker moves from a non-enumerable defineProperty to a plain assignment so a spread-derived definition stays recognizable, and `isHookDefinition` becomes a type predicate. Behaviors that used to fail quietly now say so: - `ctx.wrap()` only ever ran at the `@hook`-decorated before-points and was dropped everywhere else. Call sites now declare whether they consume middlewares, and `wrap()` throws elsewhere instead. - a definition whose name disagrees with its hook point is skipped with a warning rather than run at a point it was not written for. - `ctx.abort()` with no message produced `Error(undefined)`; it now falls back to a message naming the hook point. - a definition whose run returns a function warns, since the legacy returned-middleware convention does not apply to definitions. - an array export is rejected naming the file, reserving the form for a possible multi-definition module later. `defineHook<TPayload>` / `HookContext<TPayload>` type the payload, as `TPayload | undefined` because dispatch-fired hooks carry none, and executeBeforeHooks is typed with the middleware array it already returns. * docs(hooks): lead with the reworked defineHook API and correct the contract Documents the bag form, define-time validation, the strict name match and the one-definition-per-file rule; splits ctx into payload/wrap/abort sections; states that dispatch-fired hook points carry no payload and lists the hook points where wrap() is honored. Corrects two long-standing errors: a hook named plainly `watch` never fires (the points are `before-watch`/`after-watch`), and downgrading a rejection to a warning needs `errorAsWarning === true` together with a Boolean `stopExecution`, not `stopExecution: false` alone. * refactor(hooks): split ctx.abort into ctx.fail and ctx.skip `abort(message, { asWarning: true })` did not abort anything — it warned and the command carried on, so one verb meant opposite things depending on a flag. The two outcomes now have a verb each: `ctx.fail(message)` fails the command, `ctx.skip(message)` warns and lets it continue. Both still stop the handler by throwing, so both are typed `never` and the "handler ends here" behavior is unchanged; only the command's fate differs. A missing message falls back to one naming the hook point and the method. `abort` and the `asWarning` option are removed outright rather than shimmed, as the API is unreleased and never exposed either name.
1 parent f5e4440 commit ef68d67

9 files changed

Lines changed: 1088 additions & 114 deletions

File tree

extending-cli.md

Lines changed: 113 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ For the NativeScript CLI to execute your hooks, you must place them in the `hook
1111

1212
You can attach the hook before or after `prepare` operations or to `--watch` operations.
1313

14-
Note that `watch` hooks can be executed only at the time of running `--watch` operations. The `watch` hooks are the last thing executed before launching the file system watcher which tracks for changes to your code.
14+
Note that `watch` hooks can be executed only at the time of running `--watch` operations. The `before-watch` hooks are the last thing executed before launching the file system watcher which tracks for changes to your code.
1515

1616
Your hooks must conform to the following naming and placement conventions:
1717

@@ -36,27 +36,29 @@ Your hooks must conform to the following naming and placement conventions:
3636
├── hook1 (this is an executable file)
3737
└── hook2 (this is an executable file)
3838
```
39-
* If you want to attach a hook for `--watch` operations, you must place the hook in the root of the `hooks` subdirectory. The file must be named `watch`. For example:
39+
* If you want to attach a hook for `--watch` operations, you must place the hook in the root of the `hooks` subdirectory. The file must be named `before-watch` or `after-watch`. For example:
4040
4141
```
4242
my-app/
4343
├── index.js
4444
├── package.json
4545
└── hooks/
46-
└── watch.js (this is a Node.js script)
46+
└── before-watch.js (this is a Node.js script)
4747
```
48-
* If you want to attach multiple hooks for `--watch` operations, you must place them inside a `watch` subdirectory of the `hooks` subdirectory. You can specify any meaningful name for the the hooks inside the subdirectory. For example:
48+
* If you want to attach multiple hooks for `--watch` operations, you must place them inside a `before-watch` or `after-watch` subdirectory of the `hooks` subdirectory. You can specify any meaningful name for the the hooks inside the subdirectory. For example:
4949
5050
```
5151
my-app/
5252
├── index.js
5353
├── package.json
5454
└── hooks/
55-
└── watch (a directory)
55+
└── before-watch (a directory)
5656
├── hook1 (this is an executable file)
5757
└── hook2 (this is an executable file)
5858
```
5959
60+
A file named plainly `watch` is never executed: like every other hook point, the watch hooks are addressed by the `before-`/`after-` names above.
61+
6062
> **NOTE:** When multiple hooks are attached to a single event (i.e. multiple hooks are stored in dedicated subdirectories), at the specified time, the CLI executes each hook one by one. However, the order of hook execution is not strict and might change over command executions.
6163
6264
Execute Hooks as Child Process
@@ -77,11 +79,105 @@ Execute Hooks In-Process
7779
7880
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.
7981
80-
The CLI assumes that this is a CommonJS module and calls its single exported function.
82+
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.
8183
8284
## Writing a hook
8385
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.
86+
Export a hook definition built with `defineHook`. It takes the hook point in the usual naming convention (`before-prepare`, `after-watch`) and a `run` handler that receives a context object.
87+
88+
```JavaScript
89+
const { defineHook, inject, DoctorService } = require("nativescript/contracts");
90+
91+
module.exports = defineHook({
92+
name: "before-prepare",
93+
run: async (ctx) => {
94+
const doctorService = inject(DoctorService);
95+
await doctorService.canExecuteLocalBuild();
96+
},
97+
});
98+
```
99+
100+
`defineHook(name, run)` is shorthand for the same definition:
101+
102+
```JavaScript
103+
module.exports = defineHook("before-prepare", async (ctx) => { /* ... */ });
104+
```
105+
106+
`defineHook` validates its input immediately: a missing or non-string `name`, a missing or non-function `run`, and unknown fields all throw at definition time, naming the definition and both accepted forms.
107+
108+
The `name` decides when the hook fires and must match the hook point the file is placed at. A definition whose `name` disagrees with its location is **skipped with a warning** rather than run at the wrong point. Export exactly one definition (or one plain function) per file — an array export is rejected.
109+
110+
Services come from `inject()` — the same API used everywhere else (see [dependency-injection.md](dependency-injection.md)):
111+
112+
* `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.
113+
* 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.
114+
* 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.
115+
* 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(...)`.
116+
117+
### `ctx.payload`
118+
119+
`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:
120+
121+
```JavaScript
122+
module.exports = defineHook("before-build-task-args", (ctx) => {
123+
ctx.payload.args.push("--offline");
124+
});
125+
```
126+
127+
Not every invocation carries one. The `before-<command>`/`after-<command>` hooks fired around command dispatch (`before-build`, `after-run`, …) pass no arguments at all, so `ctx.payload` is `undefined` there. Treat it as optional — in TypeScript it is typed `TPayload | undefined`:
128+
129+
```TypeScript
130+
import { defineHook } from "nativescript/contracts";
131+
132+
export default defineHook<{ args: string[] }>("before-build-task-args", (ctx) => {
133+
ctx.payload?.args.push("--offline");
134+
});
135+
```
136+
137+
### `ctx.wrap(middleware)`
138+
139+
`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.
140+
141+
```JavaScript
142+
module.exports = defineHook("before-prepare", (ctx) => {
143+
ctx.wrap(async (args, next) => {
144+
const result = await next(...args);
145+
return result;
146+
});
147+
});
148+
```
149+
150+
Only a hook point that actually folds middlewares around a method can honor `wrap()`, so it is available **only in the before-phase of the wrappable hook points** listed below. Calling it anywhere else — from any `after-` hook, or from a before-hook at a non-wrappable point — throws an error naming the hook point instead of registering a middleware that would never run.
151+
152+
The wrappable hook points are:
153+
154+
`before-buildAndroid` · `before-buildAndroidPlugin` · `before-buildIOS` · `before-checkEnvironment` · `before-checkForChanges` · `before-install` · `before-prepare` · `before-prepareNativeApp` · `before-resolveCommand` · `before-watch` · `before-watchPatterns`
155+
156+
### `ctx.fail(message)` and `ctx.skip(message)`
157+
158+
Both end the handler immediately — nothing after the call runs — and differ in what happens to the command.
159+
160+
`ctx.fail(message)` fails the command, printing `message` as the error:
161+
162+
```JavaScript
163+
module.exports = defineHook("before-prepare", (ctx) => {
164+
ctx.fail("The generated bundle is missing; run the bundler first.");
165+
});
166+
```
167+
168+
`ctx.skip(message)` prints `message` as a warning and lets the command continue:
169+
170+
```JavaScript
171+
module.exports = defineHook("before-prepare", (ctx) => {
172+
ctx.skip("Nothing to prepare.");
173+
});
174+
```
175+
176+
The message is required in practice — calling either without one falls back to a message naming the hook point and the method.
177+
178+
### Plain function hooks
179+
180+
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.
85181

86182
```JavaScript
87183
const { inject, DoctorService } = require("nativescript/contracts");
@@ -92,23 +188,21 @@ module.exports = function (hookArgs) {
92188
};
93189
```
94190

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-
101191
## The hook contract
102192

103193
The hook must return a Promise. If the hook succeeds, it must fullfil the promise, but the fullfilment value is ignored.
104-
The hook can also reject the promise with an instance of Error. The returned error can have two optional members controlling the CLI.
105-
194+
The hook can also reject the promise with an instance of Error. The returned error can carry two members that together downgrade the rejection to a warning.
195+
106196
Member | Type | Description
107197
---|---|---
108-
`stopExecution` | Boolean | Set this to `false` to let the CLI continue executing this command.
109-
`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.
110-
111-
If these two members are not set, the CLI prints the returned error colored as fatal error and stops executing the current command.
198+
`errorAsWarning` | Boolean | Must be exactly `true`. The CLI prints the error.message colored as a warning and continues executing the current command.
199+
`stopExecution` | Boolean | Must be present and of type Boolean. It only enables the check — setting it alone, with either value, changes nothing.
200+
201+
**Both** members are required: the CLI continues only when `errorAsWarning === true` *and* `stopExecution` is a Boolean. Otherwise it prints the returned error colored as a fatal error and stops executing the current command.
202+
203+
A plain-function hook can also return a function, which the CLI folds into a middleware chain around the hooked method.
204+
205+
With `defineHook` neither convention is needed, and neither applies: `ctx.fail`/`ctx.skip` replace throwing an error carrying `stopExecution`/`errorAsWarning`, and `ctx.wrap` replaces returning a function. A definition whose `run` returns a function is warned about — the returned function is not used as a middleware.
112206

113207
## Legacy: parameter-name injection
114208

lib/common/declarations.d.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -824,12 +824,23 @@ interface IAutoCompletionService {
824824
isObsoleteAutoCompletionEnabled(): boolean;
825825
}
826826

827+
interface IHookExecutionOptions {
828+
/**
829+
* Set by call sites that fold the returned middlewares around a method (the
830+
* `@hook` decorator). Where nothing consumes them, `ctx.wrap()` rejects
831+
* instead of registering a middleware that would never run.
832+
*/
833+
consumesMiddlewares?: boolean;
834+
}
835+
827836
interface IHooksService {
828837
hookArgsName: string;
838+
/** Resolves with the middlewares hooks registered through `ctx.wrap()`. */
829839
executeBeforeHooks(
830840
commandName: string,
831841
hookArguments?: IDictionary<any>,
832-
): Promise<void>;
842+
options?: IHookExecutionOptions,
843+
): Promise<import("./define-hook").HookMiddleware[]>;
833844
executeAfterHooks(
834845
commandName: string,
835846
hookArguments?: IDictionary<any>,

0 commit comments

Comments
 (0)