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'))