Skip to content

Commit 93641e5

Browse files
committed
fix(hooks): gate in-process hooks with the TypeScript parser instead of esprima
The in-process gate parsed every .js/.cjs hook with esprima, which is abandoned and pinned to ES2017. A hook using `??`, `?.` or class fields made the parse throw, so the gate fell back to spawning the file as a child process, where the exported hook function is never invoked - the hook silently did nothing and reported exit code 0. The same silent no-op hit hooks written as ES modules and transpiled to CommonJS, since tsc emits `exports.default = ...` and the walker only recognised `module.exports = ...`. The gate now parses with the TypeScript compiler API (already a runtime dependency, required lazily so startup is unaffected), accepts both `module.exports` and `exports.default` top-level assignments, and the in-process path unwraps a function-valued `.default`. Modules that reach the in-process path without exporting a function now warn instead of crashing on an undefined `$inject`. esprima is dropped from the dependencies.
1 parent b49cf0e commit 93641e5

4 files changed

Lines changed: 198 additions & 39 deletions

File tree

lib/common/services/hooks-service.ts

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,19 @@ export class HooksService implements IHooksService {
213213
const { default: hookFn } = await import(hook.fullPath);
214214
hookEntryPoint = hookFn;
215215
} else {
216-
hookEntryPoint = require(hook.fullPath);
216+
const hookModule = require(hook.fullPath);
217+
// transpiled ES modules expose the hook as `exports.default`.
218+
hookEntryPoint =
219+
hookModule && typeof hookModule.default === "function"
220+
? hookModule.default
221+
: hookModule;
222+
}
223+
224+
if (typeof hookEntryPoint !== "function") {
225+
this.$logger.warn(
226+
`${hook.fullPath} will NOT be executed because it does not export a function.`,
227+
);
228+
return;
217229
}
218230

