diff --git a/common/changes/@rushstack/node-core-library/copilot-stream-cache-entry-for-http-plugin_2026-04-05-04-27.json b/common/changes/@rushstack/node-core-library/copilot-stream-cache-entry-for-http-plugin_2026-04-05-04-27.json new file mode 100644 index 00000000000..7c7d4cd77b9 --- /dev/null +++ b/common/changes/@rushstack/node-core-library/copilot-stream-cache-entry-for-http-plugin_2026-04-05-04-27.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add `FileSystem.createReadStream`, `FileSystem.createWriteStream`, and `FileSystem.createWriteStreamAsync` APIs for creating read and write filesystem streams.", + "type": "minor", + "packageName": "@rushstack/node-core-library" + } + ], + "packageName": "@rushstack/node-core-library", + "email": "198982749+Copilot@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/reviews/api/node-core-library.api.md b/common/reviews/api/node-core-library.api.md index c5b2ebe4f9b..2a557ba9e75 100644 --- a/common/reviews/api/node-core-library.api.md +++ b/common/reviews/api/node-core-library.api.md @@ -166,12 +166,15 @@ export class FileSystem { static copyFilesAsync(options: IFileSystemCopyFilesAsyncOptions): Promise; static createHardLink(options: IFileSystemCreateLinkOptions): void; static createHardLinkAsync(options: IFileSystemCreateLinkOptions): Promise; + static createReadStream(filePath: string): FileSystemReadStream; static createSymbolicLinkFile(options: IFileSystemCreateLinkOptions): void; static createSymbolicLinkFileAsync(options: IFileSystemCreateLinkOptions): Promise; static createSymbolicLinkFolder(options: IFileSystemCreateLinkOptions): void; static createSymbolicLinkFolderAsync(options: IFileSystemCreateLinkOptions): Promise; static createSymbolicLinkJunction(options: IFileSystemCreateLinkOptions): void; static createSymbolicLinkJunctionAsync(options: IFileSystemCreateLinkOptions): Promise; + static createWriteStream(filePath: string, options?: IFileSystemCreateWriteStreamOptions): FileSystemWriteStream; + static createWriteStreamAsync(filePath: string, options?: IFileSystemCreateWriteStreamOptions): Promise; static deleteFile(filePath: string, options?: IFileSystemDeleteFileOptions): void; static deleteFileAsync(filePath: string, options?: IFileSystemDeleteFileOptions): Promise; static deleteFolder(folderPath: string): void; @@ -225,9 +228,15 @@ export type FileSystemCopyFilesAsyncFilter = (sourcePath: string, destinationPat // @public export type FileSystemCopyFilesFilter = (sourcePath: string, destinationPath: string) => boolean; +// @public +export type FileSystemReadStream = fs.ReadStream; + // @public export type FileSystemStats = fs.Stats; +// @public +export type FileSystemWriteStream = fs.WriteStream; + // @public export class FileWriter { close(): void; @@ -336,15 +345,18 @@ export interface IFileSystemCreateLinkOptions { newLinkPath: string; } +// @public +export interface IFileSystemCreateWriteStreamOptions extends IFileSystemWriteFileOptionsBase { +} + // @public export interface IFileSystemDeleteFileOptions { throwIfNotExists?: boolean; } // @public -export interface IFileSystemMoveOptions { +export interface IFileSystemMoveOptions extends IFileSystemWriteFileOptionsBase { destinationPath: string; - ensureFolderExists?: boolean; overwrite?: boolean; sourcePath: string; } @@ -367,8 +379,7 @@ export interface IFileSystemUpdateTimeParameters { } // @public -export interface IFileSystemWriteBinaryFileOptions { - ensureFolderExists?: boolean; +export interface IFileSystemWriteBinaryFileOptions extends IFileSystemWriteFileOptionsBase { } // @public @@ -377,6 +388,11 @@ export interface IFileSystemWriteFileOptions extends IFileSystemWriteBinaryFileO encoding?: Encoding; } +// @public (undocumented) +export interface IFileSystemWriteFileOptionsBase { + ensureFolderExists?: boolean; +} + // @public export interface IFileWriterFlags { append?: boolean; diff --git a/libraries/node-core-library/src/FileSystem.ts b/libraries/node-core-library/src/FileSystem.ts index a2ff0b74d2f..0975847f35d 100644 --- a/libraries/node-core-library/src/FileSystem.ts +++ b/libraries/node-core-library/src/FileSystem.ts @@ -28,6 +28,24 @@ export type FileSystemStats = fs.Stats; */ export type FolderItem = fs.Dirent; +/** + * An alias for the Node.js `fs.ReadStream` object. + * + * @remarks + * This avoids the need to import the `fs` package when using the {@link FileSystem} API. + * @public + */ +export type FileSystemReadStream = fs.ReadStream; + +/** + * An alias for the Node.js `fs.WriteStream` object. + * + * @remarks + * This avoids the need to import the `fs` package when using the {@link FileSystem} API. + * @public + */ +export type FileSystemWriteStream = fs.WriteStream; + // The PosixModeBits are intended to be used with bitwise operations. /* eslint-disable no-bitwise */ @@ -44,10 +62,9 @@ export interface IFileSystemReadFolderOptions { } /** - * The options for {@link FileSystem.writeBuffersToFile} * @public */ -export interface IFileSystemWriteBinaryFileOptions { +export interface IFileSystemWriteFileOptionsBase { /** * If true, will ensure the folder is created before writing the file. * @defaultValue false @@ -55,6 +72,12 @@ export interface IFileSystemWriteBinaryFileOptions { ensureFolderExists?: boolean; } +/** + * The options for {@link FileSystem.writeBuffersToFile} + * @public + */ +export interface IFileSystemWriteBinaryFileOptions extends IFileSystemWriteFileOptionsBase {} + /** * The options for {@link FileSystem.writeFile} * @public @@ -95,7 +118,7 @@ export interface IFileSystemReadFileOptions { * The options for {@link FileSystem.move} * @public */ -export interface IFileSystemMoveOptions { +export interface IFileSystemMoveOptions extends IFileSystemWriteFileOptionsBase { /** * The path of the existing object to be moved. * The path may be absolute or relative. @@ -113,12 +136,6 @@ export interface IFileSystemMoveOptions { * @defaultValue true */ overwrite?: boolean; - - /** - * If true, will ensure the folder is created before writing the file. - * @defaultValue false - */ - ensureFolderExists?: boolean; } /** @@ -258,6 +275,12 @@ export interface IFileSystemCopyFilesOptions extends IFileSystemCopyFilesAsyncOp filter?: FileSystemCopyFilesFilter; // narrow the type to exclude FileSystemCopyFilesAsyncFilter } +/** + * The options for {@link FileSystem.createWriteStream} + * @public + */ +export interface IFileSystemCreateWriteStreamOptions extends IFileSystemWriteFileOptionsBase {} + /** * The options for {@link FileSystem.deleteFile} * @public @@ -750,10 +773,11 @@ export class FileSystem { * Writes a text string to a file on disk, overwriting the file if it already exists. * Behind the scenes it uses `fs.writeFileSync()`. * @remarks - * Throws an error if the folder doesn't exist, unless ensureFolder=true. + * Throws an error if the folder doesn't exist, unless {@link IFileSystemWriteFileOptionsBase.ensureFolderExists} + * is set to `true`. * @param filePath - The absolute or relative path of the file. * @param contents - The text that should be written to the file. - * @param options - Optional settings that can change the behavior. Type: `IWriteFileOptions` + * @param options - Optional settings that can change the behavior. */ public static writeFile( filePath: string, @@ -796,7 +820,8 @@ export class FileSystem { * multiple sources. * * @remarks - * Throws an error if the folder doesn't exist, unless ensureFolder=true. + * Throws an error if the folder doesn't exist, unless {@link IFileSystemWriteFileOptionsBase.ensureFolderExists} + * is set to `true`. * @param filePath - The absolute or relative path of the file. * @param contents - The content that should be written to the file. * @param options - Optional settings that can change the behavior. @@ -956,10 +981,11 @@ export class FileSystem { * Writes a text string to a file on disk, appending to the file if it already exists. * Behind the scenes it uses `fs.appendFileSync()`. * @remarks - * Throws an error if the folder doesn't exist, unless ensureFolder=true. + * Throws an error if the folder doesn't exist, unless {@link IFileSystemWriteFileOptionsBase.ensureFolderExists} + * is set to `true`. * @param filePath - The absolute or relative path of the file. * @param contents - The text that should be written to the file. - * @param options - Optional settings that can change the behavior. Type: `IWriteFileOptions` + * @param options - Optional settings that can change the behavior. */ public static appendToFile( filePath: string, @@ -1237,6 +1263,61 @@ export class FileSystem { }); } + /** + * Creates a readable stream for an existing file. + * Behind the scenes it uses `fs.createReadStream()`. + * + * @param filePath - The path to the file. The path may be absolute or relative. + * @returns A new readable stream for the file. + */ + public static createReadStream(filePath: string): FileSystemReadStream { + return FileSystem._wrapException(() => { + return fs.createReadStream(filePath); + }); + } + + /** + * Creates a writable stream for writing to a file. + * Behind the scenes it uses `fs.createWriteStream()`. + * + * @remarks + * Throws an error if the folder doesn't exist, unless {@link IFileSystemWriteFileOptionsBase.ensureFolderExists} + * is set to `true`. + * @param filePath - The path to the file. The path may be absolute or relative. + * @param options - Optional settings that can change the behavior. + * @returns A new writable stream for the file. + */ + public static createWriteStream( + filePath: string, + options?: IFileSystemCreateWriteStreamOptions + ): FileSystemWriteStream { + return FileSystem._wrapException(() => { + if (options?.ensureFolderExists) { + const folderPath: string = nodeJsPath.dirname(filePath); + FileSystem.ensureFolder(folderPath); + } + + return fs.createWriteStream(filePath); + }); + } + + /** + * An async version of {@link FileSystem.createWriteStream}. + */ + public static async createWriteStreamAsync( + filePath: string, + options?: IFileSystemCreateWriteStreamOptions + ): Promise { + return await FileSystem._wrapExceptionAsync(async () => { + if (options?.ensureFolderExists) { + const folderPath: string = nodeJsPath.dirname(filePath); + await FileSystem.ensureFolderAsync(folderPath); + } + + return fs.createWriteStream(filePath); + }); + } + // =============== // LINK OPERATIONS // =============== diff --git a/libraries/node-core-library/src/index.ts b/libraries/node-core-library/src/index.ts index c48a9c950a3..3f4592cf6b1 100644 --- a/libraries/node-core-library/src/index.ts +++ b/libraries/node-core-library/src/index.ts @@ -50,8 +50,11 @@ export { type IFileErrorOptions, type IFileErrorFormattingOptions, FileError } f export { AlreadyExistsBehavior, FileSystem, + type IFileSystemWriteFileOptionsBase, type FileSystemCopyFilesAsyncFilter, type FileSystemCopyFilesFilter, + type FileSystemReadStream, + type FileSystemWriteStream, type FolderItem, type FileSystemStats, type IFileSystemCopyFileBaseOptions, @@ -59,6 +62,7 @@ export { type IFileSystemCopyFilesAsyncOptions, type IFileSystemCopyFilesOptions, type IFileSystemCreateLinkOptions, + type IFileSystemCreateWriteStreamOptions, type IFileSystemDeleteFileOptions, type IFileSystemMoveOptions, type IFileSystemReadFileOptions, diff --git a/libraries/node-core-library/src/test/FileSystem.test.ts b/libraries/node-core-library/src/test/FileSystem.test.ts index 05e257d4917..8aa3432f6c3 100644 --- a/libraries/node-core-library/src/test/FileSystem.test.ts +++ b/libraries/node-core-library/src/test/FileSystem.test.ts @@ -6,6 +6,9 @@ import fs from 'node:fs'; import { FileSystem } from '../FileSystem'; import { PosixModeBits } from '../PosixModeBits'; +// Use a deterministic path inside the project's output folder for temp files +const testTempFolder: string = `${__dirname}/temp`; + describe(FileSystem.name, () => { test(FileSystem.formatPosixModeBits.name, () => { // The PosixModeBits are intended to be used with bitwise operations. @@ -51,4 +54,140 @@ describe(FileSystem.name, () => { } }); }); + + describe(FileSystem.createReadStream.name, () => { + const tempDir: string = `${testTempFolder}/createReadStream`; + + beforeEach(async () => { + await FileSystem.ensureFolderAsync(tempDir); + }); + + afterEach(async () => { + await FileSystem.deleteFolderAsync(tempDir); + }); + + test('returns a readable stream for an existing file', async () => { + const filePath: string = `${tempDir}/test.txt`; + await FileSystem.writeFileAsync(filePath, 'hello world'); + + const stream: fs.ReadStream = FileSystem.createReadStream(filePath); + const chunks: Buffer[] = []; + + for await (const chunk of stream) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + + const result: string = Buffer.concat(chunks).toString(); + expect(result).toBe('hello world'); + }); + + test('stream emits an error for a nonexistent file', async () => { + const filePath: string = `${tempDir}/nonexistent.txt`; + const stream: fs.ReadStream = FileSystem.createReadStream(filePath); + + await expect(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _chunk of stream) { + fail(); + } + }).rejects.toThrow(/ENOENT/); + }); + }); + + describe(FileSystem.createWriteStream.name, () => { + const tempDir: string = `${testTempFolder}/createWriteStream`; + + beforeEach(async () => { + await FileSystem.ensureFolderAsync(tempDir); + }); + + afterEach(async () => { + await FileSystem.deleteFolderAsync(tempDir); + }); + + test('creates a writable stream that writes data to a file', async () => { + const filePath: string = `${tempDir}/output.txt`; + const stream: fs.WriteStream = FileSystem.createWriteStream(filePath); + + await new Promise((resolve, reject) => { + stream.on('error', reject); + stream.write('hello '); + stream.write('world'); + stream.end(() => resolve()); + }); + + const result: string = await FileSystem.readFileAsync(filePath); + expect(result).toBe('hello world'); + }); + + test('emits an error when the parent folder does not exist and ensureFolderExists is not set', async () => { + const filePath: string = `${tempDir}/nonexistent-folder/output.txt`; + const stream: fs.WriteStream = FileSystem.createWriteStream(filePath); + + await expect( + new Promise((resolve, reject) => { + stream.on('error', reject); + stream.on('open', () => resolve()); + }) + ).rejects.toThrow(/ENOENT/); + }); + + test('creates the parent folder when ensureFolderExists is true', async () => { + const filePath: string = `${tempDir}/new-folder/output.txt`; + const stream: fs.WriteStream = FileSystem.createWriteStream(filePath, { + ensureFolderExists: true + }); + + await new Promise((resolve, reject) => { + stream.on('error', reject); + stream.write('test data'); + stream.end(() => resolve()); + }); + + const result: string = await FileSystem.readFileAsync(filePath); + expect(result).toBe('test data'); + }); + }); + + describe(FileSystem.createWriteStreamAsync.name, () => { + const tempDir: string = `${testTempFolder}/createWriteStreamAsync`; + + beforeEach(async () => { + await FileSystem.ensureFolderAsync(tempDir); + }); + + afterEach(async () => { + await FileSystem.deleteFolderAsync(tempDir); + }); + + test('creates a writable stream that writes data to a file', async () => { + const filePath: string = `${tempDir}/output.txt`; + const stream: fs.WriteStream = await FileSystem.createWriteStreamAsync(filePath); + + await new Promise((resolve, reject) => { + stream.on('error', reject); + stream.write('async hello'); + stream.end(() => resolve()); + }); + + const result: string = await FileSystem.readFileAsync(filePath); + expect(result).toBe('async hello'); + }); + + test('creates the parent folder when ensureFolderExists is true', async () => { + const filePath: string = `${tempDir}/new-folder/output.txt`; + const stream: fs.WriteStream = await FileSystem.createWriteStreamAsync(filePath, { + ensureFolderExists: true + }); + + await new Promise((resolve, reject) => { + stream.on('error', reject); + stream.write('async test data'); + stream.end(() => resolve()); + }); + + const result: string = await FileSystem.readFileAsync(filePath); + expect(result).toBe('async test data'); + }); + }); });