Skip to content

Commit 207da4c

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

File tree

6 files changed

+256
-84
lines changed

6 files changed

+256
-84
lines changed

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 { ReadStream, WriteStream } from "node:fs";
2+
import type { FileHandle } from "node:fs/promises";
3+
14
import fsPromises from "node:fs/promises";
25
import path from "node:path";
6+
import { finished } 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,72 @@ 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+
const parser = new JSONParser();
212+
213+
let fileHandle: FileHandle;
214+
let fileReadStream: ReadStream;
215+
try {
216+
fileHandle = await fsPromises.open(absolutePathToFile, "r");
217+
fileReadStream = fileHandle.createReadStream();
218+
} catch (e) {
219+
ensureError<NodeJS.ErrnoException>(e);
220+
221+
if (e.code === "ENOENT") {
222+
throw new FileNotFoundError(absolutePathToFile, e);
223+
}
224+
225+
if (e.code === "EISDIR") {
226+
throw new IsDirectoryError(absolutePathToFile, e);
227+
}
228+
229+
throw new FileSystemAccessError(e.message, e);
230+
}
231+
232+
const readPipeline = fileReadStream.pipe(parser);
233+
234+
// NOTE: We save only the last result as it contains the fully parsed object
235+
let result: T | undefined;
236+
parser.on("data", ({ value }) => {
237+
result = value;
238+
});
239+
240+
try {
241+
await finished(parser);
242+
} catch (e) {
243+
ensureError(e);
244+
throw new InvalidFileFormatError(absolutePathToFile, e);
245+
}
246+
247+
try {
248+
await finished(readPipeline);
249+
250+
await fileHandle.close();
251+
} catch (e) {
252+
ensureError(e);
253+
throw new FileSystemAccessError(absolutePathToFile, e);
254+
}
255+
256+
if (result === undefined) {
257+
throw new InvalidFileFormatError(absolutePathToFile, new Error("No data"));
258+
}
259+
260+
return result;
261+
}
262+
190263
/**
191264
* Writes an object to a JSON file. The encoding used is "utf8" and the file is overwritten.
192265
* If part of the path doesn't exist, it will be created.
@@ -211,6 +284,63 @@ export async function writeJsonFile<T>(
211284
await writeUtf8File(absolutePathToFile, content);
212285
}
213286

287+
/**
288+
* Writes an object to a JSON file as stream. The encoding used is "utf8" and the file is overwritten.
289+
* If part of the path doesn't exist, it will be created.
290+
* This function should be used when stringifying very large JSON objects.
291+
*
292+
* @param absolutePathToFile The path to the file. If the file exists, it will be overwritten.
293+
* @param object The object to write.
294+
* @throws JsonSerializationError if the object can't be serialized to JSON.
295+
* @throws FileSystemAccessError for any other error.
296+
*/
297+
export async function writeJsonFileAsStream<T>(
298+
absolutePathToFile: string,
299+
object: T,
300+
): Promise<void> {
301+
const dirPath = path.dirname(absolutePathToFile);
302+
const dirExists = await exists(dirPath);
303+
if (!dirExists) {
304+
await mkdir(dirPath);
305+
}
306+
307+
let fileHandle: FileHandle;
308+
let fileWriteStream: WriteStream;
309+
try {
310+
fileHandle = await fsPromises.open(absolutePathToFile, "w");
311+
fileWriteStream = fileHandle.createWriteStream();
312+
} catch (e) {
313+
ensureError<NodeJS.ErrnoException>(e);
314+
// if the directory was created, we should remove it
315+
if (dirExists === false) {
316+
try {
317+
await remove(dirPath);
318+
// we don't want to override the original error
319+
} catch (err) {}
320+
}
321+
322+
throw new FileSystemAccessError(e.message, e);
323+
}
324+
325+
const jsonStream = new JsonStreamStringify(object);
326+
const writePipeline = jsonStream.pipe(fileWriteStream);
327+
328+
try {
329+
await finished(jsonStream);
330+
} catch (e) {
331+
ensureError(e);
332+
throw new JsonSerializationError(absolutePathToFile, e);
333+
}
334+
335+
try {
336+
await finished(writePipeline);
337+
await fileHandle.close();
338+
} catch (e) {
339+
ensureError(e);
340+
throw new FileSystemAccessError(e.message, e);
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);

v-next/hardhat/src/internal/builtin-plugins/solidity/build-system/build-system.ts

+6-10
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
getAllFilesMatching,
2828
readJsonFile,
2929
remove,
30+
writeJsonFile,
31+
writeJsonFileAsStream,
3032
writeUtf8File,
3133
} from "@nomicfoundation/hardhat-utils/fs";
3234
import { shortenPath } from "@nomicfoundation/hardhat-utils/path";
@@ -538,23 +540,17 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem {
538540
(async () => {
539541
const buildInfo = await getBuildInfo(compilationJob);
540542

541-
await writeUtf8File(
542-
buildInfoPath,
543-
// TODO: Maybe formatting the build info is slow, but it's mostly
544-
// strings, so it probably shouldn't be a problem.
545-
JSON.stringify(buildInfo, undefined, 2),
546-
);
543+
// TODO: Maybe formatting the build info is slow, but it's mostly
544+
// strings, so it probably shouldn't be a problem.
545+
await writeJsonFile(buildInfoPath, buildInfo);
547546
})(),
548547
(async () => {
549548
const buildInfoOutput = await getBuildInfoOutput(
550549
compilationJob,
551550
compilerOutput,
552551
);
553552

554-
await writeUtf8File(
555-
buildInfoOutputPath,
556-
JSON.stringify(buildInfoOutput),
557-
);
553+
await writeJsonFileAsStream(buildInfoOutputPath, buildInfoOutput);
558554
})(),
559555
]);
560556

v-next/hardhat/src/internal/builtin-plugins/solidity/build-system/cache.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import {
66
getAllFilesMatching,
77
getFileSize,
88
move,
9-
readJsonFile,
9+
readJsonFileAsStream,
1010
remove,
11-
writeJsonFile,
11+
writeJsonFileAsStream,
1212
} from "@nomicfoundation/hardhat-utils/fs";
1313

1414
export class ObjectCache<T> {
@@ -34,13 +34,15 @@ export class ObjectCache<T> {
3434

3535
// NOTE: We are writing to a temporary file first because the value might
3636
// be large and we don't want to end up with corrupted files in the cache.
37-
await writeJsonFile(tmpPath, value);
37+
await writeJsonFileAsStream(tmpPath, value);
3838
await move(tmpPath, filePath);
3939
}
4040

4141
public async get(key: string): Promise<T | undefined> {
4242
const filePath = path.join(this.#path, `${key}.json`);
43-
return (await exists(filePath)) ? readJsonFile<T>(filePath) : undefined;
43+
return (await exists(filePath))
44+
? readJsonFileAsStream<T>(filePath)
45+
: undefined;
4446
}
4547

4648
public async clean(maxAgeMs?: number, maxSize?: number): Promise<void> {

0 commit comments

Comments
 (0)