diff --git a/index.d.ts b/index.d.ts index 075093a..bc5bdc6 100644 --- a/index.d.ts +++ b/index.d.ts @@ -21,6 +21,15 @@ export type Options = BaseOptions & { */ readonly stopOnError?: boolean; + /** + When `true`, preserves the caller's stack trace in mapper errors for better debugging. + + This adds the calling stack frames to errors thrown by the mapper function, making it easier to trace where the pMap call originated. However, it has some performance overhead as it captures stack traces upfront. + + @default false + */ + readonly preserveStackTrace?: boolean; + /** You can abort the promises using [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). @@ -53,6 +62,15 @@ export type IterableOptions = BaseOptions & { Default: `options.concurrency` */ readonly backpressure?: number; + + /** + When `true`, preserves the caller's stack trace in mapper errors for better debugging. + + This adds the calling stack frames to errors thrown by the mapper function, making it easier to trace where the pMapIterable call originated. However, it has some performance overhead as it captures stack traces upfront. + + @default false + */ + readonly preserveStackTrace?: boolean; }; type MaybePromise = T | Promise; diff --git a/index.js b/index.js index 1055800..a453f83 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,62 @@ +const preserveStackMarker = Symbol('pMapPreserveStack'); +const noop = () => {}; + +function createStackPreserver() { + const {stack: capturedStack} = new Error('pMap stack capture'); + + if (typeof capturedStack !== 'string') { + return noop; + } + + // Detect stack format across different JavaScript engines + let frameSeparator; + + // Node.js and Chrome: '\n at ' + if (capturedStack.includes('\n at ')) { + frameSeparator = '\n at '; + } else if (capturedStack.includes('@')) { + // Firefox: '\n' (simpler format) + frameSeparator = '\n'; + } else if (capturedStack.includes('\n\t')) { + // Safari/JSC: varies, try common patterns + frameSeparator = '\n\t'; + } else { + // Fallback to generic newline separation + frameSeparator = '\n'; + } + + const firstFrameIndex = capturedStack.indexOf(frameSeparator); + + if (firstFrameIndex === -1) { + return noop; + } + + const secondFrameIndex = capturedStack.indexOf(frameSeparator, firstFrameIndex + frameSeparator.length); + + const preservedStackSuffix = secondFrameIndex === -1 + ? capturedStack.slice(firstFrameIndex) // If only one frame exists, preserve from first frame + : capturedStack.slice(secondFrameIndex); + + return error => { + try { + if (!error || typeof error !== 'object' || error[preserveStackMarker]) { + return; + } + + const {stack} = error; + + if (typeof stack !== 'string') { + return; + } + + error.stack = stack + preservedStackSuffix; + Object.defineProperty(error, preserveStackMarker, {value: true}); + } catch { + // Silently ignore any errors in stack preservation + } + }; +} + export default async function pMap( iterable, mapper, @@ -5,6 +64,7 @@ export default async function pMap( concurrency = Number.POSITIVE_INFINITY, stopOnError = true, signal, + preserveStackTrace = false, } = {}, ) { return new Promise((resolve_, reject_) => { @@ -20,6 +80,8 @@ export default async function pMap( throw new TypeError(`Expected \`concurrency\` to be an integer from 1 and up or \`Infinity\`, got \`${concurrency}\` (${typeof concurrency})`); } + const preserveStack = preserveStackTrace ? createStackPreserver() : noop; + const result = []; const errors = []; const skippedIndexesMap = new Map(); @@ -46,6 +108,7 @@ export default async function pMap( const reject = reason => { isRejected = true; isResolved = true; + preserveStack(reason); reject_(reason); cleanup(); }; @@ -130,6 +193,8 @@ export default async function pMap( resolvingCount--; await next(); } catch (error) { + preserveStack(error); + if (stopOnError) { reject(error); } else { @@ -143,6 +208,7 @@ export default async function pMap( try { await next(); } catch (error) { + preserveStack(error); reject(error); } } @@ -180,6 +246,7 @@ export function pMapIterable( { concurrency = Number.POSITIVE_INFINITY, backpressure = concurrency, + preserveStackTrace = false, } = {}, ) { if (iterable[Symbol.iterator] === undefined && iterable[Symbol.asyncIterator] === undefined) { @@ -198,6 +265,8 @@ export function pMapIterable( throw new TypeError(`Expected \`backpressure\` to be an integer from \`concurrency\` (${concurrency}) and up or \`Infinity\`, got \`${backpressure}\` (${typeof backpressure})`); } + const preserveStack = preserveStackTrace ? createStackPreserver() : noop; + return { async * [Symbol.asyncIterator]() { const iterator = iterable[Symbol.asyncIterator] === undefined ? iterable[Symbol.iterator]() : iterable[Symbol.asyncIterator](); @@ -242,6 +311,7 @@ export function pMapIterable( return {done: false, value: returnValue}; } catch (error) { + preserveStack(error); isDone = true; return {error}; } @@ -253,11 +323,21 @@ export function pMapIterable( trySpawn(); while (promises.length > 0) { - const {error, done, value} = await promises[0]; // eslint-disable-line no-await-in-loop + let nextResult; + + try { + nextResult = await promises[0]; // eslint-disable-line no-await-in-loop + } catch (error) { + preserveStack(error); + throw error; + } + + const {error, done, value} = nextResult; promises.shift(); if (error) { + preserveStack(error); throw error; } diff --git a/test.js b/test.js index 9db3013..8496a0d 100644 --- a/test.js +++ b/test.js @@ -644,3 +644,82 @@ test('pMapIterable - pMapSkip', async t => { 2, ], async value => value)), [1, 2]); }); + +test('mapper error preserves caller stack when opt-in enabled', async t => { + async function runPromisePmap() { + await pMap([ + async () => { + throw new Error('stop'); + }, + ], async mapper => mapper(), {preserveStackTrace: true}); + } + + const error = await t.throwsAsync(runPromisePmap, {message: 'stop'}); + + t.true(error.stack.includes('runPromisePmap')); +}); + +test('mapper error does not preserve stack by default', async t => { + function uniqueFunctionName() { + return pMap([ + () => { + throw new Error('stop'); + }, + ], mapper => mapper()); + } + + const error = await t.throwsAsync(uniqueFunctionName, {message: 'stop'}); + + // Should not include our stack enhancement + t.false(error.stack.includes('uniqueFunctionName')); +}); + +test('aggregate error stacks preserve caller stack when opt-in enabled', async t => { + async function runPromisePmapStopOnErrorFalse() { + await pMap([ + async () => { + throw new Error('first'); + }, + async () => { + throw new Error('second'); + }, + ], async mapper => mapper(), {concurrency: 2, stopOnError: false, preserveStackTrace: true}); + } + + const error = await t.throwsAsync(runPromisePmapStopOnErrorFalse, {instanceOf: AggregateError}); + + t.true(error.stack.includes('runPromisePmapStopOnErrorFalse')); + + for (const innerError of error.errors) { + t.true(innerError.stack.includes('runPromisePmapStopOnErrorFalse')); + } +}); + +test('pMapIterable mapper error preserves caller stack when opt-in enabled', async t => { + async function runPMapIterable() { + await collectAsyncIterable(pMapIterable([ + async () => { + throw new Error('stop'); + }, + ], async mapper => mapper(), {preserveStackTrace: true})); + } + + const error = await t.throwsAsync(runPMapIterable, {message: 'stop'}); + + t.true(error.stack.includes('runPMapIterable')); +}); + +test('pMapIterable mapper error does not preserve stack by default', async t => { + function uniqueIterableFunction() { + return collectAsyncIterable(pMapIterable([ + () => { + throw new Error('stop'); + }, + ], mapper => mapper())); + } + + const error = await t.throwsAsync(uniqueIterableFunction, {message: 'stop'}); + + // Should not include our stack enhancement + t.false(error.stack.includes('uniqueIterableFunction')); +});