1
+ import type { ReadStream , WriteStream } from "node:fs" ;
2
+
3
+ import { createReadStream , createWriteStream } from "node:fs" ;
1
4
import fsPromises from "node:fs/promises" ;
2
5
import path from "node:path" ;
3
6
7
+ import { JSONParser } from "@streamparser/json-node" ;
8
+ import { JsonStreamStringify } from "json-stream-stringify" ;
9
+
4
10
import { ensureError } from "./error.js" ;
5
11
import {
6
12
FileNotFoundError ,
@@ -12,6 +18,7 @@ import {
12
18
IsDirectoryError ,
13
19
DirectoryNotEmptyError ,
14
20
} from "./errors/fs.js" ;
21
+ import { finished } from "node:stream/promises" ;
15
22
16
23
/**
17
24
* Determines the canonical pathname for a given path, resolving any symbolic
@@ -187,6 +194,61 @@ export async function readJsonFile<T>(absolutePathToFile: string): Promise<T> {
187
194
}
188
195
}
189
196
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
+
190
252
/**
191
253
* Writes an object to a JSON file. The encoding used is "utf8" and the file is overwritten.
192
254
* If part of the path doesn't exist, it will be created.
@@ -211,6 +273,69 @@ export async function writeJsonFile<T>(
211
273
await writeUtf8File ( absolutePathToFile , content ) ;
212
274
}
213
275
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
+
214
339
/**
215
340
* Reads a file and returns its content as a string. The encoding used is "utf8".
216
341
*
0 commit comments