vinyl-fs review
vinyl-fs is the Node filesystem adapter used by Gulp-style pipelines. src() expands ordered globs and emits object-mode Vinyl files containing path, base, stat, contents, and optional source-map data. dest() writes those objects and passes their updated form downstream, while symlink() creates links. Version 4.0.2 fixes eager globbing so matches are not resolved before the read stream opens. The abstraction pays off when several transforms already speak Vinyl; it is a lot of machinery for ordinary file copies. Our install loaded through require() and ESM interop, but shipped no declarations, and esbuild could not make a browser bundle from the Node filesystem code.
vinyl-fs 4.0.2 installed 48 packages in 5.1 seconds and its browser build failed in our sandbox, which is acceptable only when a Node build pipeline already depends on Vinyl. New standalone file scripts are usually clearer with fs/promises and a glob package.
We installed it
| Install | ✓ · 5.1s | 48 packages on disk · 3 MB |
| Import | ✓ | ESM import works · require() works · CommonJS package |
| Browser | n/a | could not be bundled for the browser (Node-only code, most likely) |
| Types | — | no TypeScript types found |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does vinyl-fs install cleanly?
Yes. In a fresh container with an empty cache, npm install vinyl-fs finished in 5 seconds, leaving 48 packages and 3 MB on disk. npm audit reported no known vulnerabilities.
Can vinyl-fs run in a browser?
Not directly: esbuild could not bundle it for the browser in our run, which normally means it depends on Node built-ins. Use it on the server, or find a browser-targeted alternative.
Does vinyl-fs work with both ESM and CommonJS?
Yes. Both import 'vinyl-fs' and require('vinyl-fs') worked in Node 22 in our run. The package is published as CommonJS.
Does vinyl-fs include TypeScript types?
No type declarations were found in our install, so TypeScript users need their own declarations.
vinyl-fs or fast-glob: which should you use?
fast-glob: Use it to collect paths quickly while your code performs direct reads and writes. vinyl-fs 4.0.2 installed 48 packages in 5.1 seconds and its browser build failed in our sandbox, which is acceptable only when a Node build pipeline already depends on Vinyl.
When should you not use vinyl-fs?
A script only reads, copies, or writes a few paths; fs/promises plus a glob library avoids 14 direct dependencies and the Vinyl object model
Use it if
- A Gulp plugin or build pipeline already exchanges Vinyl file objects
- Ordered positive and negative globs must feed backpressure-aware transforms
- Source maps, stat metadata, symlinks, and file contents need one object contract
- Each incoming file may choose its own destination directory before continuing downstream
- A script only reads, copies, or writes a few paths; fs/promises plus a glob library avoids 14 direct dependencies and the Vinyl object model
- The project requires native ESM or bundled TypeScript declarations; 4.0.2 is CommonJS without an exports map or types
- Code must run in a browser; our esbuild browser bundle failed because src(), dest(), and symlink() depend on Node filesystem streams
- Input bytes must remain untouched by default; src() removes UTF-8 BOMs and version 4 defaults read and write encoding to UTF-8
- Filesystem metadata must behave identically on every OS; dest() skips mode and timestamp restoration on Windows and uses junctions for directory links by default
- Downstream code assumes a Vinyl path stays immutable; dest() rewrites cwd, base, path, stat, and sometimes contents before re-emitting each file
Setup reality
Our fresh vinyl-fs 4.0.2 installation took 5.1 seconds on Node 22. npm left 48 packages occupying 3 MB, and audit reported 0 known vulnerabilities. vinyl-fs itself is 172 KB unpacked, declares 14 direct dependencies and 0 peers, and supports Node >=10.13.0. It is CommonJS with no exports map; require() and ESM import both worked through interop. No TypeScript declarations ship. esbuild failed to create a browser bundle, matching a package built entirely around Node filesystem streams.
There are no credentials, native builds, or config files. src() buffers contents by default, strips UTF-8 BOMs, and uses UTF-8 decoding. Set buffer: false for paused content streams, removeBOM: false when the marker is significant, and encoding: false for byte-preserving binary work. A downstream transform must check file.isBuffer(), file.isStream(), or file.isNull() before touching contents. Glob order is semantic: a negative expression only excludes matches from positive globs that appear earlier in the array.
Use node:stream/promises pipeline to await completion and propagate errors from every stage. dest() overwrites by default and changes each Vinyl object's cwd, base, path, stat, and stream position before emitting it again. Save original paths first if a later step needs them. The since option compares against the greater of ctime and mtime in version 4. Source-map loading is opt-in on src(), and dest() needs its own sourcemaps option to emit inline or separate maps.
Symlink behavior needs OS fixtures. src() follows links unless resolveSymlinks is false. On Windows, directory links default to junctions; disabling useJunctions can require developer mode or extra privileges. A dangling directory link passed through dest() may be guessed as a file link because its target cannot be inspected. Version 4.0.2 delays globbing until the read stream opens, fixing work that happened before consumption. For a standalone copy task, fs/promises plus fast-glob is easier to reason about and type.
Patterns
Copy an ordered glob set copy-globs
const vfs = require('vinyl-fs')
vfs.src(['src/**/*', '!src/**/*.test.js'])
.pipe(vfs.dest('dist'))The negative glob must follow its positive source. Putting it first excludes nothing from the later match set.
Await copy completion and errors await-pipeline
const { pipeline } = require('node:stream/promises')
const vfs = require('vinyl-fs')
await pipeline(
vfs.src('assets/**/*'),
vfs.dest('public/assets'),
)pipeline rejects for errors from either stream and closes connected stages. A lone finish listener does not cover source failures.
Edit buffered Vinyl contents transform-buffers
const { Transform } = require('node:stream')
const vfs = require('vinyl-fs')
const replaceVersion = new Transform({
objectMode: true,
transform(file, _encoding, callback) {
if (file.isBuffer()) {
file.contents = Buffer.from(
file.contents.toString().replaceAll('__VERSION__', '4.2.0'),
)
}
callback(null, file)
},
})
vfs.src('templates/**/*').pipe(replaceVersion).pipe(vfs.dest('dist'))src() buffers by default, but Vinyl also represents streams, directories, and files with null contents. Check the content form first.
Keep large contents as streams stream-large-files
const vfs = require('vinyl-fs')
vfs.src('videos/**/*.mp4', { buffer: false, encoding: false })
.pipe(vfs.dest('archive', { encoding: false }))buffer false makes file.contents a paused stream. Every transform in the pipeline must support streaming Vinyl files.
Match paths without reading bytes list-paths
const files = vfs.src('src/**/*', { read: false })
files.on('data', (file) => console.log(file.relative))
files.on('error', console.error)read false sets contents to null. dest() cannot write those entries unless another transform supplies contents.
Select files newer than a build time copy-changed-files
const lastBuild = new Date('2026-08-01T00:00:00Z')
vfs.src('src/**/*', { since: lastBuild })
.pipe(vfs.dest('dist'))Version 4 compares since with the greater of ctime and mtime. Store the build marker outside the output tree to avoid feedback.
Disable text conversion and BOM removal preserve-binary
vfs.src('fixtures/**/*', {
encoding: false,
removeBOM: false,
}).pipe(vfs.dest('copied', { encoding: false }))Version 4 defaults both sides to UTF-8, and src removes UTF-8 BOMs. All 3 settings matter for byte-for-byte copying.
Load and emit external source maps write-source-maps
vfs.src('src/**/*.js', { sourcemaps: true })
.pipe(vfs.dest('dist', { sourcemaps: '.' }))The source option loads inline and linked maps. The destination string writes .map files relative to that location.
Choose a directory per Vinyl file route-files
vfs.src('src/**/*').pipe(vfs.dest((file) => {
return file.extname === '.css' ? 'dist/styles' : 'dist/assets'
}))dest() rewrites cwd, base, path, and stat before downstream emission. Preserve the incoming path if later logic needs it.
Keep an existing destination file avoid-overwrite
vfs.src('defaults/**/*')
.pipe(vfs.dest('project', { overwrite: false }))overwrite defaults to true. false protects user files in a scaffold, but it also leaves an outdated destination silently.
Copy links instead of targets preserve-symlinks
vfs.src('tree/**/*', { resolveSymlinks: false })
.pipe(vfs.dest('copy'))Windows directory links default to junctions. A dangling directory link sent to dest() can be misclassified as a file link.
Create relative directory links create-relative-links
vfs.src('packages/*', { read: false })
.pipe(vfs.symlink('workspace-links', {
relativeSymlinks: true,
useJunctions: false,
}))Turning off junctions changes Windows directory-link behavior and may require developer mode or elevated OS permission.
Alternatives
More utils guides
lru-cache · ajv · type-fest · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

