@effectful/cc
Version:
Multi-prompt delimited continuations runtime
273 lines (264 loc) • 8.17 kB
JavaScript
;
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;
}