Skip to content

Commit

Permalink
Clone the file whenever possible
Browse files Browse the repository at this point in the history
Fixes #27
  • Loading branch information
sindresorhus committed Nov 5, 2023
1 parent 8b16c8e commit 58e1009
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 90 deletions.
8 changes: 8 additions & 0 deletions fs.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ export const statSync = path => {
}
};

export const lstatSync = path => {
try {
return fs.statSync(path);
} catch (error) {
throw new CopyFileError(`stat \`${path}\` failed: ${error.message}`, {cause: error});
}
};

export const utimesSync = (path, atime, mtime) => {
try {
return fs.utimesSync(path, atime, mtime);
Expand Down
4 changes: 4 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ Copy a file.
@param destination - Where you want the file copied.
@returns A `Promise` that resolves when the file is copied.
The file is cloned if the `onProgress` option is not passed and the [file system supports it](https://stackoverflow.com/a/76496347/64949).
@example
```
import {copyFile} from 'cp-file';
Expand All @@ -95,6 +97,8 @@ Copy a file synchronously.
@param source - The file you want to copy.
@param destination - Where you want the file copied.
The file is cloned if the [file system supports it](https://stackoverflow.com/a/76496347/64949).
@example
```
import {copyFileSync} from 'cp-file';
Expand Down
165 changes: 85 additions & 80 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,74 +1,25 @@
import path from 'node:path';
import {constants as fsConstants} from 'node:fs';
import realFS, {constants as fsConstants} from 'node:fs';
import realFSPromises from 'node:fs/promises';
import {pEvent} from 'p-event';
import CopyFileError from './copy-file-error.js';
import * as fs from './fs.js';

const copyFileAsync = async (source, destination, options) => {
let readError;
const {size} = await fs.stat(source);
const resolvePath = (cwd, sourcePath, destinationPath) => ({
sourcePath: path.resolve(cwd, sourcePath),
destinationPath: path.resolve(cwd, destinationPath),
});

const readStream = await fs.createReadStream(source);
await fs.makeDirectory(path.dirname(destination), {mode: options.directoryMode});
const writeStream = fs.createWriteStream(destination, {flags: options.overwrite ? 'w' : 'wx'});

const emitProgress = writtenBytes => {
if (typeof options.onProgress !== 'function') {
return;
}

options.onProgress({
sourcePath: path.resolve(source),
destinationPath: path.resolve(destination),
size,
writtenBytes,
percent: writtenBytes === size ? 1 : writtenBytes / size,
const checkSourceIsFile = (stat, source) => {
if (!stat.isFile()) {
throw Object.assign(new CopyFileError(`EISDIR: illegal operation on a directory '${source}'`), {
errno: -21,
code: 'EISDIR',
source,
});
};

readStream.on('data', () => {
emitProgress(writeStream.bytesWritten);
});

readStream.once('error', error => {
readError = new CopyFileError(`Cannot read from \`${source}\`: ${error.message}`, {cause: error});
});

let shouldUpdateStats = false;
try {
const writePromise = pEvent(writeStream, 'close');
readStream.pipe(writeStream);
await writePromise;
emitProgress(size);
shouldUpdateStats = true;
} catch (error) {
throw new CopyFileError(`Cannot write to \`${destination}\`: ${error.message}`, {cause: error});
}

if (readError) {
throw readError;
}

if (shouldUpdateStats) {
const stats = await fs.lstat(source);

return Promise.all([
fs.utimes(destination, stats.atime, stats.mtime),
fs.chmod(destination, stats.mode),
]);
}
};

const resolvePath = (cwd, sourcePath, destinationPath) => {
sourcePath = path.resolve(cwd, sourcePath);
destinationPath = path.resolve(cwd, destinationPath);

return {
sourcePath,
destinationPath,
};
};

export async function copyFile(sourcePath, destinationPath, options = {}) {
if (!sourcePath || !destinationPath) {
throw new CopyFileError('`source` and `destination` required');
Expand All @@ -83,18 +34,74 @@ export async function copyFile(sourcePath, destinationPath, options = {}) {
...options,
};

return copyFileAsync(sourcePath, destinationPath, options);
}
const stats = await fs.lstat(sourcePath);
const {size} = stats;
checkSourceIsFile(stats, sourcePath);
await fs.makeDirectory(path.dirname(destinationPath), {mode: options.directoryMode});

if (typeof options.onProgress === 'function') {
const readStream = await fs.createReadStream(sourcePath);
const writeStream = fs.createWriteStream(destinationPath, {flags: options.overwrite ? 'w' : 'wx'});

const emitProgress = writtenBytes => {
options.onProgress({
sourcePath: path.resolve(sourcePath),
destinationPath: path.resolve(destinationPath),
size,
writtenBytes,
percent: writtenBytes === size ? 1 : writtenBytes / size,
});
};

readStream.on('data', () => {
emitProgress(writeStream.bytesWritten);
});

const checkSourceIsFile = (stat, source) => {
if (stat.isDirectory()) {
throw Object.assign(new CopyFileError(`EISDIR: illegal operation on a directory '${source}'`), {
errno: -21,
code: 'EISDIR',
source,
let readError;

readStream.once('error', error => {
readError = new CopyFileError(`Cannot read from \`${sourcePath}\`: ${error.message}`, {cause: error});
});

let shouldUpdateStats = false;
try {
const writePromise = pEvent(writeStream, 'close');
readStream.pipe(writeStream);
await writePromise;
emitProgress(size);
shouldUpdateStats = true;
} catch (error) {
throw new CopyFileError(`Cannot write to \`${destinationPath}\`: ${error.message}`, {cause: error});
}

if (readError) {
throw readError;
}

if (shouldUpdateStats) {
const stats = await fs.lstat(sourcePath);

return Promise.all([
fs.utimes(destinationPath, stats.atime, stats.mtime),
fs.chmod(destinationPath, stats.mode),
]);
}
} else {
// eslint-disable-next-line no-bitwise
const flags = options.overwrite ? fsConstants.COPYFILE_FICLONE : (fsConstants.COPYFILE_FICLONE | fsConstants.COPYFILE_EXCL);

try {
await realFSPromises.copyFile(sourcePath, destinationPath, flags);

await Promise.all([
realFSPromises.utimes(destinationPath, stats.atime, stats.mtime),
realFSPromises.chmod(destinationPath, stats.mode),
]);
} catch (error) {
throw new CopyFileError(error.message, {cause: error});
}
}
};
}

export function copyFileSync(sourcePath, destinationPath, options = {}) {
if (!sourcePath || !destinationPath) {
Expand All @@ -110,20 +117,18 @@ export function copyFileSync(sourcePath, destinationPath, options = {}) {
...options,
};

const stat = fs.statSync(sourcePath);
checkSourceIsFile(stat, sourcePath);
const stats = fs.lstatSync(sourcePath);
checkSourceIsFile(stats, sourcePath);
fs.makeDirectorySync(path.dirname(destinationPath), {mode: options.directoryMode});

const flags = options.overwrite ? null : fsConstants.COPYFILE_EXCL;
// eslint-disable-next-line no-bitwise
const flags = options.overwrite ? fsConstants.COPYFILE_FICLONE : (fsConstants.COPYFILE_FICLONE | fsConstants.COPYFILE_EXCL);
try {
fs.copyFileSync(sourcePath, destinationPath, flags);
realFS.copyFileSync(sourcePath, destinationPath, flags);
} catch (error) {
if (!options.overwrite && error.code === 'EEXIST') {
return;
}

throw error;
throw new CopyFileError(error.message, {cause: error});
}

fs.utimesSync(destinationPath, stat.atime, stat.mtime);
fs.utimesSync(destinationPath, stats.atime, stats.mtime);
fs.chmod(destinationPath, stats.mode);
}
6 changes: 5 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
## Highlights

- Fast by using streams in the async version and [`fs.copyFileSync()`](https://nodejs.org/api/fs.html#fs_fs_copyfilesync_src_dest_flags) in the synchronous version.
- It's super fast by [cloning](https://stackoverflow.com/questions/71629903/node-js-why-we-should-use-copyfile-ficlone-and-copyfile-ficlone-force-what-is) the file whenever possible.
- Resilient by using [graceful-fs](https://github.com/isaacs/node-graceful-fs).
- User-friendly by creating non-existent destination directories for you.
- Can be safe by turning off [overwriting](#overwrite).
Expand Down Expand Up @@ -32,6 +32,8 @@ console.log('File copied');

Returns a `Promise` that resolves when the file is copied.

The file is cloned if the `onProgress` option is not passed and the [file system supports it](https://stackoverflow.com/a/76496347/64949).

### copyFileSync(source, destination, options?)

#### source
Expand All @@ -40,6 +42,8 @@ Type: `string`

The file you want to copy.

The file is cloned if the [file system supports it](https://stackoverflow.com/a/76496347/64949).

#### destination

Type: `string`
Expand Down
9 changes: 2 additions & 7 deletions test/async.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,16 +111,11 @@ test('do not create `destination` on unreadable `source`', async t => {
});

test('do not create `destination` directory on unreadable `source`', async t => {
const error = await t.throwsAsync(copyFile('node_modules', path.join('subdir', crypto.randomUUID())));
const error = await t.throwsAsync(copyFile('node_modules', path.join('temp/subdir', crypto.randomUUID())));

t.is(error.name, 'CopyFileError', error.message);
t.is(error.code, 'EISDIR', error.message);

t.throws(() => {
fs.statSync('subdir');
}, {
message: /ENOENT/,
});
t.false(fs.existsSync('subdir'));
});

test('preserve timestamps', async t => {
Expand Down
11 changes: 9 additions & 2 deletions test/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,14 @@ test('overwrite when options are undefined', t => {

test('do not overwrite when disabled', t => {
fs.writeFileSync(t.context.destination, '');
copyFileSync('license', t.context.destination, {overwrite: false});

const error = t.throws(() => {
copyFileSync('license', t.context.destination, {overwrite: false});
}, {
name: 'CopyFileError',
});

t.is(error.code, 'EEXIST');
t.is(fs.readFileSync(t.context.destination, 'utf8'), '');
});

Expand Down Expand Up @@ -178,7 +185,7 @@ test.failing('rethrow mkdir EACCES errors', t => {
fs.mkdirSync.restore();
});

test.failing('rethrow ENOSPC errors', t => {
test('rethrow ENOSPC errors', t => {
const noSpaceError = buildENOSPC();

fs.writeFileSync(t.context.source, '');
Expand Down

0 comments on commit 58e1009

Please sign in to comment.