Skip to content

Commit c31b112

Browse files
committed
fix: handle large JSON files with compiler outputs as streams
1 parent a352f4b commit c31b112

File tree

8 files changed

+432
-84
lines changed

8 files changed

+432
-84
lines changed

.changeset/proud-walls-pump.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@nomicfoundation/hardhat-utils": patch
3+
"hardhat": patch
4+
---
5+
6+
Started using streams when handling the solc compiler outputs to support compilation of very large codebases where the compilation outputs might exceed the maximum buffer size/string lenght

pnpm-lock.yaml

+25-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

v-next/hardhat-utils/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,12 @@
8080
"typescript-eslint": "7.7.1"
8181
},
8282
"dependencies": {
83+
"@streamparser/json-node": "^0.0.22",
8384
"debug": "^4.3.2",
8485
"env-paths": "^2.2.0",
8586
"ethereum-cryptography": "^2.2.1",
8687
"fast-equals": "^5.0.1",
88+
"json-stream-stringify": "^3.1.6",
8789
"rfdc": "^1.3.1",
8890
"undici": "^6.16.1"
8991
}

v-next/hardhat-utils/src/fs.ts

+133-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
import type { JsonTypes, ParsedElementInfo } from "@streamparser/json-node";
2+
import type { FileHandle } from "node:fs/promises";
3+
14
import fsPromises from "node:fs/promises";
25
import path from "node:path";
6+
import { pipeline } from "node:stream/promises";
7+
8+
import { JSONParser } from "@streamparser/json-node";
9+
import { JsonStreamStringify } from "json-stream-stringify";
310

411
import { ensureError } from "./error.js";
512
import {
@@ -187,6 +194,76 @@ export async function readJsonFile<T>(absolutePathToFile: string): Promise<T> {
187194
}
188195
}
189196

197+
/**
198+
* Reads a JSON file as a stream and parses it. The encoding used is "utf8".
199+
* This function should be used when parsing very large JSON files.
200+
*
201+
* @param absolutePathToFile The path to the file.
202+
* @returns The parsed JSON object.
203+
* @throws FileNotFoundError if the file doesn't exist.
204+
* @throws InvalidFileFormatError if the file is not a valid JSON file.
205+
* @throws IsDirectoryError if the path is a directory instead of a file.
206+
* @throws FileSystemAccessError for any other error.
207+
*/
208+
export async function readJsonFileAsStream<T>(
209+
absolutePathToFile: string,
210+
): Promise<T> {
211+
let fileHandle: FileHandle | undefined;
212+
213+
try {
214+
fileHandle = await fsPromises.open(absolutePathToFile, "r");
215+
216+
const fileReadStream = fileHandle.createReadStream();
217+
218+
// NOTE: We set a separator to disable self-closing to be able to use the parser
219+
// in the stream.pipeline context; see https://github.com/juanjoDiaz/streamparser-json/issues/47
220+
const jsonParser = new JSONParser({
221+
separator: "",
222+
});
223+
224+
const result: T | undefined = await pipeline(
225+
fileReadStream,
226+
jsonParser,
227+
async (
228+
elements: AsyncIterable<ParsedElementInfo.ParsedElementInfo>,
229+
): Promise<any | undefined> => {
230+
let value: JsonTypes.JsonPrimitive | JsonTypes.JsonStruct | undefined;
231+
for await (const element of elements) {
232+
value = element.value;
233+
}
234+
return value;
235+
},
236+
);
237+
238+
if (result === undefined) {
239+
throw new Error("No data");
240+
}
241+
242+
return result;
243+
} catch (e) {
244+
ensureError<NodeJS.ErrnoException>(e);
245+
246+
if (e.code === "ENOENT") {
247+
throw new FileNotFoundError(absolutePathToFile, e);
248+
}
249+
250+
if (e.code === "EISDIR") {
251+
throw new IsDirectoryError(absolutePathToFile, e);
252+
}
253+
254+
// If the code is defined, we assume the error to be related to the file system
255+
if (e.code !== undefined) {
256+
throw new FileSystemAccessError(absolutePathToFile, e);
257+
}
258+
259+
// Otherwise, we assume the error to be related to the file formatting
260+
throw new InvalidFileFormatError(absolutePathToFile, e);
261+
} finally {
262+
// Explicitly closing the file handle to fully release the underlying resources
263+
await fileHandle?.close();
264+
}
265+
}
266+
190267
/**
191268
* Writes an object to a JSON file. The encoding used is "utf8" and the file is overwritten.
192269
* If part of the path doesn't exist, it will be created.
@@ -211,6 +288,59 @@ export async function writeJsonFile<T>(
211288
await writeUtf8File(absolutePathToFile, content);
212289
}
213290

291+
/**
292+
* Writes an object to a JSON file as stream. The encoding used is "utf8" and the file is overwritten.
293+
* If part of the path doesn't exist, it will be created.
294+
* This function should be used when stringifying very large JSON objects.
295+
*
296+
* @param absolutePathToFile The path to the file. If the file exists, it will be overwritten.
297+
* @param object The object to write.
298+
* @throws JsonSerializationError if the object can't be serialized to JSON.
299+
* @throws FileSystemAccessError for any other error.
300+
*/
301+
export async function writeJsonFileAsStream<T>(
302+
absolutePathToFile: string,
303+
object: T,
304+
): Promise<void> {
305+
const dirPath = path.dirname(absolutePathToFile);
306+
const dirExists = await exists(dirPath);
307+
if (!dirExists) {
308+
await mkdir(dirPath);
309+
}
310+
311+
let fileHandle: FileHandle | undefined;
312+
313+
try {
314+
fileHandle = await fsPromises.open(absolutePathToFile, "w");
315+
316+
const jsonStream = new JsonStreamStringify(object);
317+
const fileWriteStream = fileHandle.createWriteStream();
318+
319+
await pipeline(jsonStream, fileWriteStream);
320+
} catch (e) {
321+
ensureError<NodeJS.ErrnoException>(e);
322+
// if the directory was created, we should remove it
323+
if (dirExists === false) {
324+
try {
325+
await remove(dirPath);
326+
// we don't want to override the original error
327+
} catch (err) {}
328+
}
329+
330+
// If the code is defined, we assume the error to be related to the file system
331+
if (e.code !== undefined) {
332+
throw new FileSystemAccessError(e.message, e);
333+
}
334+
335+
// Otherwise, we assume the error to be related to the file formatting
336+
throw new JsonSerializationError(absolutePathToFile, e);
337+
} finally {
338+
// NOTE: Historically, not closing the file handle caused issues on Windows,
339+
// for example, when trying to move the file previously written to by this function
340+
await fileHandle?.close();
341+
}
342+
}
343+
214344
/**
215345
* Reads a file and returns its content as a string. The encoding used is "utf8".
216346
*
@@ -537,7 +667,9 @@ export async function move(source: string, destination: string): Promise<void> {
537667
// On linux, trying to move a non-empty directory will throw ENOTEMPTY,
538668
// while on Windows it will throw EPERM.
539669
if (e.code === "ENOTEMPTY" || e.code === "EPERM") {
540-
throw new DirectoryNotEmptyError(destination, e);
670+
if (await isDirectory(source)) {
671+
throw new DirectoryNotEmptyError(destination, e);
672+
}
541673
}
542674

543675
throw new FileSystemAccessError(e.message, e);

0 commit comments

Comments
 (0)