From 953ab32040adde89f00bf67be163a961001cbb2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:55:49 +0000 Subject: [PATCH] feat: add `this.fs` interface across bundlers --- docs/guide/index.md | 2 ++ package.json | 1 + pnpm-lock.yaml | 12 +++++++ pnpm-workspace.yaml | 1 + src/bun/utils.ts | 2 ++ src/esbuild/utils.ts | 2 ++ src/farm/context.ts | 2 ++ src/rspack/context.ts | 3 ++ src/types.ts | 8 +++++ src/utils/fs.ts | 32 +++++++++++++++++++ src/webpack/context.ts | 3 ++ test/unit-tests/bun/utils.test.ts | 4 +++ test/unit-tests/esbuild/utils.test.ts | 4 +++ test/unit-tests/farm/context.test.ts | 4 +++ test/unit-tests/resolve-id/resolve-id.test.ts | 18 +++++++++-- test/unit-tests/rspack/context.test.ts | 4 +++ test/unit-tests/webpack/context.test.ts | 4 +++ 17 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 src/utils/fs.ts diff --git a/docs/guide/index.md b/docs/guide/index.md index 82f8311c..1a48780e 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -310,6 +310,7 @@ More details can be found in the [Rolldown's documentation](https://rolldown.rs/ | Context | Rollup | Vite | webpack | esbuild | Rspack | Farm | Rolldown | Bun | | ------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :-----: | :----: | :--: | :------: | :-: | | [`this.parse`](https://rollupjs.org/plugin-development/#this-parse)1 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [`this.fs`](https://rollupjs.org/plugin-development/#this-fs)3 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [`this.addWatchFile`](https://rollupjs.org/plugin-development/#this-addwatchfile) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | | [`this.emitFile`](https://rollupjs.org/plugin-development/#this-emitfile)2 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | [`this.getWatchFiles`](https://rollupjs.org/plugin-development/#this-getwatchfiles) | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | @@ -320,6 +321,7 @@ More details can be found in the [Rolldown's documentation](https://rolldown.rs/ 1. For bundlers other than Rollup, Rolldown, or Vite, `setParseImpl` must be called to manually provide a parser implementation. Parsers such as [Acorn](https://github.com/acornjs/acorn), [Babel](https://babeljs.io/), or [Oxc](https://oxc.rs/) can be used. 2. Currently, [`this.emitFile`](https://rollupjs.org/plugin-development/#thisemitfile) only supports the `EmittedAsset` variant. +3. For bundlers without native plugin context file-system APIs, `this.fs` falls back to a Node-compatible implementation. ::: diff --git a/package.json b/package.json index 15579153..1a75281c 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "dependencies": { "@jridgewell/remapping": "catalog:prod", "picomatch": "catalog:prod", + "pify": "catalog:prod", "webpack-virtual-modules": "catalog:prod" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fca8c15b..77b0c4bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,9 @@ catalogs: picomatch: specifier: ^4.0.4 version: 4.0.4 + pify: + specifier: ^6.1.0 + version: 6.1.0 webpack-virtual-modules: specifier: ^0.6.2 version: 0.6.2 @@ -168,6 +171,9 @@ importers: picomatch: specifier: catalog:prod version: 4.0.4 + pify: + specifier: catalog:prod + version: 6.1.0 webpack-virtual-modules: specifier: catalog:prod version: 0.6.2 @@ -4405,6 +4411,10 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pify@6.1.0: + resolution: {integrity: sha512-KocF8ve28eFjjuBKKGvzOBGzG8ew2OqOOSxTTZhirkzH7h3BI1vyzqlR0qbfcDBve1Yzo3FVlWUAtCRrbVN8Fw==} + engines: {node: '>=14.16'} + pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -9549,6 +9559,8 @@ snapshots: pify@4.0.1: {} + pify@6.1.0: {} + pkg-dir@4.2.0: dependencies: find-up: 4.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 131d94b2..41332ce0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -61,6 +61,7 @@ catalogs: prod: '@jridgewell/remapping': ^2.3.5 picomatch: ^4.0.4 + pify: ^6.1.0 webpack-virtual-modules: ^0.6.2 test: diff --git a/src/bun/utils.ts b/src/bun/utils.ts index 7bf2f7df..446b5128 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -2,6 +2,7 @@ import type { Loader, PluginBuilder } from 'bun' import type { UnpluginBuildContext, UnpluginContext, UnpluginMessage } from '../types' import fs from 'node:fs' import path from 'node:path' +import { createBuildContextFs } from '../utils/fs' import { parse } from '../utils/parse' const ExtToLoader: Record = { @@ -30,6 +31,7 @@ export function createBuildContext(build: PluginBuilder): UnpluginBuildContext { const watchFiles: string[] = [] return { + fs: createBuildContextFs(), addWatchFile(file) { watchFiles.push(file) }, diff --git a/src/esbuild/utils.ts b/src/esbuild/utils.ts index 0fb66999..78bfb5b6 100644 --- a/src/esbuild/utils.ts +++ b/src/esbuild/utils.ts @@ -6,6 +6,7 @@ import { Buffer } from 'node:buffer' import fs from 'node:fs' import path from 'node:path' import remapping from '@jridgewell/remapping' +import { createBuildContextFs } from '../utils/fs' import { parse } from '../utils/parse' const ExtToLoader: Record = { @@ -113,6 +114,7 @@ export function createBuildContext(build: PluginBuild): UnpluginBuildContext { const watchFiles: string[] = [] const { initialOptions } = build return { + fs: createBuildContextFs(), parse, addWatchFile() { throw new Error('unplugin/esbuild: addWatchFile outside supported hooks (resolveId, load, transform)') diff --git a/src/farm/context.ts b/src/farm/context.ts index 71114d8d..ee54231a 100644 --- a/src/farm/context.ts +++ b/src/farm/context.ts @@ -2,6 +2,7 @@ import type { CompilationContext } from '@farmfe/core' import type { UnpluginBuildContext, UnpluginContext } from '../types' import { Buffer } from 'node:buffer' import { extname } from 'node:path' +import { createBuildContextFs } from '../utils/fs' import { parse } from '../utils/parse' export function createFarmContext( @@ -9,6 +10,7 @@ export function createFarmContext( currentResolveId?: string, ): UnpluginBuildContext { return { + fs: createBuildContextFs(), parse, addWatchFile(id: string) { diff --git a/src/rspack/context.ts b/src/rspack/context.ts index bc30aa8d..f4df1afc 100644 --- a/src/rspack/context.ts +++ b/src/rspack/context.ts @@ -2,10 +2,13 @@ import type { Compilation, Compiler, LoaderContext } from '@rspack/core' import type { UnpluginBuildContext, UnpluginContext, UnpluginMessage } from '../types' import { Buffer } from 'node:buffer' import { resolve } from 'node:path' +import { createBuildContextFs } from '../utils/fs' import { parse } from '../utils/parse' export function createBuildContext(compiler: Compiler, compilation: Compilation, loaderContext?: LoaderContext, inputSourceMap?: any): UnpluginBuildContext { + const inputFs = loaderContext?.fs ?? compiler.inputFileSystem return { + fs: createBuildContextFs(inputFs), getNativeBuildContext() { return { framework: 'rspack', diff --git a/src/types.ts b/src/types.ts index 83ab4311..d82f2678 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ import type { CompilationContext as FarmCompilationContext, JsPlugin as FarmPlug import type { Compilation as RspackCompilation, Compiler as RspackCompiler, LoaderContext as RspackLoaderContext, RspackPluginInstance } from '@rspack/core' import type { BunPlugin, PluginBuilder as BunPluginBuilder } from 'bun' import type { BuildOptions, Plugin as EsbuildPlugin, Loader, PluginBuild } from 'esbuild' +import type { PathLike, Stats } from 'node:fs' import type { Plugin as RolldownPlugin } from 'rolldown' import type { EmittedAsset, PluginContextMeta as RollupContextMeta, Plugin as RollupPlugin, SourceMapInput } from 'rollup' import type { Plugin as UnloaderPlugin } from 'unloader' @@ -57,6 +58,7 @@ export type NativeBuildContext | { framework: 'bun', build: BunPluginBuilder } export interface UnpluginBuildContext { + fs: UnpluginContextFs addWatchFile: (id: string) => void emitFile: (emittedFile: EmittedAsset) => void getWatchFiles: () => string[] @@ -64,6 +66,12 @@ export interface UnpluginBuildContext { getNativeBuildContext?: (() => NativeBuildContext) | undefined } +export interface UnpluginContextFs { + readFile: (path: PathLike, options?: any) => Promise + stat: (path: PathLike, options?: any) => Promise + lstat: (path: PathLike, options?: any) => Promise +} + export type StringOrRegExp = string | RegExp export type FilterPattern = Arrayable export type StringFilter diff --git a/src/utils/fs.ts b/src/utils/fs.ts new file mode 100644 index 00000000..cbc99934 --- /dev/null +++ b/src/utils/fs.ts @@ -0,0 +1,32 @@ +import type { InputFileSystem } from 'webpack' +import type { UnpluginContextFs } from '../types' +import pify from 'pify' + +// Dynamic import node:fs module since it may not be available in some environments +function createNodeFs(): UnpluginContextFs { + const fsPromiseModule = import('node:fs/promises') + return { + readFile: async (path, options) => { + const fs = await fsPromiseModule + return fs.readFile(path, options) + }, + stat: async (path, options) => { + const fs = await fsPromiseModule + return fs.stat(path, options) + }, + lstat: async (path, options) => { + const fs = await fsPromiseModule + return fs.lstat(path, options) + }, + } +} + +export function createBuildContextFs(inputFs?: InputFileSystem | null): UnpluginContextFs { + const fs = inputFs ? pify(inputFs) : createNodeFs() + + return { + readFile: fs.readFile as UnpluginContextFs['readFile'], + stat: fs.stat as UnpluginContextFs['stat'], + lstat: fs.lstat as UnpluginContextFs['lstat'], + } +} diff --git a/src/webpack/context.ts b/src/webpack/context.ts index 7471d284..bc96dc9f 100644 --- a/src/webpack/context.ts +++ b/src/webpack/context.ts @@ -4,6 +4,7 @@ import { Buffer } from 'node:buffer' import { createRequire } from 'node:module' import { resolve } from 'node:path' import process from 'node:process' +import { createBuildContextFs } from '../utils/fs' import { parse } from '../utils/parse' interface ContextOptions { @@ -31,7 +32,9 @@ export function getSource(fileSource: string | Uint8Array): sources.RawSource { } export function createBuildContext(options: ContextOptions, compiler: Compiler, compilation?: Compilation, loaderContext?: LoaderContext<{ unpluginName: string }>, inputSourceMap?: any): UnpluginBuildContext { + const inputFs = loaderContext?.fs ?? compiler.inputFileSystem return { + fs: createBuildContextFs(inputFs), parse, addWatchFile(id) { options.addWatchFile(resolve(process.cwd(), id)) diff --git a/test/unit-tests/bun/utils.test.ts b/test/unit-tests/bun/utils.test.ts index 983d5ef8..0381f792 100644 --- a/test/unit-tests/bun/utils.test.ts +++ b/test/unit-tests/bun/utils.test.ts @@ -24,6 +24,10 @@ describe('bun utils', () => { expect(context.addWatchFile).toBeInstanceOf(Function) expect(context.getWatchFiles).toBeInstanceOf(Function) expect(context.emitFile).toBeInstanceOf(Function) + expect(context.fs).toBeInstanceOf(Object) + expect(context.fs.readFile).toBeInstanceOf(Function) + expect(context.fs.stat).toBeInstanceOf(Function) + expect(context.fs.lstat).toBeInstanceOf(Function) expect(context.parse).toBeInstanceOf(Function) expect(context.getNativeBuildContext).toBeInstanceOf(Function) }) diff --git a/test/unit-tests/esbuild/utils.test.ts b/test/unit-tests/esbuild/utils.test.ts index 5aa43303..231570da 100644 --- a/test/unit-tests/esbuild/utils.test.ts +++ b/test/unit-tests/esbuild/utils.test.ts @@ -122,6 +122,10 @@ describe('utils', () => { expect(actual.parse).toBeInstanceOf(Function) expect(actual.emitFile).toBeInstanceOf(Function) expect(actual.addWatchFile).toBeInstanceOf(Function) + expect(actual.fs).toBeInstanceOf(Object) + expect(actual.fs.readFile).toBeInstanceOf(Function) + expect(actual.fs.stat).toBeInstanceOf(Function) + expect(actual.fs.lstat).toBeInstanceOf(Function) expect(actual.getNativeBuildContext).toBeInstanceOf(Function) expect(actual.getNativeBuildContext!()).toEqual({ diff --git a/test/unit-tests/farm/context.test.ts b/test/unit-tests/farm/context.test.ts index e211610c..e7ccf032 100644 --- a/test/unit-tests/farm/context.test.ts +++ b/test/unit-tests/farm/context.test.ts @@ -12,6 +12,10 @@ describe('createFarmContext', () => { const farmContext = createFarmContext(mockContext) + expect(farmContext.fs).toBeDefined() + expect(farmContext.fs.readFile).toBeInstanceOf(Function) + expect(farmContext.fs.stat).toBeInstanceOf(Function) + expect(farmContext.fs.lstat).toBeInstanceOf(Function) expect(farmContext.parse).toBeDefined() expect(farmContext.parse).toBeInstanceOf(Function) }) diff --git a/test/unit-tests/resolve-id/resolve-id.test.ts b/test/unit-tests/resolve-id/resolve-id.test.ts index 818f2606..7e62187d 100644 --- a/test/unit-tests/resolve-id/resolve-id.test.ts +++ b/test/unit-tests/resolve-id/resolve-id.test.ts @@ -14,20 +14,32 @@ function createUnpluginWithCallback(resolveIdCallback: UnpluginOptions['resolveI } // We extract this check because all bundlers should behave the same -const propsToTest: (keyof (UnpluginContext & UnpluginBuildContext))[] = ['addWatchFile', 'emitFile', 'getWatchFiles', 'parse', 'error', 'warn'] +const propsToTest: (keyof (UnpluginContext & UnpluginBuildContext))[] = ['addWatchFile', 'emitFile', 'getWatchFiles', 'parse', 'error', 'warn', 'fs'] function createResolveIdHook(): Mock { const mockResolveIdHook = vi.fn(function (this: UnpluginContext & UnpluginBuildContext) { for (const prop of propsToTest) { expect(this).toHaveProperty(prop) - expect(this[prop]).toBeInstanceOf(Function) + if (prop === 'fs') { + expect(this.fs).toBeTruthy() + expect(typeof this.fs).toBe('object') + expect(this.fs.readFile).toBeInstanceOf(Function) + expect(this.fs.stat).toBeInstanceOf(Function) + expect(this.fs.lstat).toBeInstanceOf(Function) + } + else { + expect(this[prop]).toBeInstanceOf(Function) + } } }) return mockResolveIdHook } function checkResolveIdHook(resolveIdCallback: Mock): void { - expect.assertions(4 * (1 + propsToTest.length * 2)) + const fsAssertionsPerHookCall = 6 // `toHaveProperty('fs')` + 5 assertions (`toBeTruthy`, `typeof`, `readFile`, `stat`, `lstat`) + const nonFsAssertionsPerHookCall = (propsToTest.length - 1) * 2 + const calledWithAssertionPerHookCall = 1 + expect.assertions(4 * (calledWithAssertionPerHookCall + nonFsAssertionsPerHookCall + fsAssertionsPerHookCall)) expect(resolveIdCallback).toHaveBeenCalledWith( expect.stringMatching(/(?:\/|\\)entry\.js$/), diff --git a/test/unit-tests/rspack/context.test.ts b/test/unit-tests/rspack/context.test.ts index dfce35a5..2622f38e 100644 --- a/test/unit-tests/rspack/context.test.ts +++ b/test/unit-tests/rspack/context.test.ts @@ -10,6 +10,10 @@ describe('createBuildContext', () => { const inputSourceMap = { name: 'inputSourceMap' } const buildContext = createBuildContext(compiler as any, compilation as any, loaderContext as any, inputSourceMap as any) + expect(buildContext.fs).toBeInstanceOf(Object) + expect(buildContext.fs.readFile).toBeInstanceOf(Function) + expect(buildContext.fs.stat).toBeInstanceOf(Function) + expect(buildContext.fs.lstat).toBeInstanceOf(Function) expect(buildContext.getNativeBuildContext!()).toEqual({ framework: 'rspack', diff --git a/test/unit-tests/webpack/context.test.ts b/test/unit-tests/webpack/context.test.ts index 3be96a0b..a28a55ba 100644 --- a/test/unit-tests/webpack/context.test.ts +++ b/test/unit-tests/webpack/context.test.ts @@ -37,6 +37,10 @@ describe('webpack - utils', () => { } as unknown as Compilation const buildContext = createBuildContext(mockOptions, mockCompiler, mockCompilation) + expect(buildContext.fs).toBeInstanceOf(Object) + expect(buildContext.fs.readFile).toBeInstanceOf(Function) + expect(buildContext.fs.stat).toBeInstanceOf(Function) + expect(buildContext.fs.lstat).toBeInstanceOf(Function) buildContext.addWatchFile('file2.js') expect(mockOptions.addWatchFile).toHaveBeenCalledWith(expect.stringContaining('file2.js'))