vinyl-fs
vinyl-fs is the filesystem adapter underneath Gulp. Its src() method expands ordered globs and emits Vinyl file objects, while dest() writes those objects back to disk and symlink() creates links. The useful abstraction is that a file carries its path, base directory, contents, stat data, and optional source map through an object-mode stream. That makes it a focused fit for build pipelines that transform many files, but an unnecessarily elaborate replacement for fs/promises when all you need is to copy or edit a few paths.
Use vinyl-fs when Vinyl is already the contract between your build steps. For a new standalone file task, direct filesystem promises plus a glob package are easier to type, test, and explain.
Use it if
- You are building a Gulp-compatible plugin or pipeline that already passes Vinyl file objects between transforms
- You need ordered positive and negative globs feeding a backpressure-aware file stream
- You need to preserve file metadata, source maps, or symlink information while transforming files
- You want one adapter whose destination can be selected separately for every Vinyl file
- You only need to copy, read, or write a handful of files: fs/promises and a glob package avoid the Vinyl object model and 13 direct runtime dependencies
- You want a native ESM package with bundled TypeScript declarations: 4.0.2 exposes a CommonJS index.js, has no exports map, and requires the separate @types/vinyl-fs package for types
- You expect browser support: src(), dest(), and symlink() are Node filesystem streams and have no browser runtime path
- You cannot tolerate content changes on read: src() removes UTF-8 BOMs by default and version 4 also defaults both decoding and encoding to UTF-8 unless configured otherwise
- You need identical metadata behavior across operating systems: dest() skips mode and timestamp restoration on Windows, and directory links default to Windows junctions
Setup reality
Install with npm install vinyl-fs. There are no peer dependencies or native compilation steps, and Node 10.13 or newer satisfies the declared engine, but the package is not a tiny fs wrapper: 4.0.2 installs 13 direct runtime dependencies and centers every operation on object-mode Vinyl streams. It is CommonJS, so use require('vinyl-fs') or import it through your runtime's CommonJS interop. TypeScript users also need npm install -D @types/vinyl-fs because the package publishes no declarations. The first surprise is content handling. src() buffers every file by default, removes a UTF-8 BOM, and version 4 decodes with UTF-8; use buffer: false for streams, removeBOM: false when the marker matters, or encoding: false for untouched binary bytes. Glob order matters because a negative pattern only excludes earlier positive matches. dest() overwrites by default and mutates each Vinyl object's cwd, base, path, stat, and sometimes contents before passing it downstream. Error handling is stream error handling, so await stream/promises.pipeline or subscribe before work begins. Symlinks need extra testing: src() resolves them by default, Windows directory links become junctions by default, and a dangling directory link can be guessed as a file. None of this requires credentials or a config file, but production use does require explicit choices about buffering, encoding, overwrite behavior, glob roots, and platform-specific links.
Patterns
Copy files selected by ordered globscopy-matched-files
const vfs = require('vinyl-fs');
vfs.src(['src/**/*', '!src/**/*.test.js'])
.pipe(vfs.dest('dist'));Negations must follow the positive glob they filter; reversing these two patterns does not exclude the test files.
Await a pipeline and surface stream errorsawait-copy-completion
const { pipeline } = require('node:stream/promises');
const vfs = require('vinyl-fs');
await pipeline(
vfs.src('assets/**/*'),
vfs.dest('public/assets')
);Awaiting pipeline is safer than listening only for finish because it rejects when either the source or destination errors.
Modify buffered Vinyl contentstransform-buffered-files
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 a transform should still check isBuffer() because Vinyl can also carry streams, null contents, or directories.
Keep large file contents as streamsstream-large-files
const vfs = require('vinyl-fs');
vfs.src('videos/**/*.mp4', { buffer: false, encoding: false })
.pipe(vfs.dest('archive'));With buffer: false each file.contents is a paused stream; downstream transforms must explicitly support streaming Vinyl contents.
Inspect matched paths without reading contentslist-without-reading
const vfs = require('vinyl-fs');
const files = vfs.src('src/**/*', { read: false });
files.on('data', (file) => console.log(file.relative));
files.on('error', console.error);read: false sets file.contents to null, and those files cannot later be written by dest() unless a transform supplies contents.
Process only files changed after a timestampcopy-recent-files
const vfs = require('vinyl-fs');
const lastBuild = new Date('2026-08-01T00:00:00Z');
vfs.src('src/**/*', { since: lastBuild })
.pipe(vfs.dest('dist'));Version 4 compares since against the greater of ctime and mtime; keep the build timestamp outside the output tree to avoid feedback loops.
Disable transcoding and BOM removalpreserve-binary-bytes
const vfs = require('vinyl-fs');
vfs.src('fixtures/**/*', {
encoding: false,
removeBOM: false,
}).pipe(vfs.dest('copied', { encoding: false }));Version 4 defaults to UTF-8 decoding and encoding, while src() removes UTF-8 BOMs by default; both switches matter for byte-for-byte copies.
Load and write external source mapswrite-source-maps
const vfs = require('vinyl-fs');
vfs.src('src/**/*.js', { sourcemaps: true })
.pipe(vfs.dest('dist', { sourcemaps: '.' }));The source option loads inline maps and resolves linked maps; a destination string writes separate .map files relative to that path.
Choose a destination for each fileroute-by-file
const vfs = require('vinyl-fs');
vfs.src('src/**/*').pipe(vfs.dest((file) => {
return file.extname === '.css' ? 'dist/styles' : 'dist/assets';
}));dest() mutates cwd, base, path, stat, and sometimes contents before emitting the file downstream, so retain original paths beforehand if needed.
Leave existing destination files untouchedavoid-overwriting
const vfs = require('vinyl-fs');
vfs.src('defaults/**/*')
.pipe(vfs.dest('project', { overwrite: false }));overwrite defaults to true; false is useful for scaffolding but means an existing stale file is silently retained.
Copy links instead of resolving their targetspreserve-symlinks
const vfs = require('vinyl-fs');
vfs.src('tree/**/*', { resolveSymlinks: false })
.pipe(vfs.dest('copy'));Windows directory links become junctions by default, and dangling directory links passed through dest() may be guessed as file links.
Create relative symbolic linkscreate-symlinks
const vfs = require('vinyl-fs');
vfs.src('packages/*', { read: false })
.pipe(vfs.symlink('workspace-links', {
relativeSymlinks: true,
useJunctions: false,
}));Disabling junctions affects Windows directory links; creating links may still require operating-system permissions or developer mode.