Repository: NomicFoundation/hardhat (TypeScript)

Issue:
Add build info watcher to the hardhat node task
This is a follow-up task to #5615

It is about adding a build info (from artifact) watcher functionality. The idea is to watch for build info changes and adding compilation info to EDR when they happen.

We'll be able to work on this once EDR is ready to receive build info from us.

Below, you'll find a draft of the implementation.

Implementation:

```
import fsPromises from "node:fs/promises";

export interface WatcherEvent {
  eventType: "change" | "rename";
  filename: string | null;
}

export type WatcherEventHandler = (event: WatcherEvent) => Promise<void>;

export class Watcher {
  readonly #abortController: AbortController;
  readonly #eventLoop: Promise<void>;

  constructor(path: string, eventHandler: WatcherEventHandler) {
    this.#abortController = new AbortController();

    const events = fsPromises.watch(path, {
      recursive: true,
      signal: this.#abortController.signal,
    });

    this.#eventLoop = new Promise(async () => {
      for await (const event of events) {
        await eventHandler(event);
      }
    });
  }

  public close(): Promise<void> {
    this.#abortController.abort();
    return this.#eventLoop;
  }
}
```

Usage:
```
import type { WatcherEvent } from "./watcher.js";
import type { BuildInfo } from "../../../types/artifacts.js";

import path from "node:path";

import { exists, readJsonFile } from "@ignored/hardhat-vnext-utils/fs";

import { BUILD_INFO_DIR_NAME } from "../artifacts/artifacts-manager.js";

import { Watcher } from "./watcher.js";

...

  const watcher = new Watcher(
    path.join(hre.config.paths.artifacts, BUILD_INFO_DIR_NAME),
    async ({ eventType, filename }: WatcherEvent) => {
      log(`Detected ${eventType} in ${filename}`);

      if (
        filename === null ||
        filename.endsWith(".output.json") ||
        !(await exists(filename))
      ) {
        return;
      }

      const filenameOutput = filename.replace(".json", ".output.json");
      if (await exists(filenameOutput)) {
        return;
      }

      const buildInfo: BuildInfo = await readJsonFile(filename);
      const buildInfoOutput = await readJsonFile(filenameOutput);

      try {
        await provider.request({
          method: "hardhat_addCompilationResult",
          params: [buildInfo.solcVersion, buildInfo.input, buildInfoOutput],
        });
      } catch (error) {
        log(error);
      }
    },
  );
  
  ...
  
  await watcher.close();
  
...
```


Discussion:
WIP at https://github.com/NomicFoundation/hardhat/pull/6524 (not ready for review yet)
Ready for review at https://github.com/NomicFoundation/hardhat/pull/6928



