hardhat
Version:
Hardhat is an extensible developer tool that helps smart contract developers increase productivity by reliably bringing together the tools they want.
530 lines (452 loc) • 17.7 kB
text/typescript
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Typescript
can handle the generic types in this file correctly. It can do it for function
signatures, but not for function bodies. */
import type {
ChainedHook,
HookContext,
HookManager,
InitialHookParams as InitialHookParams,
InitialChainedHookParams,
HardhatHooks,
} from "../../types/hooks.js";
import type { HardhatPlugin } from "../../types/plugins.js";
import type { LastParameter, Return } from "../../types/utils.js";
import {
assertHardhatInvariant,
HardhatError,
} from "@nomicfoundation/hardhat-errors";
import { ensureError } from "@nomicfoundation/hardhat-utils/error";
import { AsyncMutex } from "@nomicfoundation/hardhat-utils/synchronization";
import { detectPluginNpmDependencyProblems } from "./plugins/detect-plugin-npm-dependency-problems.js";
export class HookManagerImplementation implements HookManager {
readonly #mutex: AsyncMutex = new AsyncMutex();
readonly #projectRoot: string;
/**
* The context passed to hook handlers, except to the `config` ones, to break
* a circular dependency between the config and the hook handler.
*
* Initially `undefined` to be able to run the config hooks during
* initialization.
*/
#context: HookContext | undefined;
/**
* Plugins that provide hook handlers for each category, in reverse order.
*
* Precomputed from the plugin list at construction.
*/
readonly #pluginsByHookCategory: Map<keyof HardhatHooks, HardhatPlugin[]> =
new Map();
/**
* Cached resolved category objects per hook category in reverse plugin
* order.
*
* Only written by #getStaticHookHandlerCategories, which uses a mutex to
* ensure that every Hook Category Factory is run once per HookManager
* instance.
*/
readonly #resolvedStaticCategories: Map<
keyof HardhatHooks,
Array<Partial<HardhatHooks[keyof HardhatHooks]>>
> = new Map();
/**
* A map of the dynamically registered handler categories.
*
* Each array is a list of categories, in reverse order of registration.
*
* Written by registerHandlers and unregisterHandlers.
*/
readonly #dynamicHookHandlerCategories: Map<
keyof HardhatHooks,
Array<Partial<HardhatHooks[keyof HardhatHooks]>>
> = new Map();
/**
* Cached combined (dynamic + static) handlers per (category, hook name) in
* chained running order.
*
* Only written by #getHandlersInChainedRunningOrder, and invalidated
* per-category on dynamic handlers register/unregister.
*/
readonly #chainedHandlers: Map<keyof HardhatHooks, Map<string, any[]>> =
new Map();
/**
* Cached combined handlers per (category, hook name) in sequential running
* order (reverse of chained).
*
* Only written by #getHandlersInSequentialRunningOrder, and invalidated
* per-category on dynamic handlers register/unregister.
*/
readonly #sequentialHandlers: Map<keyof HardhatHooks, Map<string, any[]>> =
new Map();
constructor(projectRoot: string, plugins: HardhatPlugin[]) {
this.#projectRoot = projectRoot;
for (const plugin of plugins.toReversed()) {
if (plugin.hookHandlers === undefined) {
continue;
}
for (const hookCategoryName of Object.keys(plugin.hookHandlers) as Array<
keyof HardhatHooks
>) {
if (plugin.hookHandlers[hookCategoryName] === undefined) {
continue;
}
let pluginsForCategory =
this.#pluginsByHookCategory.get(hookCategoryName);
if (pluginsForCategory === undefined) {
pluginsForCategory = [];
this.#pluginsByHookCategory.set(hookCategoryName, pluginsForCategory);
}
pluginsForCategory.push(plugin);
}
}
}
public setContext(context: HookContext): void {
this.#context = context;
}
public registerHandlers<HookCategoryNameT extends keyof HardhatHooks>(
hookCategoryName: HookCategoryNameT,
hookHandlerCategory: Partial<HardhatHooks[HookCategoryNameT]>,
): void {
let categories = this.#dynamicHookHandlerCategories.get(hookCategoryName);
if (categories === undefined) {
categories = [];
this.#dynamicHookHandlerCategories.set(hookCategoryName, categories);
}
categories.unshift(hookHandlerCategory);
this.#invalidateResolvedHandlersCache(hookCategoryName);
}
public unregisterHandlers<HookCategoryNameT extends keyof HardhatHooks>(
hookCategoryName: HookCategoryNameT,
hookHandlerCategory: Partial<HardhatHooks[HookCategoryNameT]>,
): void {
const categories = this.#dynamicHookHandlerCategories.get(hookCategoryName);
if (categories === undefined) {
return;
}
this.#dynamicHookHandlerCategories.set(
hookCategoryName,
categories.filter((c) => c !== hookHandlerCategory),
);
this.#invalidateResolvedHandlersCache(hookCategoryName);
}
public async runHandlerChain<
HookCategoryNameT extends keyof HardhatHooks,
HookNameT extends keyof HardhatHooks[HookCategoryNameT],
HookT extends ChainedHook<HardhatHooks[HookCategoryNameT][HookNameT]>,
>(
hookCategoryName: HookCategoryNameT,
hookName: HookNameT,
params: InitialChainedHookParams<HookCategoryNameT, HookT>,
defaultImplementation: LastParameter<HookT>,
): Promise<Awaited<Return<HardhatHooks[HookCategoryNameT][HookNameT]>>> {
// Synchronous fast path for already cached handlers. This duplicates
// the check inside #getHandlersInChainedRunningOrder on purpose:
// calling that async method introduces a microtask tick even on a
// cache hit, whereas a direct Map lookup stays on the current tick.
// That tick matters here because runHandlerChain is on every hook's
// hot path, and this path pairs with the empty-handlers shortcut
// below to dispatch straight to defaultImplementation with no awaits.
const cachedHandlers = this.#chainedHandlers
.get(hookCategoryName)
?.get(hookName as string);
const handlers =
cachedHandlers ??
(await this.#getHandlersInChainedRunningOrder(
hookCategoryName,
hookName,
));
let handlerParams: Parameters<typeof defaultImplementation>;
if (hookCategoryName !== "config") {
assertHardhatInvariant(
this.#context !== undefined,
"Context must be set before running non-config hooks",
);
handlerParams = [this.#context, ...params] as any;
} else {
handlerParams = params as any;
}
// Fast path for the common case of no registered handlers: skip building
// handlerParams and the `next` closure, and call the default implementation
// directly.
if (handlers.length === 0) {
return (await defaultImplementation(...handlerParams)) as any;
}
const numberOfHandlers = handlers.length;
let index = 0;
const next = async (...nextParams: typeof handlerParams) => {
const result =
index < numberOfHandlers
? await (handlers[index++] as any)(...nextParams, next)
: await defaultImplementation(...nextParams);
return result;
};
return await next(...handlerParams);
}
public async runSequentialHandlers<
HookCategoryNameT extends keyof HardhatHooks,
HookNameT extends keyof HardhatHooks[HookCategoryNameT],
HookT extends HardhatHooks[HookCategoryNameT][HookNameT],
>(
hookCategoryName: HookCategoryNameT,
hookName: HookNameT,
params: InitialHookParams<HookCategoryNameT, HookT>,
): Promise<
Array<Awaited<Return<HardhatHooks[HookCategoryNameT][HookNameT]>>>
> {
const handlers = await this.#getHandlersInSequentialRunningOrder(
hookCategoryName,
hookName,
);
let handlerParams: any;
if (hookCategoryName !== "config") {
assertHardhatInvariant(
this.#context !== undefined,
"Context must be set before running non-config hooks",
);
handlerParams = [this.#context, ...params];
} else {
handlerParams = params;
}
const result = [];
for (const handler of handlers) {
result.push(await (handler as any)(...handlerParams));
}
return result;
}
public async runParallelHandlers<
HookCategoryNameT extends keyof HardhatHooks,
HookNameT extends keyof HardhatHooks[HookCategoryNameT],
HookT extends HardhatHooks[HookCategoryNameT][HookNameT],
>(
hookCategoryName: HookCategoryNameT,
hookName: HookNameT,
params: InitialHookParams<HookCategoryNameT, HookT>,
): Promise<
Array<Awaited<Return<HardhatHooks[HookCategoryNameT][HookNameT]>>>
> {
// The ordering of handlers is unimportant here, as they are run in parallel
const handlers = await this.#getHandlersInChainedRunningOrder(
hookCategoryName,
hookName,
);
let handlerParams: any;
if (hookCategoryName !== "config") {
assertHardhatInvariant(
this.#context !== undefined,
"Context must be set before running non-config hooks",
);
handlerParams = [this.#context, ...params];
} else {
handlerParams = params;
}
return await Promise.all(
handlers.map((handler) => (handler as any)(...handlerParams)),
);
}
public async hasHandlers<
HookCategoryNameT extends keyof HardhatHooks,
HookNameT extends keyof HardhatHooks[HookCategoryNameT],
>(
hookCategoryName: HookCategoryNameT,
hookName: HookNameT,
): Promise<boolean> {
// The ordering of handlers is unimportant here, as we only check if any exist
const handlers = await this.#getHandlersInChainedRunningOrder(
hookCategoryName,
hookName,
);
return handlers.length > 0;
}
async #getHandlersInChainedRunningOrder<
HookCategoryNameT extends keyof HardhatHooks,
HookNameT extends keyof HardhatHooks[HookCategoryNameT],
>(
hookCategoryName: HookCategoryNameT,
hookName: HookNameT,
): Promise<Array<HardhatHooks[HookCategoryNameT][HookNameT]>> {
let handlersByName = this.#chainedHandlers.get(hookCategoryName);
if (handlersByName === undefined) {
handlersByName = new Map();
this.#chainedHandlers.set(hookCategoryName, handlersByName);
}
const cached = handlersByName.get(hookName as string);
if (cached !== undefined) {
return cached;
}
const staticCategories =
await this.#getStaticHookHandlerCategories(hookCategoryName);
// IMPORTANT NOTE: Accessing the dynamic hook handlers MUST happen
// after awaiting the static ones. See
// #invalidateResolvedHandlersCache for more info.
const dynamicCategories = this.#dynamicHookHandlerCategories.get(
hookCategoryName,
) as Array<Partial<HardhatHooks[HookCategoryNameT]>> | undefined;
const handlers: Array<HardhatHooks[HookCategoryNameT][HookNameT]> = [];
if (dynamicCategories !== undefined) {
for (const category of dynamicCategories) {
const handler = category[hookName];
if (handler !== undefined) {
handlers.push(handler as HardhatHooks[HookCategoryNameT][HookNameT]);
}
}
}
for (const category of staticCategories) {
const handler = category[hookName];
if (handler !== undefined) {
handlers.push(handler as HardhatHooks[HookCategoryNameT][HookNameT]);
}
}
handlersByName.set(hookName as string, handlers);
return handlers;
}
async #getHandlersInSequentialRunningOrder<
HookCategoryNameT extends keyof HardhatHooks,
HookNameT extends keyof HardhatHooks[HookCategoryNameT],
>(
hookCategoryName: HookCategoryNameT,
hookName: HookNameT,
): Promise<Array<HardhatHooks[HookCategoryNameT][HookNameT]>> {
let handlersByName = this.#sequentialHandlers.get(hookCategoryName);
if (handlersByName === undefined) {
handlersByName = new Map();
this.#sequentialHandlers.set(hookCategoryName, handlersByName);
}
const cached = handlersByName.get(hookName as string);
if (cached !== undefined) {
return cached;
}
const chained = await this.#getHandlersInChainedRunningOrder(
hookCategoryName,
hookName,
);
const sequential = chained.toReversed();
handlersByName.set(hookName as string, sequential);
return sequential;
}
async #getStaticHookHandlerCategories<
HookCategoryNameT extends keyof HardhatHooks,
>(
hookCategoryName: HookCategoryNameT,
): Promise<Array<Partial<HardhatHooks[HookCategoryNameT]>>> {
const cached = this.#resolvedStaticCategories.get(hookCategoryName) as
| Array<Partial<HardhatHooks[HookCategoryNameT]>>
| undefined;
if (cached !== undefined) {
return cached;
}
const plugins = this.#pluginsByHookCategory.get(hookCategoryName);
// We don't need to get the mutex to resolve this case, as it will always
// be an empty array, and won't execute any factory.
if (plugins === undefined) {
this.#resolvedStaticCategories.set(hookCategoryName, []);
return [];
}
return await this.#mutex.exclusiveRun(async () => {
// Re-check under the mutex in case another caller just populated it.
const recheck = this.#resolvedStaticCategories.get(hookCategoryName) as
| Array<Partial<HardhatHooks[HookCategoryNameT]>>
| undefined;
if (recheck !== undefined) {
return recheck;
}
const resolved = await Promise.all(
plugins.map(
async (plugin) =>
await this.#getPluginStaticHookCategory(plugin, hookCategoryName),
),
);
this.#resolvedStaticCategories.set(hookCategoryName, resolved);
return resolved;
});
}
/**
* Returns the hook category object for a plugin that has the hook category
* defined.
*
* @param plugin A plugin that MUST have the given hook category defined.
* @param hookCategoryName The name of the hook category.
* @returns The hook category object.
*/
async #getPluginStaticHookCategory<
HookCategoryNameT extends keyof HardhatHooks,
>(
plugin: HardhatPlugin,
hookCategoryName: HookCategoryNameT,
): Promise<Partial<HardhatHooks[HookCategoryNameT]>> {
const hookHandlerCategoryFactory = plugin.hookHandlers?.[hookCategoryName];
assertHardhatInvariant(
hookHandlerCategoryFactory !== undefined,
"#pluginsByHookCategory only contains plugins with this hook category",
);
let factory;
try {
factory = (await hookHandlerCategoryFactory()).default;
} catch (error) {
ensureError(error);
await detectPluginNpmDependencyProblems(this.#projectRoot, plugin, error);
throw new HardhatError(
HardhatError.ERRORS.CORE.HOOKS.FAILED_TO_LOAD_HOOK_HANDLER_FACTORY,
{ pluginId: plugin.id, hookCategoryName },
error,
);
}
assertHardhatInvariant(
typeof factory === "function",
`Plugin ${plugin.id} doesn't export a hook factory for category ${hookCategoryName}`,
);
let hookCategory: Partial<HardhatHooks[HookCategoryNameT]>;
try {
hookCategory = await factory();
} catch (error) {
ensureError(error);
throw new HardhatError(
HardhatError.ERRORS.CORE.HOOKS.FAILED_TO_RUN_HOOK_HANDLER_FACTORY,
{ pluginId: plugin.id, hookCategoryName },
error,
);
}
assertHardhatInvariant(
hookCategory !== null && typeof hookCategory === "object",
`Plugin ${plugin.id} doesn't export a valid factory for category ${hookCategoryName}, it didn't return an object`,
);
return hookCategory;
}
#invalidateResolvedHandlersCache<
HookCategoryNameT extends keyof HardhatHooks,
>(hookCategoryName: HookCategoryNameT) {
// Invalidation deletes the outer entry rather than clearing the inner
// map. This matters under concurrency.
//
// A reader of #getHandlersInChainedRunningOrder (or its sequential
// sibling) captures a reference to the inner map before awaiting the
// static categories, and writes its computed array back after the
// await. If invalidation runs during that await, deleting the outer
// entry leaves the reader's inner map orphaned: its write lands in a
// map no longer reachable from #chainedHandlers/#sequentialHandlers,
// so it cannot poison the shared cache. The next reader sees
// `undefined`, installs a fresh inner map, and rebuilds from the
// current dynamic state.
//
// Two distinct properties make this safe, guaranteed by two different
// things:
//
// 1. The in-flight reader's own return value is correct. This is
// because #getHandlersInChainedRunningOrder reads
// #dynamicHookHandlerCategories *after* awaiting the static
// categories. Any invalidation that happened during the await is
// visible to the reader when it resumes, so the array it builds
// reflects the current dynamic state.
//
// 2. The shared cache never holds a stale array. This is guaranteed
// by the orphaning-by-delete described above: a reader that
// started before the invalidation can only write into an
// unreachable inner map.
//
// Property 1 depends on the ordering of the dynamic handlers read relative
// to the await. If that read ever moved *before* the await, a reader
// could build a stale array and return it to its caller — the cache
// would still be protected by property 2, but the reader's caller
// would see the stale result.
this.#chainedHandlers.delete(hookCategoryName);
this.#sequentialHandlers.delete(hookCategoryName);
}
}