Skip to content

Commit 9a43a9e

Browse files
committed
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.
1 parent 55a507e commit 9a43a9e

5 files changed

Lines changed: 618 additions & 69 deletions

File tree

extending-cli.md

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,58 @@ 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.
80+
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.
8181
8282
## Writing a hook
8383
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.
84+
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.
85+
86+
```JavaScript
87+
const { defineHook, inject, DoctorService } = require("nativescript/contracts");
88+
89+
module.exports = defineHook("before-prepare", async (ctx) => {
90+
const doctorService = inject(DoctorService);
91+
await doctorService.canExecuteLocalBuild();
92+
});
93+
```
94+
95+
Services come from `inject()` — the same API used everywhere else (see [dependency-injection.md](dependency-injection.md)):
96+
97+
* `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)`, then `injector.get(...)` later.
98+
* 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.
99+
* 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.
100+
* 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(...)`.
101+
102+
`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:
103+
104+
```JavaScript
105+
module.exports = defineHook("before-build-task-args", (ctx) => {
106+
ctx.payload.args.push("--offline");
107+
});
108+
```
109+
110+
`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.
111+
112+
```JavaScript
113+
module.exports = defineHook("before-prepare", (ctx) => {
114+
ctx.wrap(async (args, next) => {
115+
const result = await next(...args);
116+
return result;
117+
});
118+
});
119+
```
120+
121+
`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.
122+
123+
```JavaScript
124+
module.exports = defineHook("before-prepare", (ctx) => {
125+
ctx.abort("Nothing to prepare.", { asWarning: true });
126+
});
127+
```
128+
129+
### Plain function hooks
130+
131+
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.
85132

86133
```JavaScript
87134
const { inject, DoctorService } = require("nativescript/contracts");
@@ -92,12 +139,6 @@ module.exports = function (hookArgs) {
92139
};
93140
```
94141

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-
101142
## The hook contract
102143

103144
The hook must return a Promise. If the hook succeeds, it must fullfil the promise, but the fullfilment value is ignored.
@@ -110,6 +151,10 @@ Member | Type | Description
110151

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

154+
A plain-function hook can also return a function, which the CLI folds into a middleware chain around the hooked method.
155+
156+
With `defineHook` neither convention is needed: `ctx.abort` replaces throwing an error carrying `stopExecution`/`errorAsWarning`, and `ctx.wrap` replaces returning a function.
157+
113158
## Legacy: parameter-name injection
114159

115160
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`).

lib/common/define-hook.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* The typed hook-authoring API. Kept import-free so that a hook (or an
3+
* extension carrying its own copy of the CLI) can load it without booting a
4+
* second runtime — importing lib/common/yok creates global.$injector.
5+
*/
6+
7+
/**
8+
* `Symbol.for` rather than a module-local symbol: an extension may resolve a
9+
* duplicated copy of the CLI from its own node_modules, and the running CLI
10+
* still has to recognize definitions minted by that copy.
11+
*/
12+
export const HOOK_DEFINITION_MARKER = Symbol.for(
13+
"nativescript:cli:hookDefinition",
14+
);
15+
16+
/**
17+
* Wraps the method the hook point decorates. `next` continues the chain — call
18+
* it with `args` to run the original, or skip it to short-circuit.
19+
*/
20+
export type HookMiddleware = (
21+
args: any[],
22+
next: (...args: any[]) => any,
23+
) => any;
24+
25+
export interface IHookContext {
26+
/**
27+
* The payload of the operation being hooked. Its shape depends on the hook
28+
* point, and it is the caller's own object: mutating it is a supported
29+
* channel for influencing the operation.
30+
*/
31+
payload: any;
32+
33+
/** Registers a middleware around the method this hook point decorates. */
34+
wrap(middleware: HookMiddleware): void;
35+
36+
/**
37+
* Stops the hook. With `asWarning`, the CLI logs the message and continues
38+
* the command; otherwise the command fails.
39+
*/
40+
abort(message: string, opts?: { asWarning?: boolean }): never;
41+
}
42+
43+
export type HookHandler = (ctx: IHookContext) => void | Promise<void>;
44+
45+
export interface IHookDefinition {
46+
/** Hook point, in the hyphen convention: `before-prepare`, `after-watch`. */
47+
readonly name: string;
48+
readonly handler: HookHandler;
49+
}
50+
51+
export interface IHookInvocation {
52+
context: IHookContext;
53+
/** Populated by `ctx.wrap()` while the handler runs. */
54+
middlewares: HookMiddleware[];
55+
}
56+
57+
export function defineHook(
58+
name: string,
59+
handler: HookHandler,
60+
): IHookDefinition {
61+
const definition: IHookDefinition = { name, handler };
62+
Object.defineProperty(definition, HOOK_DEFINITION_MARKER, { value: true });
63+
return definition;
64+
}
65+
66+
export function isHookDefinition(value: any): boolean {
67+
return (
68+
!!value &&
69+
(typeof value === "object" || typeof value === "function") &&
70+
value[HOOK_DEFINITION_MARKER] === true &&
71+
typeof value.handler === "function"
72+
);
73+
}
74+
75+
/**
76+
* Derives the context from the raw hook argument bag: the `hookArgs` wrapper
77+
* when the hook point supplies one, the bag itself for hook points that pass
78+
* their keys at the top level, and nothing when there is no payload.
79+
*/
80+
export function createHookInvocation(hookArguments: any): IHookInvocation {
81+
const middlewares: HookMiddleware[] = [];
82+
const context: IHookContext = {
83+
payload: derivePayload(hookArguments),
84+
wrap(middleware: HookMiddleware): void {
85+
middlewares.push(middleware);
86+
},
87+
abort(message: string, opts?: { asWarning?: boolean }): never {
88+
const error: any = new Error(message);
89+
if (opts && opts.asWarning) {
90+
// The pair the hooks service checks for to downgrade a rejection.
91+
error.stopExecution = false;
92+
error.errorAsWarning = true;
93+
}
94+
throw error;
95+
},
96+
};
97+
98+
return { context, middlewares };
99+
}
100+
101+
function derivePayload(hookArguments: any): any {
102+
if (!hookArguments || typeof hookArguments !== "object") {
103+
return undefined;
104+
}
105+
106+
if ("hookArgs" in hookArguments) {
107+
return hookArguments["hookArgs"];
108+
}
109+
110+
return Object.keys(hookArguments).length ? hookArguments : undefined;
111+
}

0 commit comments

Comments
 (0)