Patch:
diff --git a/.changeset/old-bottles-allow.md b/.changeset/old-bottles-allow.md
new file mode 100644
index 00000000000..ee412da8882
--- /dev/null
+++ b/.changeset/old-bottles-allow.md
@@ -0,0 +1,5 @@
+---
+"hardhat": patch
+---
+
+Automatically add compilation results to a running `npx hardhat node` on recompilation ([#6040](https://github.com/NomicFoundation/hardhat/issues/6040))
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5bd56849dce..dc763737972 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -167,6 +167,9 @@ importers:
       chalk:
         specifier: ^5.3.0
         version: 5.4.1
+      chokidar:
+        specifier: ^4.0.3
+        version: 4.0.3
       debug:
         specifier: ^4.3.2
         version: 4.4.0(supports-color@5.5.0)
@@ -3304,6 +3307,10 @@ packages:
     resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
     engines: {node: '>= 8.10.0'}
 
+  chokidar@4.0.3:
+    resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
+    engines: {node: '>= 14.16.0'}
+
   ci-info@3.9.0:
     resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
     engines: {node: '>=8'}
@@ -4872,6 +4879,10 @@ packages:
     resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
     engines: {node: '>=8.10.0'}
 
+  readdirp@4.1.2:
+    resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
+    engines: {node: '>= 14.18.0'}
+
   reduce-flatten@2.0.0:
     resolution: {integrity: sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==}
     engines: {node: '>=6'}
@@ -7240,6 +7251,10 @@ snapshots:
     optionalDependencies:
       fsevents: 2.3.3
 
+  chokidar@4.0.3:
+    dependencies:
+      readdirp: 4.1.2
+
   ci-info@3.9.0: {}
 
   classnames@2.5.1: {}
@@ -9090,6 +9105,8 @@ snapshots:
     dependencies:
       picomatch: 2.3.1
 
+  readdirp@4.1.2: {}
+
   reduce-flatten@2.0.0: {}
 
   reflect.getprototypeof@1.0.10:
diff --git a/v-next/hardhat/package.json b/v-next/hardhat/package.json
index caacfc5d282..928e0461edc 100644
--- a/v-next/hardhat/package.json
+++ b/v-next/hardhat/package.json
@@ -94,6 +94,7 @@
     "@sentry/core": "^9.4.0",
     "adm-zip": "^0.4.16",
     "chalk": "^5.3.0",
+    "chokidar": "^4.0.3",
     "debug": "^4.3.2",
     "enquirer": "^2.3.0",
     "ethereum-cryptography": "^2.2.1",
diff --git a/v-next/hardhat/src/internal/builtin-plugins/network-manager/edr/edr-provider.ts b/v-next/hardhat/src/internal/builtin-plugins/network-manager/edr/edr-provider.ts
index 2a53e193fd8..8a60b0119f3 100644
--- a/v-next/hardhat/src/internal/builtin-plugins/network-manager/edr/edr-provider.ts
+++ b/v-next/hardhat/src/internal/builtin-plugins/network-manager/edr/edr-provider.ts
@@ -304,6 +304,22 @@ export class EdrProvider extends BaseProvider {
     this.#provider = undefined;
   }
 
+  public async addCompilationResult(
+    solcVersion: string,
+    compilerInput: any,
+    compilerOutput: any,
+  ): Promise<void> {
+    if (this.#provider === undefined) {
+      throw new HardhatError(HardhatError.ERRORS.CORE.NETWORK.PROVIDER_CLOSED);
+    }
+
+    await this.#provider.addCompilationResult(
+      solcVersion,
+      compilerInput,
+      compilerOutput,
+    );
+  }
+
   async #handleEdrResponse(
     edrResponse: Response,
   ): Promise<SuccessfulJsonRpcResponse> {
diff --git a/v-next/hardhat/src/internal/builtin-plugins/node/artifacts/build-info-watcher.ts b/v-next/hardhat/src/internal/builtin-plugins/node/artifacts/build-info-watcher.ts
new file mode 100644
index 00000000000..ba760b23585
--- /dev/null
+++ b/v-next/hardhat/src/internal/builtin-plugins/node/artifacts/build-info-watcher.ts
@@ -0,0 +1,82 @@
+import type { FSWatcher } from "chokidar";
+
+import path from "node:path";
+
+import debug from "debug";
+
+export type BuildInfoWatcher = FSWatcher;
+export type BuildInfoHandler = (buildId: string) => Promise<void>;
+
+const log = debug("hardhat:core:tasks:node:artifacts");
+
+const STABILITY_THRESHOLD = 250;
+const POLL_INTERVAL = 50;
+
+/**
+ * `listener` is a callback function invoked when a new file is added to the
+ * build info directory. It processes the received event to check whether it is
+ * for a build info file.
+ *
+ * If so, it extracts the build id from it and triggers the provided handler
+ * with the extracted build id as an argument. Any errors encountered during
+ * handler execution are captured and logged.
+ *
+ * @param absolutePath - The absolute path of the file added to the build info directory.
+ *
+ * This function is exposed for testing purposes only.
+ */
+export async function listener(
+  handler: BuildInfoHandler,
+  absolutePath: string,
+): Promise<void> {
+  log(`Detected change in ${absolutePath}`);
+
+  const isBuildInfoFile =
+    absolutePath.endsWith(".json") && !absolutePath.endsWith(".output.json");
+
+  if (!isBuildInfoFile) {
+    log(`File ${absolutePath} is not a build info file`);
+    return;
+  }
+
+  const buildId = path.basename(absolutePath).replace(".json", "");
+
+  await handler(buildId).catch(async (error: unknown) => {
+    log(
+      `There was a problem executing the handler for build ${buildId}.`,
+      error,
+    );
+  });
+}
+
+/**
+ * `watchBuildInfo` is a function that creates a watch over provided build info
+ * directory. If it encounters a build info file being added, it will trigger
+ * the provided handler, passing the build id as an argument. This allows for
+ * further processing or actions to be taken upon completion of a build.
+ */
+export async function watchBuildInfo(
+  buildInfoDirPath: string,
+  handler: BuildInfoHandler,
+): Promise<BuildInfoWatcher> {
+  const { watch } = await import("chokidar");
+
+  // NOTE: Deleting the build info directory while it is being watched will
+  // effectively cause the watcher to stop working.
+  // NOTE: We use chokidar's `awaitWriteFinish` option because we are certain
+  // the build info file will be added after the build info output file.
+  const watcher = watch(buildInfoDirPath, {
+    persistent: true,
+    ignoreInitial: true,
+    awaitWriteFinish: {
+      stabilityThreshold: STABILITY_THRESHOLD,
+      pollInterval: POLL_INTERVAL,
+    },
+  });
+
+  // NOTE: We listen only to the "add" event because the contents of the build info
+  // files identified by a build id should be considered immutable under usual circumstances.
+  watcher.on("add", listener.bind(null, handler));
+
+  return watcher;
+}
diff --git a/v-next/hardhat/src/internal/builtin-plugins/node/helpers.ts b/v-next/hardhat/src/internal/builtin-plugins/node/helpers.ts
index caab18a895c..7b4f97ed26a 100644
--- a/v-next/hardhat/src/internal/builtin-plugins/node/helpers.ts
+++ b/v-next/hardhat/src/internal/builtin-plugins/node/helpers.ts
@@ -1,9 +1,19 @@
+import type { BuildInfo } from "../../../types/artifacts.js";
 import type { EdrNetworkAccountsConfig } from "../../../types/config.js";
+import type { SolidityBuildInfoOutput } from "../../../types/solidity.js";
+import type { EdrProvider } from "../network-manager/edr/edr-provider.js";
 
+import path from "node:path";
+
+import {
+  readJsonFile,
+  readJsonFileAsStream,
+} from "@nomicfoundation/hardhat-utils/fs";
 import { hexStringToBytes } from "@nomicfoundation/hardhat-utils/hex";
 import chalk from "chalk";
 import { addr } from "micro-eth-signer";
 
+import { sendErrorTelemetry } from "../../cli/telemetry/sentry/reporter.js";
 import { isDefaultEdrNetworkHDAccountsConfig } from "../network-manager/edr/edr-provider.js";
 import { normalizeEdrNetworkAccountsConfig } from "../network-manager/edr/utils/convert-to-edr.js";
 
@@ -64,6 +74,60 @@ export async function formatEdrNetworkConfigAccounts(
   return formattedAccountsLines.join("\n");
 }
 
+/**
+ * Creates a handler function that will be called on buildInfo file creations
+ * (triggered from the compilation pipeline); the handler reads the build info
+ * file and uploads the key details into the EDR instance.
+ *
+ * @param buildInfoDirPath - The path (under artifacts) to the build info
+ * directory
+ * @param provider - The EDR provider being updated.
+ * @returns The handler function that is called with the buildId to upload.
+ */
+export function createBuildInfoUploadHandlerFrom(
+  buildInfoDirPath: string,
+  provider: EdrProvider,
+  log: debug.Debugger,
+): (buildId: string) => Promise<void> {
+  const buildInfoHandler = async (buildId: string) => {
+    try {
+      log(`Adding new compilation result for build ${buildId} to the node`);
+      const buildInfo: BuildInfo = await readJsonFile(
+        path.join(buildInfoDirPath, `${buildId}.json`),
+      );
+      const buildInfoOutput: SolidityBuildInfoOutput =
+        await readJsonFileAsStream(
+          path.join(buildInfoDirPath, `${buildId}.output.json`),
+        );
+
+      await provider.addCompilationResult(
+        buildInfo.solcVersion,
+        buildInfo.input,
+        buildInfoOutput.output,
+      );
+
+      log(`Added compiler result for ${buildId}`);
+    } catch (error) {
+      console.warn(
+        chalk.yellow(
+          `There was a problem adding the new compiler result for build ${buildId}.`,
+        ),
+      );
+
+      log(
+        "Last compilation result couldn't be added. Please report this to help us improve Hardhat.\n",
+        error,
+      );
+
+      if (error instanceof Error) {
+        await sendErrorTelemetry(error);
+      }
+    }
+  };
+
+  return buildInfoHandler;
+}
+
 // NOTE: This function is exported for testing purposes only
 export function getPublicPrivateKeysWarning(): string {
   return chalk.bold(
diff --git a/v-next/hardhat/src/internal/builtin-plugins/node/task-action.ts b/v-next/hardhat/src/internal/builtin-plugins/node/task-action.ts
index 4af7e48db3f..9802117d655 100644
--- a/v-next/hardhat/src/internal/builtin-plugins/node/task-action.ts
+++ b/v-next/hardhat/src/internal/builtin-plugins/node/task-action.ts
@@ -1,19 +1,30 @@
 import type { EdrNetworkConfigOverride } from "../../../types/config.js";
 import type { NewTaskActionFunction } from "../../../types/tasks.js";
 
+import path from "node:path";
+
 import {
   assertHardhatInvariant,
   HardhatError,
 } from "@nomicfoundation/hardhat-errors";
-import { exists } from "@nomicfoundation/hardhat-utils/fs";
+import { ensureDir, exists } from "@nomicfoundation/hardhat-utils/fs";
 import chalk from "chalk";
+import debug from "debug";
 
 import { DEFAULT_NETWORK_NAME } from "../../constants.js";
 import { isSupportedChainType } from "../../edr/chain-type.js";
+import { BUILD_INFO_DIR_NAME } from "../artifacts/artifact-manager.js";
+import { EdrProvider } from "../network-manager/edr/edr-provider.js";
 
-import { formatEdrNetworkConfigAccounts } from "./helpers.js";
+import { watchBuildInfo } from "./artifacts/build-info-watcher.js";
+import {
+  createBuildInfoUploadHandlerFrom,
+  formatEdrNetworkConfigAccounts,
+} from "./helpers.js";
 import { JsonRpcServerImplementation } from "./json-rpc/server.js";
 
+const log = debug("hardhat:core:tasks:node");
+
 interface NodeActionArguments {
   hostname?: string;
   port: number;
@@ -94,6 +105,11 @@ const nodeAction: NewTaskActionFunction<NodeActionArguments> = async (
     override: networkConfigOverride,
   });
 
+  assertHardhatInvariant(
+    provider instanceof EdrProvider,
+    "Provider must be EdrProvider, since only edr networks are supported",
+  );
+
   // NOTE: We enable logging for the node
   await provider.request({
     method: "hardhat_setLoggingEnabled",
@@ -128,7 +144,16 @@ const nodeAction: NewTaskActionFunction<NodeActionArguments> = async (
 
   console.log();
 
-  // TODO(https://github.com/NomicFoundation/hardhat/issues/6040): Add build info watcher here
+  const buildInfoDirPath = path.join(
+    hre.config.paths.artifacts,
+    BUILD_INFO_DIR_NAME,
+  );
+  await ensureDir(buildInfoDirPath);
+
+  const buildInfoWatcher = await watchBuildInfo(
+    buildInfoDirPath,
+    createBuildInfoUploadHandlerFrom(buildInfoDirPath, provider, log),
+  );
 
   // NOTE: Before creating the node, we check if the input network config is of type edr.
   // We only proceed if it is. Hence, we can assume that the output network config is of type edr as well.
@@ -140,6 +165,7 @@ const nodeAction: NewTaskActionFunction<NodeActionArguments> = async (
   console.log(await formatEdrNetworkConfigAccounts(networkConfig.accounts));
 
   await server.afterClosed();
+  await buildInfoWatcher.close();
 };
 
 export default nodeAction;
diff --git a/v-next/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts b/v-next/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts
index bc67e18a0a5..919d7ec9713 100644
--- a/v-next/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts
+++ b/v-next/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts
@@ -33,8 +33,10 @@ import {
 } from "@nomicfoundation/hardhat-errors";
 import {
   exists,
+  ensureDir,
   getAllDirectoriesMatching,
   getAllFilesMatching,
+  move,
   readJsonFile,
   remove,
   writeJsonFile,
@@ -735,27 +737,39 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem {
     // file, we emit the build info file and its output file.
     const buildInfoId = buildId;
 
-    const buildInfoPath = path.join(
-      artifactsDirectory,
+    const buildInfoCacheDirPath = path.join(
+      this.#options.cachePath,
       `build-info`,
+    );
+
+    await ensureDir(buildInfoCacheDirPath);
+
+    const buildInfoCachePath = path.join(
+      buildInfoCacheDirPath,
       `${buildInfoId}.json`,
     );
 
-    const buildInfoOutputPath = path.join(
-      artifactsDirectory,
-      `build-info`,
+    const buildInfoOutputCachePath = path.join(
+      buildInfoCacheDirPath,
       `${buildInfoId}.output.json`,
     );
 
     // BuildInfo and BuildInfoOutput files are large, so we write them
     // concurrently, and keep their lifetimes separated and small.
+    // NOTE: First, we write the build info file and its output to the cache
+    // directory. Once both are successfully written, we move them to the
+    // artifacts directory sequentially, ensuring the build info file is moved
+    // last. This approach minimizes the risk of having corrupted build info
+    // files in the artifacts directory and ensures other processes, like
+    // `hardhat node`, can safely monitor the build info file as an indicator
+    // for build completion.
     await Promise.all([
       (async () => {
         const buildInfo = await getBuildInfo(runnableCompilationJob);
 
         // TODO: Maybe formatting the build info is slow, but it's mostly
         // strings, so it probably shouldn't be a problem.
-        await writeJsonFile(buildInfoPath, buildInfo);
+        await writeJsonFile(buildInfoCachePath, buildInfo);
       })(),
       (async () => {
         const buildInfoOutput = await getBuildInfoOutput(
@@ -768,10 +782,24 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem {
         // TODO: Earlier in the build process, very similar files are created on disk by the
         // Compiler.  Instead of creating them again, we should consider copying/moving them.
         // This would require changing the format of the build info output file.
-        await writeJsonFileAsStream(buildInfoOutputPath, buildInfoOutput);
+        await writeJsonFileAsStream(buildInfoOutputCachePath, buildInfoOutput);
       })(),
     ]);
 
+    const buildInfoDirPath = path.join(artifactsDirectory, `build-info`);
+
+    await ensureDir(buildInfoDirPath);
+
+    const buildInfoPath = path.join(buildInfoDirPath, `${buildInfoId}.json`);
+
+    const buildInfoOutputPath = path.join(
+      buildInfoDirPath,
+      `${buildInfoId}.output.json`,
+    );
+
+    await move(buildInfoOutputCachePath, buildInfoOutputPath);
+    await move(buildInfoCachePath, buildInfoPath);
+
     return {
       artifactsPerFile,
       buildInfoPath,


Test patch:
diff --git a/v-next/hardhat/test/internal/builtin-plugins/node/artifacts/build-info-watcher.ts b/v-next/hardhat/test/internal/builtin-plugins/node/artifacts/build-info-watcher.ts
new file mode 100644
index 00000000000..d2062e52278
--- /dev/null
+++ b/v-next/hardhat/test/internal/builtin-plugins/node/artifacts/build-info-watcher.ts
@@ -0,0 +1,42 @@
+import assert from "node:assert/strict";
+import path from "node:path";
+import { beforeEach, describe, it, mock } from "node:test";
+
+import { useTmpDir } from "@nomicfoundation/hardhat-test-utils";
+
+import { listener } from "../../../../../src/internal/builtin-plugins/node/artifacts/build-info-watcher.js";
+
+describe("build-info-watcher", function () {
+  useTmpDir();
+
+  let buildInfoDirPath: string;
+
+  beforeEach(async () => {
+    buildInfoDirPath = process.cwd();
+  });
+
+  describe("listener", async () => {
+    it("should not call the handler when a build info output file is added", async function () {
+      const buildId = "123";
+
+      const handler = mock.fn(async () => {});
+
+      await listener(
+        handler,
+        path.join(buildInfoDirPath, `${buildId}.output.json`),
+      );
+
+      assert.equal(handler.mock.callCount(), 0);
+    });
+
+    it("should call the handler when a build info file is added", async function () {
+      const buildId = "123";
+
+      const handler = mock.fn(async () => {});
+
+      await listener(handler, path.join(buildInfoDirPath, `${buildId}.json`));
+
+      assert.equal(handler.mock.callCount(), 1);
+    });
+  });
+});