219231
this.$logger.trace(`Validating ${hookName} arguments.`);
@@ -462,33 +474,44 @@ export class HooksService implements IHooksService {
462474

463475
private shouldExecuteInProcess(scriptSource: string): boolean {
464476
try {
465-
const esprima = require("esprima");
466-
const ast = esprima.parse(scriptSource);
467-
468-
let inproc = false;
469-
ast.body.forEach((statement: any) => {
470-
if (
471-
statement.type !== "ExpressionStatement" ||
472-
statement.expression.type !== "AssignmentExpression"
473-
) {
474-
return;
477+
// required lazily so that CLI startup does not pay the cost of loading
478+
// the TypeScript compiler, which is only needed when a hook runs.
479+
const ts = require("typescript");
480+
const sourceFile = ts.createSourceFile(
481+
"hook.js",
482+
scriptSource,
483+
ts.ScriptTarget.Latest,
484+
/* setParentNodes */ false,
485+
ts.ScriptKind.JS,
486+
);
487+
488+
const isExportsTarget = (node: any): boolean => {
489+
if (!ts.isPropertyAccessExpression(node)) {
490+
return false;
475491
}
476492

477-
const left = statement.expression.left;
478-
if (
479-
left.type === "MemberExpression" &&
480-
left.object &&
481-
left.object.type === "Identifier" &&
482-
left.object.name === "module" &&
483-
left.property &&
484-
left.property.type === "Identifier" &&
485-
left.property.name === "exports"
486-
) {
487-
inproc = true;
493+
if (!ts.isIdentifier(node.expression)) {
494+
return false;
488495
}
489-
});
490496

491-
return inproc;
497+
const object = node.expression.text;
498+
const property = node.name.text;
499+
500+
return (
501+
(object === "module" && property === "exports") ||
502+
(object === "exports" && property === "default")
503+
);
504+
};
505+
506+
return sourceFile.statements.some((statement: any) => {
507+
return (
508+
ts.isExpressionStatement(statement) &&
509+
ts.isBinaryExpression(statement.expression) &&
510+
statement.expression.operatorToken.kind ===
511+
ts.SyntaxKind.EqualsToken &&
512+
isExportsTarget(statement.expression.left)
513+
);
514+
});
492515
} catch (err) {
493516
return false;
494517
}

lib/common/test/unit-tests/services/hook-service.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,157 @@ describe("hooks-service", () => {
143143
);
144144
});
145145

146+
it("should run hooks using syntax newer than ES2017", async () => {
147+
const projectName = "projectDirectory";
148+
const projectPath = mkdtempSync(path.join(tmpdir(), `${projectName}-`));
149+
150+
const testInjector = createTestInjector({ projectDir: projectPath });
151+
152+
const script = [
153+
`class Message {`,
154+
` text = "after-prepare hook is running";`,
155+
`}`,
156+
`module.exports = function ($logger, hookArgs) {`,
157+
` const message = new Message();`,
158+
` $logger.info(message?.text ?? "fallback");`,
159+
`};`,
160+
].join("\n");
161+
162+
fs.mkdirSync(path.join(projectPath, "hooks"));
163+
fs.mkdirSync(path.join(projectPath, "hooks/after-prepare"));
164+
fs.writeFileSync(
165+
path.join(projectPath, "hooks/after-prepare/hook.js"),
166+
script,
167+
);
168+
169+
service = testInjector.resolve("$hooksService");
170+
171+
await service.executeAfterHooks("prepare", { hookArgs: {} });
172+
173+
assert.equal(
174+
testInjector.resolve("$logger").output,
175+
"after-prepare hook is running\n",
176+
);
177+
});
178+
179+
it("should run hooks transpiled from a default export", async () => {
180+
const projectName = "projectDirectory";
181+
const projectPath = mkdtempSync(path.join(tmpdir(), `${projectName}-`));
182+
183+
const testInjector = createTestInjector({ projectDir: projectPath });
184+
185+
const script = [
186+
`"use strict";`,
187+
`Object.defineProperty(exports, "__esModule", { value: true });`,
188+
`exports.default = function ($logger, hookArgs) {`,
189+
` $logger.info("after-prepare hook is running");`,
190+
`};`,
191+
].join("\n");
192+
193+
fs.mkdirSync(path.join(projectPath, "hooks"));
194+
fs.mkdirSync(path.join(projectPath, "hooks/after-prepare"));
195+
fs.writeFileSync(
196+
path.join(projectPath, "hooks/after-prepare/hook.js"),
197+
script,
198+
);
199+
200+
service = testInjector.resolve("$hooksService");
201+
202+
await service.executeAfterHooks("prepare", { hookArgs: {} });
203+
204+
assert.equal(
205+
testInjector.resolve("$logger").output,
206+
"after-prepare hook is running\n",
207+
);
208+
});
209+
210+
it("should not run in-process hooks that do not export a function", async () => {
211+
const projectName = "projectDirectory";
212+
const projectPath = mkdtempSync(path.join(tmpdir(), `${projectName}-`));
213+
214+
const testInjector = createTestInjector({ projectDir: projectPath });
215+
216+
const script = [`module.exports = { name: "not-a-hook" };`].join("\n");
217+
218+
fs.mkdirSync(path.join(projectPath, "hooks"));
219+
fs.mkdirSync(path.join(projectPath, "hooks/after-prepare"));
220+
fs.writeFileSync(
221+
path.join(projectPath, "hooks/after-prepare/hook.js"),
222+
script,
223+
);
224+
225+
service = testInjector.resolve("$hooksService");
226+
227+
await service.executeAfterHooks("prepare", { hookArgs: {} });
228+
229+
expect(testInjector.resolve("$logger").warnOutput).to.have.string(
230+
"does not export a function",
231+
);
232+
});
233+
234+
describe("in-process detection", () => {
235+
const shouldExecuteInProcess = (source: string): boolean => {
236+
const testInjector = createTestInjector();
237+
const hooksService = testInjector.resolve("$hooksService");
238+
return (<any>hooksService).shouldExecuteInProcess(source);
239+
};
240+
241+
it("detects module.exports assignments alongside modern syntax", () => {
242+
assert.isTrue(
243+
shouldExecuteInProcess(
244+
[
245+
`class Message { text = "hi"; }`,
246+
`module.exports = function ($logger) {`,
247+
` $logger.info(new Message()?.text ?? "fallback");`,
248+
`};`,
249+
].join("\n"),
250+
),
251+
);
252+
});
253+
254+
it("detects exports.default assignments", () => {
255+
assert.isTrue(
256+
shouldExecuteInProcess(
257+
[
258+
`"use strict";`,
259+
`Object.defineProperty(exports, "__esModule", { value: true });`,
260+
`exports.default = function ($logger) {};`,
261+
].join("\n"),
262+
),
263+
);
264+
});
265+
266+
it("ignores scripts without an export assignment", () => {
267+
assert.isFalse(
268+
shouldExecuteInProcess(
269+
[
270+
`var fs = require("fs");`,
271+
`fs.writeFileSync("test.txt", "test");`,
272+
].join("\n"),
273+
),
274+
);
275+
});
276+
277+
it("ignores nested and unrelated assignments", () => {
278+
assert.isFalse(
279+
shouldExecuteInProcess(
280+
[
281+
`function register() {`,
282+
` module.exports = function () {};`,
283+
`}`,
284+
`exports.named = function () {};`,
285+
`module.other = function () {};`,
286+
].join("\n"),
287+
),
288+
);
289+
});
290+
291+
it("does not throw on unparseable sources", () => {
292+
assert.isFalse(shouldExecuteInProcess(`}{ this is not ((javascript`));
293+
assert.isFalse(shouldExecuteInProcess(<any>null));
294+
});
295+
});
296+
146297
it("should run non-hook files", async () => {
147298
const projectName = "projectDirectory";
148299
const projectPath = mkdtempSync(path.join(tmpdir(), `${projectName}-`));

package-lock.json

Lines changed: 0 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@
5555
"convert-source-map": "2.0.0",
5656
"detect-newline": "3.1.0",
5757
"email-validator": "2.0.4",
58-
"esprima": "4.0.1",
5958
"font-finder": "1.1.0",
6059
"ios-device-lib": "0.9.5",
6160
"ios-mobileprovision-finder": "1.2.1",

0 commit comments

Comments
 (0)