Skip to content

Commit fbf661f

Browse files
committed
Add signal option
Fixes #53
1 parent 098ab91 commit fbf661f

File tree

4 files changed

+140
-49
lines changed

4 files changed

+140
-49
lines changed

index.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ export type Options = {
119119
```
120120
*/
121121
readonly onProgress?: (progress: ProgressData) => void;
122+
123+
/**
124+
Abort signal to cancel the copy operation.
125+
*/
126+
readonly signal?: AbortSignal;
122127
} & Readonly<GlobOptions> & CopyFileOptions;
123128

124129
export type ProgressData = {

index.js

Lines changed: 69 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,49 @@ export default function cpy(
214214
...options,
215215
};
216216

217+
/**
218+
@param {GlobPattern[]} patterns
219+
@param {string[]} ignorePatterns
220+
@returns {Promise<Entry[]>}
221+
*/
222+
const getEntries = async (patterns, ignorePatterns) => {
223+
let entries = [];
224+
225+
for (const pattern of patterns) {
226+
/**
227+
@type {string[]}
228+
*/
229+
let matches = [];
230+
231+
try {
232+
matches = pattern.getMatches();
233+
} catch (error) {
234+
options.signal?.throwIfAborted();
235+
throw new CpyError(`Cannot glob \`${pattern.originalPath}\`: ${error.message}`, {cause: error});
236+
}
237+
238+
if (matches.length === 0 && !isDynamicPattern(pattern.originalPath) && !isDynamicPattern(ignorePatterns)) {
239+
throw new CpyError(`Cannot copy \`${pattern.originalPath}\`: the file doesn't exist`);
240+
}
241+
242+
for (const sourcePath of matches) {
243+
entries.push(new Entry(sourcePath, path.relative(options.cwd, sourcePath), pattern));
244+
}
245+
}
246+
247+
if (options.filter !== undefined) {
248+
entries = await pFilter(entries, options.filter, {
249+
concurrency: 1024,
250+
signal: options.signal,
251+
});
252+
}
253+
254+
return entries;
255+
};
256+
217257
const promise = (async () => {
258+
options.signal?.throwIfAborted();
259+
218260
/**
219261
@type {Entry[]}
220262
*/
@@ -236,31 +278,7 @@ export default function cpy(
236278

237279
patterns = patterns.map(pattern => new GlobPattern(pattern, destination, {...options, ignore}));
238280

239-
for (const pattern of patterns) {
240-
/**
241-
@type {string[]}
242-
*/
243-
let matches = [];
244-
245-
try {
246-
matches = pattern.getMatches();
247-
} catch (error) {
248-
throw new CpyError(`Cannot glob \`${pattern.originalPath}\`: ${error.message}`, {cause: error});
249-
}
250-
251-
if (matches.length === 0 && !isDynamicPattern(pattern.originalPath) && !isDynamicPattern(ignore)) {
252-
throw new CpyError(`Cannot copy \`${pattern.originalPath}\`: the file doesn't exist`);
253-
}
254-
255-
entries = [
256-
...entries,
257-
...matches.map(sourcePath => new Entry(sourcePath, path.relative(options.cwd, sourcePath), pattern)),
258-
];
259-
}
260-
261-
if (options.filter !== undefined) {
262-
entries = await pFilter(entries, options.filter, {concurrency: 1024});
263-
}
281+
entries = await getEntries(patterns, ignore);
264282

265283
if (entries.length === 0) {
266284
const progressData = {
@@ -306,7 +324,7 @@ export default function cpy(
306324

307325
const progressData = {
308326
totalFiles: entries.length,
309-
percent: entries.length === 0 ? 0 : completedFiles / entries.length,
327+
percent: completedFiles / entries.length,
310328
completedFiles,
311329
completedSize,
312330
sourcePath: event.sourcePath,
@@ -321,33 +339,35 @@ export default function cpy(
321339
}
322340
};
323341

324-
return pMap(
325-
entries,
326-
async entry => {
327-
let to = preprocessDestinationPath({
328-
entry,
329-
destination,
330-
options,
331-
});
342+
const copyFileMapper = async entry => {
343+
let to = preprocessDestinationPath({
344+
entry,
345+
destination,
346+
options,
347+
});
332348

333-
// Apply rename after computing the base destination path
334-
to = renameFile(to, options.rename);
349+
// Apply rename after computing the base destination path
350+
to = renameFile(to, options.rename);
335351

336-
// Check for self-copy after rename has been applied
337-
if (isSelfCopy(entry.path, to)) {
338-
throw new CpyError(`Refusing to copy to itself: \`${entry.path}\``);
339-
}
352+
// Check for self-copy after rename has been applied
353+
if (isSelfCopy(entry.path, to)) {
354+
throw new CpyError(`Refusing to copy to itself: \`${entry.path}\``);
355+
}
340356

341-
try {
342-
await copyFile(entry.path, to, {...options, onProgress: fileProgressHandler});
343-
} catch (error) {
344-
throw new CpyError(`Cannot copy from \`${entry.relativePath}\` to \`${to}\`: ${error.message}`, {cause: error});
345-
}
357+
try {
358+
await copyFile(entry.path, to, {...options, onProgress: fileProgressHandler});
359+
} catch (error) {
360+
options.signal?.throwIfAborted();
361+
throw new CpyError(`Cannot copy from \`${entry.relativePath}\` to \`${to}\`: ${error.message}`, {cause: error});
362+
}
363+
364+
return to;
365+
};
346366

347-
return to;
348-
},
349-
{concurrency},
350-
);
367+
return pMap(entries, copyFileMapper, {
368+
concurrency,
369+
signal: options.signal,
370+
});
351371
})();
352372

353373
promise.on = (_eventName, callback) => {

readme.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,12 @@ await cpy('foo', 'destination', {
173173
});
174174
```
175175

176+
##### signal
177+
178+
Type: [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal)
179+
180+
Abort signal to cancel the copy operation.
181+
176182
##### Source file object
177183

178184
###### path

test.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,3 +1111,63 @@ test('glob with flat and absolute destination', async t => {
11111111
t.true(fs.existsSync(path.join(absDest, 'b.js')));
11121112
t.false(fs.existsSync(path.join(absDest, 'c.ts')));
11131113
});
1114+
1115+
test('signal option allows aborting copy operation', async t => {
1116+
fs.mkdirSync(t.context.tmp);
1117+
fs.mkdirSync(path.join(t.context.tmp, 'source'));
1118+
fs.mkdirSync(path.join(t.context.tmp, 'dest'));
1119+
1120+
// Create many files to increase chance of catching the abort
1121+
for (let index = 0; index < 100; index++) {
1122+
fs.writeFileSync(
1123+
path.join(t.context.tmp, 'source', `file${index}.txt`),
1124+
'x'.repeat(10_000),
1125+
);
1126+
}
1127+
1128+
const controller = new AbortController();
1129+
1130+
setTimeout(() => {
1131+
controller.abort();
1132+
}, 10);
1133+
1134+
await t.throwsAsync(
1135+
cpy('source/*', path.join(t.context.tmp, 'dest'), {
1136+
cwd: t.context.tmp,
1137+
signal: controller.signal,
1138+
}),
1139+
{name: 'AbortError'},
1140+
);
1141+
});
1142+
1143+
test('signal option throws when already aborted', async t => {
1144+
fs.mkdirSync(t.context.tmp);
1145+
fs.writeFileSync(path.join(t.context.tmp, 'source.txt'), 'content');
1146+
1147+
const controller = new AbortController();
1148+
controller.abort();
1149+
1150+
await t.throwsAsync(
1151+
cpy('source.txt', t.context.tmp, {
1152+
cwd: t.context.tmp,
1153+
signal: controller.signal,
1154+
}),
1155+
{name: 'AbortError'},
1156+
);
1157+
});
1158+
1159+
test('signal option works with custom abort reason', async t => {
1160+
fs.mkdirSync(t.context.tmp);
1161+
fs.writeFileSync(path.join(t.context.tmp, 'source.txt'), 'content');
1162+
1163+
const controller = new AbortController();
1164+
const customReason = new Error('Custom abort reason');
1165+
controller.abort(customReason);
1166+
1167+
const error = await t.throwsAsync(cpy('source.txt', t.context.tmp, {
1168+
cwd: t.context.tmp,
1169+
signal: controller.signal,
1170+
}));
1171+
1172+
t.is(error, customReason);
1173+
});

0 commit comments

Comments
 (0)