@on-the-ground/daemonizer
Version:
A minimal async control flow framework for browser and Node.js daemons.
430 lines (422 loc) • 16.9 kB
JavaScript
import { nanoid } from 'nanoid';
/**
* A standard `DOMException` used to signal that an operation was aborted due to a timeout.
*
* This error uses the `"AbortError"` name so it integrates cleanly with `AbortSignal`-based cancellation.
* It is returned by `withTimeout()` when the specified timeout duration elapses.
*
* @example
* try {
* await withTimeout(doSomething, 1000);
* } catch (err) {
* if (err === timeoutError) {
* console.warn("Operation timed out");
* }
* }
*/
const timeoutError = new DOMException("Timeout", "AbortError");
/**
* Unique symbol used to identify embedded AbortSignal in an object.
* Prevents key collisions when mixing custom objects with abort handling logic.
*/
const SIGNAL_KEY = Symbol("abort-signal");
function isSignalSource(sigSrc) {
return (sigSrc instanceof AbortSignal ||
(typeof sigSrc === "object" &&
sigSrc !== null &&
SIGNAL_KEY in sigSrc &&
sigSrc[SIGNAL_KEY] instanceof AbortSignal));
}
/**
* Merges an external `AbortSignal` with an existing `SignalSource`.
*
* If the `sigSrc` is a raw signal, returns a new merged `AbortSignal`.
* If the `sigSrc` is a wrapped signal object, returns a shallow clone with the embedded signal replaced
* by the merged one.
*
* This is useful for combining timeout signals with externally provided cancellation sources.
*
* @param signal - An `AbortSignal` to merge in (e.g. from timeout or internal controller).
* @param sigSrc - An existing signal or signal-carrying object to merge with.
* @returns A new `SignalSource` of the same type as `sigSrc` with merged cancellation behavior.
*/
function mergeAbortSignal(signal, sigSrc) {
if (sigSrc instanceof AbortSignal) {
return mergeAbortSignals([sigSrc, signal]);
}
const mergedSignal = mergeAbortSignals([sigSrc[SIGNAL_KEY], signal]);
const newSigSrc = Object.create(sigSrc);
newSigSrc[SIGNAL_KEY] = mergedSignal;
return newSigSrc;
}
/**
* Extracts the `AbortSignal` from a `SignalSource`, whether it's direct or embedded.
*
* @param sigSrc - A `SignalSource`, either a raw signal or an object containing one.
* @returns The corresponding `AbortSignal`.
*/
function signalFrom(sigSrc) {
return sigSrc instanceof AbortSignal ? sigSrc : sigSrc[SIGNAL_KEY];
}
/** * Wraps a promise with an AbortSignal to allow it to be aborted.
* If the signal is already aborted, it rejects immediately.
* If the signal aborts while the promise is pending, it rejects with an ErrAborted error.
*
* @param signal - An AbortSignal to monitor for abortion.
* @returns A function that takes a promise and returns a new promise that can be aborted.
*/
const withAbort = (promise, signal) => {
if (!signal)
return promise;
if (signal.aborted)
return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
promise.then((value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
}, (err) => {
signal.removeEventListener("abort", onAbort);
reject(err);
});
});
};
/**
* Runs an async operation with a timeout and optional external AbortSignal.
*
* If the operation does not complete within the given timeout,
* it is aborted with a `timeoutError`. If an external `AbortSignal` is provided
* and it fires first, the operation is also aborted with that signal's reason.
*
* Internally, it merges the external signal (if provided) with an internal timeout-based signal.
* Then it wraps the callback execution with `withAbort()` to ensure proper cancellation handling.
*
* @template T - The type of the resolved value.
* @param callback - An async function that accepts an `AbortSignal` and returns a `Promise<T>`.
* This function is expected to respect the signal and abort early if triggered.
* @param timeout - Timeout in milliseconds after which the operation will be aborted.
* @param extSigSrc - Optional `AbortSignal` to combine with the timeout signal.
* Abortion from either will cancel the operation.
* @returns A `Promise<T>` that resolves if the callback completes within time,
* or rejects with `timeoutError` or the external signal's reason.
*/
const withTimeout = async (callback, timeout, extSigSrc) => {
const timeoutController = new AbortController();
const timeoutId = setTimeout(() => {
timeoutController.abort(timeoutError);
}, timeout);
try {
if (!extSigSrc) {
return await withAbort(callback(timeoutController.signal), timeoutController.signal);
}
const newSigSrc = mergeAbortSignal(timeoutController.signal, extSigSrc);
return await withAbort(callback(newSigSrc), signalFrom(newSigSrc));
}
finally {
clearTimeout(timeoutId);
}
};
function mergeAbortSignals(signals) {
const controller = new AbortController();
const onAbort = () => {
for (const signal of signals) {
if (signal.aborted) {
controller.abort(signal.reason);
break;
}
}
for (const signal of signals) {
signal.removeEventListener("abort", onAbort);
}
};
for (const signal of signals) {
if (signal.aborted) {
controller.abort(signal.reason);
for (const s of signals) {
s.removeEventListener("abort", onAbort);
}
return controller.signal;
}
signal.addEventListener("abort", onAbort);
}
return controller.signal;
}
var _a;
class ErrZeroCapacity extends Error {
constructor() {
super("Capacity must be greater than 0");
this.name = new.target.name;
}
}
const errZeroCapacity = new ErrZeroCapacity();
const Opening = Symbol("Opening");
/** * A bounded queue that allows asynchronous producers and consumers to interact.
* It supports pushing items, closing the queue, and iterating over the items.
* If the queue is full, producers will wait until space is available or fail fast.
* If the queue is empty, consumers will wait until items are available.
* If the queue is closed, consumers will receive a done signal.
*/
class BoundedQueue {
constructor(capacity) {
this.capacity = capacity;
this.buffer = [];
this.waitingConsumers = [];
this.waitingProducers = [];
this.closed = false;
this.tryPushInternal = (value) => {
if (this.waitingConsumers.length > 0) {
const resolve = this.waitingConsumers.shift();
resolve({ value, done: false });
return true;
}
if (this.buffer.length < this.capacity) {
this.buffer.push(value);
return true;
}
return false; // overflow
};
/** * Attempts to push a value into the queue without waiting.
* Returns true if successful, false if the queue is full or closed.
*/
this.tryPush = (value) => {
if (this.closed)
return false;
return this.tryPushInternal(value);
};
/** * Pushes a value into the queue, waiting if necessary until space is available.
* Returns true if successful, false if the queue is closed.
*/
this.push = async (value) => {
while (true) {
if (this.closed)
return false;
if (this.tryPushInternal(value)) {
return true;
}
await new Promise((resolve) => {
this.waitingProducers.push(resolve);
});
}
};
/** * Closes the queue, signaling that no more items will be added.
* All waiting consumers will be resolved with a done signal.
* Waiting producers will be resolved with an Opening signal.
*/
this.close = () => {
if (this.closed)
return;
this.closed = true;
for (const resolve of this.waitingConsumers) {
resolve({ value: undefined, done: true });
}
this.waitingConsumers.length = 0;
for (const resolve of this.waitingProducers) {
resolve(Opening);
}
this.waitingProducers.length = 0;
};
this.nextInternal = () => {
if (this.waitingProducers.length > 0) {
const resolve = this.waitingProducers.shift();
resolve(Opening);
}
if (this.buffer.length > 0) {
return Promise.resolve({ value: this.buffer.shift(), done: false });
}
if (this.closed) {
return Promise.resolve({ value: undefined, done: true });
}
return new Promise((resolve) => {
this.waitingConsumers.push(resolve);
});
};
/** * Returns the next item from the queue, waiting if necessary until an item is available.
* If the queue is closed, it resolves with a done signal.
*/
this.next = async () => {
return await this.nextInternal();
};
/** * Returns an async iterator for the queue, allowing iteration over its items.
* The iterator will yield items until the queue is closed.
*/
this[_a] = () => {
const boundedqueue = this;
return {
async next() {
return await boundedqueue.nextInternal();
},
};
};
if (capacity <= 0)
throw errZeroCapacity;
}
get isClosed() {
return this.closed;
}
get size() {
return this.buffer.length;
}
get isEmpty() {
return this.buffer.length === 0;
}
}
_a = Symbol.asyncIterator;
/**
* MacroTaskYielder is a utility class that allows yielding control back to the event loop
* at specified intervals. This is useful in long-running tasks to prevent blocking the event loop
* and allow other tasks to run.
*/
class MacroTaskYielder {
constructor(interval = 8) {
this.interval = interval;
this.yieldByInterval = async () => {
const now = performance.now();
if (now - this.lastYield < this.interval)
return;
await new Promise((resolve) => setTimeout(() => {
this.lastYield = performance.now();
resolve();
}));
};
this.lastYield = performance.now();
}
}
/**
* Launches an event loop that processes events from an async iterable.
* It handles each event using the provided handler function and yields control
* to allow other macro tasks to run periodically.
*
* @param signal - An AbortSignal to allow cancellation of the loop.
* @param taskGroup - A TaskGroup to manage the lifecycle of the loop.
* @param eventStream - An async iterable of events to process.
* @param handleEvent - A function that processes each event.
* @param loopIntervalMs - Interval in milliseconds used for two purposes:
* (1) to periodically yield control to the JS event loop for fairness,
* (2) if `strictInterval` is enabled, to enforce a per-event processing timeout.
* Defaults to 8ms (roughly 60fps frame budget).
* @param strictInterval - If true, each event handler must complete within
* `loopIntervalMs`, otherwise it is aborted. If false, handlers may take longer
* but yielding still happens at the interval. Defaults to false.
*/
async function launchEventLoop(sigSrc, taskGroup, eventStream, handleEvent, loopIntervalMs = 8, strictInterval = false) {
const yieldScheduler = new MacroTaskYielder(loopIntervalMs);
const iterator = eventStream[Symbol.asyncIterator]();
taskGroup.add(1);
try {
loop: while (true) {
let result;
try {
result = await withAbort(iterator.next(), signalFrom(sigSrc));
}
catch (err) {
if (err instanceof DOMException && err.name === "AbortError")
break;
throw err;
}
if (result.done)
break;
try {
await (strictInterval
? withTimeout((sigSrc) => handleEvent(sigSrc, result.value), loopIntervalMs, sigSrc)
: withAbort(handleEvent(sigSrc, result.value), signalFrom(sigSrc)));
}
catch (err) {
if (err === timeoutError) {
// continue
}
else if (err instanceof DOMException && err.name === "AbortError") {
break loop; // Break the loop iff it is aborted from outside
}
else {
throw err;
}
}
// Yield control to allow other macro tasks to run.
await yieldScheduler.yieldByInterval();
}
}
finally {
taskGroup.done();
}
}
/**
* TaskGroup is a utility class that allows managing a group of tasks
* and waiting for all of them to complete. It is useful for coordinating multiple asynchronous
* operations and ensuring that all tasks are done before proceeding.
*/
class TaskGroup {
constructor() {
this.count = 0;
this.waiters = [];
}
add(n) {
this.count += n;
}
done() {
if (this.count <= 0)
throw new Error("TaskGroup underflow: done() called more times than add()");
this.count -= 1;
if (this.count === 0) {
this.waiters.forEach((r) => r());
this.waiters.length = 0;
}
}
async wait() {
if (this.count === 0)
return Promise.resolve();
return new Promise((resolve) => {
this.waiters.push(resolve);
});
}
}
/** * Daemon is a utility class that manages an event loop processing events
* from a bounded queue. It allows pushing events to the queue and handles them
* asynchronously using a provided handler function.
* It supports graceful shutdown and ensures that all events are processed before closing.
*
* @template E - The type of events to handle.
* @param signal - An AbortSignal to allow cancellation of the event loop.
* @param handleEvent - A function that processes each event. It should return a
* Promise that resolves when the event is handled.
* @param bufferSize - The size of the bounded queue buffer. Defaults to 10.
* @param loopIntervalMs - Interval in milliseconds used for two purposes:
* (1) to periodically yield control to the JS event loop for fairness,
* (2) if `strictInterval` is enabled, to enforce a per-event processing timeout.
* Defaults to 8ms (roughly 60fps frame budget).
* @param strictInterval - If true, each event handler must complete within
* `loopIntervalMs`, otherwise it is aborted. If false, handlers may take longer
* but yielding still happens at the interval. Defaults to false.
*/
class Daemon {
constructor(signalSource, handleEvent, bufferSize = 10, loopIntervalMs = 8, strictInterval = false) {
this.id = nanoid();
/** * Closes the event handler, stopping it from accepting new events.
* It waits for the event loop to finish processing all events before resolving.
*/
this.close = async () => {
this.eventStream.close();
// Wait for the event loop to finish processing.
await this.tg.wait();
};
/** * Pushes an event to the event stream.
* Returns true if the event was successfully pushed, false if the event handler is closed.
*
* @param event - The event to push to the event stream.
*/
this.pushEvent = async (event) => {
return this.eventStream.push(event);
};
if (!isSignalSource(signalSource))
throw new Error("invalid signalSource");
const tg = new TaskGroup();
const eventStream = new BoundedQueue(bufferSize);
// launchEventLoop is intentionally fire-and-forget.
// The lifecycle is tracked via the provided TaskGroup.
launchEventLoop(signalSource, tg, eventStream, handleEvent, loopIntervalMs, strictInterval);
this.eventStream = eventStream;
this.tg = tg;
}
}
export { BoundedQueue, Daemon, ErrZeroCapacity, MacroTaskYielder, SIGNAL_KEY, TaskGroup, errZeroCapacity, isSignalSource, launchEventLoop, mergeAbortSignal, signalFrom, timeoutError, withAbort, withTimeout };
//# sourceMappingURL=index.mjs.map