Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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> = T | Promise<T>;
Expand Down
82 changes: 81 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,70 @@
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';
}
Comment on lines +14 to +26
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't extensively tested this yet. Will do if we decide to do accept this PR.


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,
{
concurrency = Number.POSITIVE_INFINITY,
stopOnError = true,
signal,
preserveStackTrace = false,
} = {},
) {
return new Promise((resolve_, reject_) => {
Expand All @@ -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();
Expand All @@ -46,6 +108,7 @@ export default async function pMap(
const reject = reason => {
isRejected = true;
isResolved = true;
preserveStack(reason);
reject_(reason);
cleanup();
};
Expand Down Expand Up @@ -130,6 +193,8 @@ export default async function pMap(
resolvingCount--;
await next();
} catch (error) {
preserveStack(error);

if (stopOnError) {
reject(error);
} else {
Expand All @@ -143,6 +208,7 @@ export default async function pMap(
try {
await next();
} catch (error) {
preserveStack(error);
reject(error);
}
}
Expand Down Expand Up @@ -180,6 +246,7 @@ export function pMapIterable(
{
concurrency = Number.POSITIVE_INFINITY,
backpressure = concurrency,
preserveStackTrace = false,
} = {},
) {
if (iterable[Symbol.iterator] === undefined && iterable[Symbol.asyncIterator] === undefined) {
Expand All @@ -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]();
Expand Down Expand Up @@ -242,6 +311,7 @@ export function pMapIterable(

return {done: false, value: returnValue};
} catch (error) {
preserveStack(error);
isDone = true;
return {error};
}
Expand All @@ -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;
}

Expand Down
79 changes: 79 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});