Skip to content

Commit 271fd8f

Browse files
edusperoniNathanWalker
authored andcommitted
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.
1 parent 0e9d1b6 commit 271fd8f

8 files changed

Lines changed: 399 additions & 63 deletions

File tree

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>,

lib/common/define-hook.ts

Lines changed: 148 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
* `Symbol.for` rather than a module-local symbol: an extension may resolve a
99
* duplicated copy of the CLI from its own node_modules, and the running CLI
1010
* still has to recognize definitions minted by that copy.
11+
*
12+
* Assigned as a plain enumerable property so that `{ ...definition }` keeps the
13+
* marker; symbols stay invisible to Object.keys/for..in/JSON either way.
1114
*/
1215
export const HOOK_DEFINITION_MARKER = Symbol.for(
1316
"nativescript:cli:hookDefinition",
@@ -22,15 +25,20 @@ export type HookMiddleware = (
2225
next: (...args: any[]) => any,
2326
) => any;
2427

25-
export interface IHookContext {
28+
export interface HookContext<TPayload = any> {
2629
/**
2730
* The payload of the operation being hooked. Its shape depends on the hook
2831
* point, and it is the caller's own object: mutating it is a supported
29-
* channel for influencing the operation.
32+
* channel for influencing the operation. Hook points fired by command
33+
* dispatch carry no payload at all, hence `undefined`.
3034
*/
31-
payload: any;
35+
payload: TPayload | undefined;
3236

33-
/** Registers a middleware around the method this hook point decorates. */
37+
/**
38+
* Registers a middleware around the method this hook point decorates.
39+
* Available only to before-hooks of the hook points that fold middlewares
40+
* around a method; elsewhere it throws rather than dropping the middleware.
41+
*/
3442
wrap(middleware: HookMiddleware): void;
3543

3644
/**
@@ -40,52 +48,173 @@ export interface IHookContext {
4048
abort(message: string, opts?: { asWarning?: boolean }): never;
4149
}
4250

43-
export type HookHandler = (ctx: IHookContext) => void | Promise<void>;
51+
export type HookHandler<TPayload = any> = (
52+
ctx: HookContext<TPayload>,
53+
) => void | Promise<void>;
54+
55+
/** The object bag accepted by `defineHook`. */
56+
export interface HookDefinitionInput<TPayload = any> {
57+
/** Hook point, in the hyphen convention: `before-prepare`, `after-watch`. */
58+
name: string;
59+
run: HookHandler<TPayload>;
60+
}
4461

45-
export interface IHookDefinition {
62+
export interface HookDefinition<TPayload = any> {
4663
/** Hook point, in the hyphen convention: `before-prepare`, `after-watch`. */
4764
readonly name: string;
48-
readonly handler: HookHandler;
65+
readonly run: HookHandler<TPayload>;
4966
}
5067

51-
export interface IHookInvocation {
52-
context: IHookContext;
68+
export interface HookInvocation<TPayload = any> {
69+
context: HookContext<TPayload>;
5370
/** Populated by `ctx.wrap()` while the handler runs. */
5471
middlewares: HookMiddleware[];
5572
}
5673

57-
export function defineHook(
74+
const DEFINITION_FIELDS = ["name", "run"];
75+
76+
const ACCEPTED_FORMS =
77+
'defineHook({ name: "before-prepare", run: (ctx) => {} }) or ' +
78+
'defineHook("before-prepare", (ctx) => {})';
79+
80+
function describeDefinition(name: any): string {
81+
return typeof name === "string" && name.length
82+
? JSON.stringify(name)
83+
: "<unnamed>";
84+
}
85+
86+
function failToDefine(message: string): never {
87+
throw new Error(`${message} Accepted forms: ${ACCEPTED_FORMS}.`);
88+
}
89+
90+
export function defineHook<TPayload = any>(
91+
definition: HookDefinitionInput<TPayload>,
92+
): HookDefinition<TPayload>;
93+
export function defineHook<TPayload = any>(
5894
name: string,
59-
handler: HookHandler,
60-
): IHookDefinition {
61-
const definition: IHookDefinition = { name, handler };
62-
Object.defineProperty(definition, HOOK_DEFINITION_MARKER, { value: true });
95+
run: HookHandler<TPayload>,
96+
): HookDefinition<TPayload>;
97+
export function defineHook<TPayload = any>(
98+
nameOrDefinition: string | HookDefinitionInput<TPayload>,
99+
run?: HookHandler<TPayload>,
100+
): HookDefinition<TPayload> {
101+
const input = normalizeDefinitionInput(nameOrDefinition, run);
102+
const definition: any = { name: input.name, run: input.run };
103+
definition[HOOK_DEFINITION_MARKER] = true;
104+
63105
return definition;
64106
}
65107

66-
export function isHookDefinition(value: any): boolean {
108+
function normalizeDefinitionInput<TPayload>(
109+
nameOrDefinition: string | HookDefinitionInput<TPayload>,
110+
run?: HookHandler<TPayload>,
111+
): HookDefinitionInput<TPayload> {
112+
if (typeof nameOrDefinition === "string") {
113+
if (!nameOrDefinition.length) {
114+
failToDefine("defineHook() requires a non-empty hook point name.");
115+
}
116+
117+
if (typeof run !== "function") {
118+
failToDefine(
119+
`defineHook(${describeDefinition(nameOrDefinition)}) requires a handler function as its second argument.`,
120+
);
121+
}
122+
123+
return { name: nameOrDefinition, run };
124+
}
125+
126+
if (
127+
!nameOrDefinition ||
128+
typeof nameOrDefinition !== "object" ||
129+
Array.isArray(nameOrDefinition)
130+
) {
131+
failToDefine("defineHook() was called with an unsupported argument.");
132+
}
133+
134+
const unknownFields = Object.keys(nameOrDefinition).filter(
135+
(field) => DEFINITION_FIELDS.indexOf(field) === -1,
136+
);
137+
if (unknownFields.length) {
138+
failToDefine(
139+
`defineHook(${describeDefinition(nameOrDefinition.name)}) received unknown ` +
140+
`field${unknownFields.length > 1 ? "s" : ""} ` +
141+
`${unknownFields.map((field) => JSON.stringify(field)).join(", ")}. ` +
142+
`Supported fields: ${DEFINITION_FIELDS.map((field) => JSON.stringify(field)).join(", ")}.`,
143+
);
144+
}
145+
146+
if (typeof nameOrDefinition.name !== "string" || !nameOrDefinition.name) {
147+
failToDefine(
148+
'defineHook() requires a non-empty "name" naming the hook point.',
149+
);
150+
}
151+
152+
if (typeof nameOrDefinition.run !== "function") {
153+
failToDefine(
154+
`defineHook(${describeDefinition(nameOrDefinition.name)}) requires "run" to be a function.`,
155+
);
156+
}
157+
158+
return { name: nameOrDefinition.name, run: nameOrDefinition.run };
159+
}
160+
161+
export function isHookDefinition<TPayload = any>(
162+
value: any,
163+
): value is HookDefinition<TPayload> {
67164
return (
68165
!!value &&
69166
(typeof value === "object" || typeof value === "function") &&
70167
value[HOOK_DEFINITION_MARKER] === true &&
71-
typeof value.handler === "function"
168+
typeof value.run === "function" &&
169+
typeof value.name === "string"
72170
);
73171
}
74172

173+
export interface HookInvocationOptions {
174+
/** The hook point the definition runs at; used in diagnostics. */
175+
hookName: string;
176+
/**
177+
* Whether the caller folds the collected middlewares around a method. Only
178+
* the `@hook`-decorated before-points do; everywhere else `ctx.wrap()` has
179+
* nothing to wrap and says so instead of silently dropping the middleware.
180+
*/
181+
consumesMiddlewares?: boolean;
182+
}
183+
75184
/**
76185
* Derives the context from the raw hook argument bag: the `hookArgs` wrapper
77186
* when the hook point supplies one, the bag itself for hook points that pass
78187
* their keys at the top level, and nothing when there is no payload.
79188
*/
80-
export function createHookInvocation(hookArguments: any): IHookInvocation {
189+
export function createHookInvocation<TPayload = any>(
190+
hookArguments: any,
191+
options: HookInvocationOptions,
192+
): HookInvocation<TPayload> {
193+
const { hookName, consumesMiddlewares } = options;
81194
const middlewares: HookMiddleware[] = [];
82-
const context: IHookContext = {
195+
const context: HookContext<TPayload> = {
83196
payload: derivePayload(hookArguments),
84197
wrap(middleware: HookMiddleware): void {
198+
if (!consumesMiddlewares) {
199+
throw new Error(
200+
`ctx.wrap() is not available at the "${hookName}" hook point: nothing folds the middleware around a method there, so it would never run.`,
201+
);
202+
}
203+
204+
if (typeof middleware !== "function") {
205+
throw new Error(
206+
`ctx.wrap() expects a function at the "${hookName}" hook point.`,
207+
);
208+
}
209+
85210
middlewares.push(middleware);
86211
},
87212
abort(message: string, opts?: { asWarning?: boolean }): never {
88-
const error: any = new Error(message);
213+
const text =
214+
typeof message === "string" && message.trim().length
215+
? message
216+
: `The "${hookName}" hook aborted without a message.`;
217+
const error: any = new Error(text);
89218
if (opts && opts.asWarning) {
90219
// The pair the hooks service checks for to downgrade a rejection.
91220
error.stopExecution = false;

lib/common/helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,7 @@ export function hook(commandName: string) {
615615
return hooksService.executeBeforeHooks(
616616
commandName,
617617
prepareArguments(method, args, hooksService),
618+
{ consumesMiddlewares: true },
618619
);
619620
},
620621
async (method: any, self: any, resultPromise: any, args: any[]) => {

0 commit comments

Comments
 (0)