Skip to content

Commit 4554b50

Browse files
committed
fix: handle large JSON files with compiler outputs as streams
1 parent a1d1df7 commit 4554b50

File tree

6 files changed

+235
-83
lines changed

6 files changed

+235
-83
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

+125
Original file line numberDiff line numberDiff line change
@@ -1,6 +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";
36

7+
import { JSONParser } from "@streamparser/json-node";
8+
import { JsonStreamStringify } from "json-stream-stringify";
9+
410
import { ensureError } from "./error.js";
511
import {
612
FileNotFoundError,
@@ -12,6 +18,7 @@ import {
1218
IsDirectoryError,
1319
DirectoryNotEmptyError,
1420
} from "./errors/fs.js";
21+
import { finished } from "node:stream/promises";
1522

1623
/**
1724
* Determines the canonical pathname for a given path, resolving any symbolic
@@ -187,6 +194,61 @@ 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, "utf8");
216+
} catch (e) {
217+
ensureError<NodeJS.ErrnoException>(e);
218+
219+
if (e.code === "ENOENT") {
220+
throw new FileNotFoundError(absolutePathToFile, e);
221+
}
222+
223+
if (e.code === "EISDIR") {
224+
throw new IsDirectoryError(absolutePathToFile, e);
225+
}
226+
227+
throw new FileSystemAccessError(e.message, e);
228+
}
229+
230+
const readPipeline = fileContents.pipe(parser);
231+
232+
// NOTE: We save only the last result as it contains the fully parsed object
233+
let result: T | undefined;
234+
readPipeline.on("data", ({ value }) => {
235+
result = value;
236+
});
237+
238+
try {
239+
await finished(readPipeline);
240+
} catch (e) {
241+
ensureError(e);
242+
throw new InvalidFileFormatError(absolutePathToFile, e);
243+
}
244+
245+
if (result === undefined) {
246+
throw new InvalidFileFormatError(absolutePathToFile, new Error("No data"));
247+
}
248+
249+
return result;
250+
}
251+
190252
/**
191253
* Writes an object to a JSON file. The encoding used is "utf8" and the file is overwritten.
192254
* If part of the path doesn't exist, it will be created.
@@ -211,6 +273,69 @@ export async function writeJsonFile<T>(
211273
await writeUtf8File(absolutePathToFile, content);
212274
}
213275

276+
/**
277+
* Writes an object to a JSON file as stream. The encoding used is "utf8" and the file is overwritten.
278+
* If part of the path doesn't exist, it will be created.
279+
* This function should be used when stringifying very large JSON objects.
280+
*
281+
* @param absolutePathToFile The path to the file. If the file exists, it will be overwritten.
282+
* @param object The object to write.
283+
* @throws JsonSerializationError if the object can't be serialized to JSON.
284+
* @throws FileSystemAccessError for any other error.
285+
*/
286+
export async function writeJsonFileAsStream<T>(
287+
absolutePathToFile: string,
288+
object: T,
289+
): Promise<void> {
290+
const dirPath = path.dirname(absolutePathToFile);
291+
const dirExists = await exists(dirPath);
292+
if (!dirExists) {
293+
await mkdir(dirPath);
294+
}
295+
296+
let fileContents: WriteStream;
297+
try {
298+
fileContents = createWriteStream(absolutePathToFile, "utf8");
299+
} catch (e) {
300+
ensureError<NodeJS.ErrnoException>(e);
301+
// if the directory was created, we should remove it
302+
if (dirExists === false) {
303+
try {
304+
await remove(dirPath);
305+
// we don't want to override the original error
306+
} catch (err) {}
307+
}
308+
309+
if (e.code === "ENOENT") {
310+
throw new FileNotFoundError(absolutePathToFile, e);
311+
}
312+
313+
// flag "x" has been used and the file already exists
314+
if (e.code === "EEXIST") {
315+
throw new FileAlreadyExistsError(absolutePathToFile, e);
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
*

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

+4-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,13 @@ 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)) ? readJsonFileAsStream<T>(filePath) : undefined;
4444
}
4545

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

0 commit comments

Comments
 (0)