UNPKG

@effectful/cc

Version:

Multi-prompt delimited continuations runtime

273 lines (264 loc) 8.17 kB
"use strict"; exports.__esModule = true; exports.addOnCancel = void 0; exports.addOnRollback = addOnRollback; exports.begin = begin; exports.cancel = cancel; exports.commit = commit; exports.currentCancellationCallbacks = currentCancellationCallbacks; exports.deleteOnCancel = void 0; exports.installCancelablePromise = installCancelablePromise; exports.removeOnCancel = void 0; exports.removeOnRollback = removeOnRollback; exports.rollback = rollback; exports.withCancellationCallbacks = withCancellationCallbacks; /** * Zone.js-based cancellation context propagation. * * We keep a cancellation scope as a transaction-local `Set` of callbacks. * Rolling back a transaction drains its set. * * Zone.js is only used for context propagation (like AsyncLocalStorage / * AsyncContext), so we can swap the implementation later without changing the * public API. */ const kInstalled = Symbol.for("@effectful/cancelable/zone/installed"); const kTransaction = Symbol.for("@effectful/cancelable/zone/transaction"); const kZoneTransactionKey = "@effectful/cancelable/transaction"; function ensureZone() { const g = globalThis; if (g.Zone) return g.Zone; // Prefer the Node bundle so `process.nextTick`, timers, etc are patched. try { require("zone.js/node"); } catch (_e) { require("zone.js"); } if (!g.Zone) { throw new Error("zone.js didn't initialize Zone"); } return g.Zone; } function drain(callbacks) { for (const cb of Array.from(callbacks)) { callbacks.delete(cb); try { cb(); } catch (_e) { // ignore cancellation errors } } } function currentCancellationCallbacks() { const tx = currentTransaction(); if (!tx) return undefined; return getTransactionState(tx).callbacks; } function getTransactionState(tx) { const state = tx[kTransaction]; if (!state) { throw new Error("Invalid transaction object"); } return state; } function currentTransaction() { const Zone = globalThis.Zone; if (!Zone) return undefined; return Zone.current.get(kZoneTransactionKey); } function requireTransaction(tx) { if (tx) return tx; const current = currentTransaction(); if (!current) { throw new Error("No active transaction"); } return current; } function rollbackInternal(state) { if (state.done) return; state.done = true; if (state.parentCallbacks && state.parentDrain) { state.parentCallbacks.delete(state.parentDrain); } state.parentDrain = undefined; state.zone.run(() => drain(state.callbacks)); } /** * Begins a nested cancellation "transaction". * * - Creates a fresh cancellation scope (`Set`). * - Registers a drain callback into the parent scope (if any), so parent * cancellation cancels this transaction too. */ function begin() { let name = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : "cancelable:tx"; const Zone = ensureZone(); const parentZone = Zone.current; const parentCallbacks = currentCancellationCallbacks(); const callbacks = new Set(); const tx = {}; const state = { committed: false, parentCallbacks, parentDrain: undefined, callbacks, zone: undefined, done: false }; Object.defineProperty(tx, kTransaction, { value: state }); const zone = parentZone.fork({ name, properties: { [kZoneTransactionKey]: tx } }); state.zone = zone; let parentDrain; if (parentCallbacks) { parentDrain = () => rollbackInternal(state); parentCallbacks.add(parentDrain); } state.parentDrain = parentDrain; tx.run = function run(body) { return zone.run(body); }; return tx; } /** * Commits a transaction. * * This doesn't drain and doesn't detach from the parent, so rolling back the * parent later will still roll back this committed transaction too. * * In zone terms, "moving focus to parent" is done by simply leaving `tx.run`. */ function commit(tx) { const state = getTransactionState(requireTransaction(tx)); if (state.done) { throw new Error("Transaction is already finished"); } if (state.committed) { throw new Error("Transaction is already committed"); } state.committed = true; } /** * Rolls back a transaction. * * - Drains the transaction scope. * - Detaches the transaction from the parent scope. */ function rollback(tx) { const state = getTransactionState(requireTransaction(tx)); if (state.done) { throw new Error("Transaction is already finished"); } rollbackInternal(state); } function addOnRollback(callback) { ensureZone(); const callbacks = currentCancellationCallbacks(); if (!callbacks) { throw new Error("No active transaction"); } callbacks.add(callback); } function removeOnRollback(callback) { ensureZone(); const callbacks = currentCancellationCallbacks(); if (!callbacks) { throw new Error("No active transaction"); } callbacks.delete(callback); } // Compatibility aliases (older naming). const addOnCancel = exports.addOnCancel = addOnRollback; const removeOnCancel = exports.removeOnCancel = removeOnRollback; const deleteOnCancel = exports.deleteOnCancel = removeOnRollback; function cancel(target) { if (target instanceof Set) { drain(target); return; } const callbacks = currentCancellationCallbacks(); if (callbacks) drain(callbacks); } function withCancellationCallbacks(callbacks, body) { const Zone = ensureZone(); const parentZone = Zone.current; // This is the "root" transaction for this async call tree. // Unlike `begin()`, it doesn't link to the parent transaction; it just // installs the provided callbacks set as the current scope. const tx = {}; const state = { committed: false, parentCallbacks: undefined, parentDrain: undefined, callbacks, zone: undefined, done: false }; Object.defineProperty(tx, kTransaction, { value: state }); const zone = parentZone.fork({ name: "cancelable", properties: { [kZoneTransactionKey]: tx } }); state.zone = zone; tx.run = function run(fn) { return zone.run(fn); }; return zone.run(body); } function wrapCombinator(PromiseImpl, name) { const original = PromiseImpl[name]; if (typeof original !== "function") return; PromiseImpl[name] = function patchedCombinator() { for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { args[_key] = arguments[_key]; } const tx = begin("cancelable:" + name); const state = getTransactionState(tx); const base = tx.run(() => original.apply(this, args)); commit(tx); return base.then(value => { rollbackInternal(state); return value; }, error => { rollbackInternal(state); throw error; }); }; } function installCancelablePromise(PromiseImpl) { const BasePromise = PromiseImpl != null ? PromiseImpl : require("promise"); if (BasePromise[kInstalled]) return BasePromise; ensureZone(); Object.defineProperty(BasePromise, kInstalled, { value: true }); const originalThen = BasePromise.prototype.then; BasePromise.prototype.then = function patchedThen(onFulfilled, onRejected) { const Zone = globalThis.Zone; if (!Zone) return originalThen.call(this, onFulfilled, onRejected); // The `promise` library uses `asap/raw` which flushes a *shared* callback // queue behind a single `process.nextTick(flush)`. Callbacks queued from // different Zones before the same flush would otherwise execute under the // Zone that scheduled the flush. Wrapping here binds handlers to the Zone // at attachment time so user code sees the correct cancellation scope. const zone = Zone.current; const wrappedOnFulfilled = typeof onFulfilled === "function" ? zone.wrap(onFulfilled, "Promise.then") : onFulfilled; const wrappedOnRejected = typeof onRejected === "function" ? zone.wrap(onRejected, "Promise.then") : onRejected; return originalThen.call(this, wrappedOnFulfilled, wrappedOnRejected); }; wrapCombinator(BasePromise, "all"); wrapCombinator(BasePromise, "race"); wrapCombinator(BasePromise, "any"); wrapCombinator(BasePromise, "allSettled"); return BasePromise; }