Skip to content

Commit 7ce2b19

Browse files
committed
fix: handle large JSON files with compiler outputs as streams
1 parent d71693d commit 7ce2b19

File tree

6 files changed

+254
-84
lines changed

6 files changed

+254
-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

+128-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
import type { ReadStream, WriteStream } from "node:fs";
2+
3+
import { createReadStream, createWriteStream } from "node:fs";
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,65 @@ 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 fileContents: ReadStream;
214+
try {
215+
fileContents = createReadStream(absolutePathToFile, {
216+
encoding: "utf8",
217+
autoClose: true,
218+
flags: "r",
219+
});
220+
} catch (e) {
221+
ensureError<NodeJS.ErrnoException>(e);
222+
223+
if (e.code === "ENOENT") {
224+
throw new FileNotFoundError(absolutePathToFile, e);
225+
}
226+
227+
if (e.code === "EISDIR") {
228+
throw new IsDirectoryError(absolutePathToFile, e);
229+
}
230+
231+
throw new FileSystemAccessError(e.message, e);
232+
}
233+
234+
const readPipeline = fileContents.pipe(parser);
235+
236+
// NOTE: We save only the last result as it contains the fully parsed object
237+
let result: T | undefined;
238+
readPipeline.on("data", ({ value }) => {
239+
result = value;
240+
});
241+
242+
try {
243+
await finished(readPipeline);
244+
} catch (e) {
245+
ensureError(e);
246+
throw new InvalidFileFormatError(absolutePathToFile, e);
247+
}
248+
249+
if (result === undefined) {
250+
throw new InvalidFileFormatError(absolutePathToFile, new Error("No data"));
251+
}
252+
253+
return result;
254+
}
255+
190256
/**
191257
* Writes an object to a JSON file. The encoding used is "utf8" and the file is overwritten.
192258
* If part of the path doesn't exist, it will be created.
@@ -211,6 +277,65 @@ export async function writeJsonFile<T>(
211277
await writeUtf8File(absolutePathToFile, content);
212278
}
213279

280+
/**
281+
* Writes an object to a JSON file as stream. The encoding used is "utf8" and the file is overwritten.
282+
* If part of the path doesn't exist, it will be created.
283+
* This function should be used when stringifying very large JSON objects.
284+
*
285+
* @param absolutePathToFile The path to the file. If the file exists, it will be overwritten.
286+
* @param object The object to write.
287+
* @throws JsonSerializationError if the object can't be serialized to JSON.
288+
* @throws FileSystemAccessError for any other error.
289+
*/
290+
export async function writeJsonFileAsStream<T>(
291+
absolutePathToFile: string,
292+
object: T,
293+
): Promise<void> {
294+
const dirPath = path.dirname(absolutePathToFile);
295+
const dirExists = await exists(dirPath);
296+
if (!dirExists) {
297+
await mkdir(dirPath);
298+
}
299+
300+
let fileContents: WriteStream;
301+
try {
302+
fileContents = createWriteStream(absolutePathToFile, {
303+
encoding: "utf8",
304+
autoClose: true,
305+
mode: 0o666,
306+
flags: "w",
307+
});
308+
} catch (e) {
309+
ensureError<NodeJS.ErrnoException>(e);
310+
// if the directory was created, we should remove it
311+
if (dirExists === false) {
312+
try {
313+
await remove(dirPath);
314+
// we don't want to override the original error
315+
} catch (err) {}
316+
}
317+
318+
throw new FileSystemAccessError(e.message, e);
319+
}
320+
321+
const jsonStream = new JsonStreamStringify(object);
322+
const writePipeline = jsonStream.pipe(fileContents);
323+
324+
try {
325+
await finished(jsonStream);
326+
} catch (e) {
327+
ensureError(e);
328+
throw new JsonSerializationError(absolutePathToFile, e);
329+
}
330+
331+
try {
332+
await finished(writePipeline);
333+
} catch (e) {
334+
ensureError(e);
335+
throw new FileSystemAccessError(e.message, e);
336+
}
337+
}
338+
214339
/**
215340
* Reads a file and returns its content as a string. The encoding used is "utf8".
216341
*
@@ -537,7 +662,9 @@ export async function move(source: string, destination: string): Promise<void> {
537662
// On linux, trying to move a non-empty directory will throw ENOTEMPTY,
538663
// while on Windows it will throw EPERM.
539664
if (e.code === "ENOTEMPTY" || e.code === "EPERM") {
540-
throw new DirectoryNotEmptyError(destination, e);
665+
if (await isDirectory(source)) {
666+
throw new DirectoryNotEmptyError(destination, e);
667+
}
541668
}
542669

543670
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";
@@ -557,23 +559,17 @@ export class SolidityBuildSystemImplementation implements SolidityBuildSystem {
557559
(async () => {
558560
const buildInfo = await getBuildInfo(compilationJob);
559561

560-
await writeUtf8File(
561-
buildInfoPath,
562-
// TODO: Maybe formatting the build info is slow, but it's mostly
563-
// strings, so it probably shouldn't be a problem.
564-
JSON.stringify(buildInfo, undefined, 2),
565-
);
562+
// TODO: Maybe formatting the build info is slow, but it's mostly
563+
// strings, so it probably shouldn't be a problem.
564+
await writeJsonFile(buildInfoPath, buildInfo);
566565
})(),
567566
(async () => {
568567
const buildInfoOutput = await getBuildInfoOutput(
569568
compilationJob,
570569
compilerOutput,
571570
);
572571

573-
await writeUtf8File(
574-
buildInfoOutputPath,
575-
JSON.stringify(buildInfoOutput),
576-
);
572+
await writeJsonFileAsStream(buildInfoOutputPath, buildInfoOutput);
577573
})(),
578574
]);
579575

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)