Repository: streamich/memfs (ts)

Issue:
Support Stream as source in Promises writeFile
Currently, it is not possible to write a stream to file using `writeFile()` from the Promises API. However, this is supported by the [Node.js Promises API](https://nodejs.org/docs/latest-v20.x/api/fs.html#fspromiseswritefilefile-data-options).

Since the callback API does not support writing streams to file, only the promises API needs to be adjusted. I suggest collecting the stream data into a buffer at `FsPromises.ts` in `writeFile()`. This would only change the promises API and make it compliant to the Node.js Promises API.

Pull request description:
feat: support stream as source in promises version of `writeFile`
Now supporting streams as `writeFile()` data source in the Promises API, which makes it more compliant to the [Node.js Promises API](https://nodejs.org/docs/latest-v20.x/api/fs.html#fspromiseswritefilefile-data-options).

What's still missing is the support for `AsyncIterable` and `Iterable`. I can implement them as well if this PR gets approved.

Closes #1068

Patch:
diff --git a/src/node/FsPromises.ts b/src/node/FsPromises.ts
index 680d614a..21a9551a 100644
--- a/src/node/FsPromises.ts
+++ b/src/node/FsPromises.ts
@@ -1,4 +1,4 @@
-import { promisify } from './util';
+import { isReadableStream, promisify, streamToBuffer } from './util';
 import { constants } from '../constants';
 import type * as opts from './types/options';
 import type * as misc from './types/misc';
@@ -63,13 +63,12 @@ export class FsPromises implements FsPromisesApi {
 
   public readonly writeFile = (
     id: misc.TFileHandle,
-    data: misc.TData,
+    data: misc.TPromisesData,
     options?: opts.IWriteFileOptions,
   ): Promise<void> => {
-    return promisify(this.fs, 'writeFile')(
-      id instanceof this.FileHandle ? id.fd : (id as misc.PathLike),
-      data,
-      options,
+    const dataPromise = isReadableStream(data) ? streamToBuffer(data) : Promise.resolve(data);
+    return dataPromise.then(data =>
+      promisify(this.fs, 'writeFile')(id instanceof this.FileHandle ? id.fd : (id as misc.PathLike), data, options),
     );
   };
 
diff --git a/src/node/types/FsPromisesApi.ts b/src/node/types/FsPromisesApi.ts
index 22ea29df..b8719f5b 100644
--- a/src/node/types/FsPromisesApi.ts
+++ b/src/node/types/FsPromisesApi.ts
@@ -37,5 +37,5 @@ export interface FsPromisesApi {
     filename: misc.PathLike,
     options?: opts.IWatchOptions,
   ): AsyncIterableIterator<{ eventType: string; filename: string | Buffer }>;
-  writeFile(id: misc.TFileHandle, data: misc.TData, options?: opts.IWriteFileOptions): Promise<void>;
+  writeFile(id: misc.TFileHandle, data: misc.TPromisesData, options?: opts.IWriteFileOptions): Promise<void>;
 }
diff --git a/src/node/types/misc.ts b/src/node/types/misc.ts
index 4e82977a..199202fa 100644
--- a/src/node/types/misc.ts
+++ b/src/node/types/misc.ts
@@ -17,6 +17,7 @@ export type TDataOut = string | Buffer; // Data formats we give back to users.
 export type TEncodingExtended = BufferEncoding | 'buffer';
 export type TFileId = PathLike | number; // Number is used as a file descriptor.
 export type TData = TDataOut | ArrayBufferView | DataView; // Data formats users can give us.
+export type TPromisesData = TData | Readable; // Data formats users can give us in the promises API.
 export type TFlags = string | number;
 export type TMode = string | number; // Mode can be a String, although docs say it should be a Number.
 export type TTime = number | string | Date;
diff --git a/src/node/util.ts b/src/node/util.ts
index 26eae115..09e6c682 100644
--- a/src/node/util.ts
+++ b/src/node/util.ts
@@ -6,6 +6,7 @@ import type * as misc from './types/misc';
 import { ENCODING_UTF8, TEncodingExtended } from '../encoding';
 import { bufferFrom } from '../internal/buffer';
 import queueMicrotask from '../queueMicrotask';
+import { Readable } from 'stream';
 
 export const isWin = process.platform === 'win32';
 
@@ -178,6 +179,15 @@ export function validateFd(fd) {
   if (!isFd(fd)) throw TypeError(ERRSTR.FD);
 }
 
+export function streamToBuffer(stream: Readable) {
+  const chunks: any[] = [];
+  return new Promise<Buffer>((resolve, reject) => {
+    stream.on('data', chunk => chunks.push(chunk));
+    stream.on('end', () => resolve(Buffer.concat(chunks)));
+    stream.on('error', reject);
+  });
+}
+
 export function dataToBuffer(data: misc.TData, encoding: string = ENCODING_UTF8): Buffer {
   if (Buffer.isBuffer(data)) return data;
   else if (data instanceof Uint8Array) return bufferFrom(data);
@@ -289,6 +299,16 @@ export function bufferToEncoding(buffer: Buffer, encoding?: TEncodingExtended):
   else return buffer.toString(encoding);
 }
 
+export function isReadableStream(stream): stream is Readable {
+  return (
+    stream !== null &&
+    typeof stream === 'object' &&
+    typeof stream.pipe === 'function' &&
+    typeof stream.on === 'function' &&
+    stream.readable === true
+  );
+}
+
 const isSeparator = (str, i) => {
   let char = str[i];
   return i > 0 && (char === '/' || (isWin && char === '\\'));


Test patch:
diff --git a/src/__tests__/promises.test.ts b/src/__tests__/promises.test.ts
index 6a768b67..8259850e 100644
--- a/src/__tests__/promises.test.ts
+++ b/src/__tests__/promises.test.ts
@@ -1,4 +1,5 @@
 import { Volume } from '../volume';
+import { Readable } from 'stream';
 
 describe('Promises API', () => {
   describe('FileHandle', () => {
@@ -704,6 +705,22 @@ describe('Promises API', () => {
       expect(vol.readFileSync('/foo').toString()).toEqual('bar');
       await fileHandle.close();
     });
+    it('Write data to an existing file using stream as source', async () => {
+      const vol = new Volume();
+      const { promises } = vol;
+      vol.fromJSON({
+        '/foo': '',
+      });
+      const text = 'bar';
+      const stream = new Readable({
+        read() {
+          this.push(text);
+          this.push(null);
+        },
+      });
+      await promises.writeFile('/foo', stream);
+      expect(vol.readFileSync('/foo').toString()).toEqual(text);
+    });
     it('Reject when trying to write on a directory', () => {
       const vol = new Volume();
       const { promises } = vol;