p-throttle
Version:
Throttle promise-returning & async functions
185 lines (149 loc) • 4.58 kB
JavaScript
const states = new WeakMap();
const signalThrottleds = new WeakMap(); // AbortSignal -> {throttleds: Set<WeakRef>, listener: Function}
const finalizationRegistry = new FinalizationRegistry(({signalWeakRef, weakReference}) => {
const signal = signalWeakRef.deref();
if (!signal) {
return; // Signal already GC'd
}
const registration = signalThrottleds.get(signal);
if (registration) {
registration.throttleds.delete(weakReference);
if (registration.throttleds.size === 0) {
// Remove the abort listener when no throttleds remain
signal.removeEventListener('abort', registration.listener);
signalThrottleds.delete(signal);
}
}
});
export default function pThrottle({limit, interval, strict, signal, onDelay}) {
if (!Number.isFinite(limit)) {
throw new TypeError('Expected `limit` to be a finite number');
}
if (!Number.isFinite(interval)) {
throw new TypeError('Expected `interval` to be a finite number');
}
if (limit < 0) {
throw new TypeError('Expected `limit` to be >= 0');
}
if (interval < 0) {
throw new TypeError('Expected `interval` to be >= 0');
}
const state = {
queue: new Map(),
strictTicks: [],
// Track windowed algorithm state so it can be reset on abort
currentTick: 0,
activeCount: 0,
};
function windowedDelay() {
const now = Date.now();
if ((now - state.currentTick) > interval) {
state.activeCount = 1;
state.currentTick = now;
return 0;
}
if (state.activeCount < limit) {
state.activeCount++;
} else {
state.currentTick += interval;
state.activeCount = 1;
}
return state.currentTick - now;
}
function strictDelay() {
const now = Date.now();
// Clear the queue if there's a significant delay since the last execution
if (state.strictTicks.length > 0 && now - state.strictTicks.at(-1) > interval) {
state.strictTicks.length = 0;
}
// If the queue is not full, add the current time and execute immediately
if (state.strictTicks.length < limit) {
state.strictTicks.push(now);
return 0;
}
// Calculate the next execution time based on the first item in the queue
const nextExecutionTime = state.strictTicks[0] + interval;
// Shift the queue and add the new execution time
state.strictTicks.shift();
state.strictTicks.push(nextExecutionTime);
// Calculate the delay for the current execution
return Math.max(0, nextExecutionTime - now);
}
const getDelay = strict ? strictDelay : windowedDelay;
return function_ => {
const throttled = function (...arguments_) {
if (!throttled.isEnabled) {
return (async () => function_.apply(this, arguments_))();
}
let timeoutId;
return new Promise((resolve, reject) => {
const execute = () => {
try {
resolve(function_.apply(this, arguments_));
} catch (error) {
reject(error);
}
state.queue.delete(timeoutId);
};
const delay = getDelay();
if (delay > 0) {
timeoutId = setTimeout(execute, delay);
state.queue.set(timeoutId, reject);
try {
onDelay?.(...arguments_);
} catch {}
} else {
execute();
}
});
};
signal?.throwIfAborted();
if (signal) {
let registration = signalThrottleds.get(signal);
if (!registration) {
registration = {
throttleds: new Set(),
listener: null,
};
registration.listener = () => {
for (const weakReference of registration.throttleds) {
const function_ = weakReference.deref();
if (!function_) {
continue;
}
const functionState = states.get(function_);
if (!functionState) {
continue;
}
for (const timeout of functionState.queue.keys()) {
clearTimeout(timeout);
functionState.queue.get(timeout)(signal.reason);
}
functionState.queue.clear();
functionState.strictTicks.length = 0;
// Reset windowed state so subsequent calls are not artificially delayed
functionState.currentTick = 0;
functionState.activeCount = 0;
}
signalThrottleds.delete(signal);
};
signalThrottleds.set(signal, registration);
signal.addEventListener('abort', registration.listener, {once: true});
}
const weakReference = new WeakRef(throttled);
registration.throttleds.add(weakReference);
finalizationRegistry.register(throttled, {
signalWeakRef: new WeakRef(signal),
weakReference,
});
}
throttled.isEnabled = true;
Object.defineProperty(throttled, 'queueSize', {
get() {
return state.queue.size;
},
});
states.set(throttled, state);
return throttled;
};
}