safe-flow
Version:
A safer way to cancel or interrupt the await-async flow.
731 lines (724 loc) • 23.3 kB
JavaScript
import { defaults } from 'custom-defaults';
import { safeAwait, CatchFirst } from 'catch-first';
class Plugins {
constructor() {
this.plugins = [];
}
onState(state, thread) {
this.plugins.forEach(plugin => {
if (plugin) plugin.onState(state, thread);
});
}
add(plugin) {
const i = this.plugins.indexOf(plugin);
if (i < 0) this.plugins.push(plugin);else throw new Error('[safe-flow] This plugin has been added.');
}
remove(plugin) {
const i = this.plugins.indexOf(plugin);
if (i >= 0) this.plugins[i] = undefined;else throw new Error('[safe-flow] Cannot remove unadded plugin.');
}
}
const plugins = new Plugins();
/* istanbul ignore next */
function report() {
return new Error('[safe-flow] Please report this bug to the author.');
}
const lostControlMsg = '[safe-flow] Some child threads have lost control. There should be no unfinished child threads in the completed parent thread.';
function lostControl() {
return new Error(lostControlMsg);
}
const manualBreakMsg = '[safe-flow] In the cancelled thread, no new child threads should be started. When autoBreak is set to false, you must manually end function execution of the cancelled thread.';
function manualBreak() {
return new Error(manualBreakMsg);
}
const cancelSelfErrMsg = '[safe-flow] The called cancelSelf method is not in the currently running thread, or the thread is cancelled when autoBreak is false. Don not call it in other asynchronous callback.';
function cancelSelfErr() {
return new Error(cancelSelfErrMsg);
}
const FlowState = {
canceled: 1,
done: 2
};
const ErrfState = {
error: 1,
canceled: 2,
done: 3
};
let PromiseState = /*#__PURE__*/function (PromiseState) {
PromiseState[PromiseState["pending"] = 0] = "pending";
PromiseState[PromiseState["fulfilled"] = 1] = "fulfilled";
PromiseState[PromiseState["rejected"] = 2] = "rejected";
PromiseState[PromiseState["canceled"] = 3] = "canceled";
return PromiseState;
}({});
class Thread {
get hasChildren() {
return Boolean(this.children.length);
}
get completed() {
return this._completed;
}
get disposed() {
return this._disposed;
}
// get level() {
// return this._level;
// }
levelup() {
this._level++;
}
constructor(func, creator, parent, token, trace, autoBreak = true, name, id, onState, plugins) {
this.func = func;
this.creator = creator;
this.parent = parent;
this.token = token;
this.trace = trace;
this.autoBreak = autoBreak;
this.name = name;
this.id = id;
this.onState = onState;
this.plugins = plugins;
this.promiseState = PromiseState.pending;
this.canceled = false;
this.state = void 0;
this.canceler = void 0;
this.cancellation = void 0;
this.children = [];
this.willCancel = false;
this.willBreak = false;
this._completed = false;
this._disposed = false;
this._level = -1;
this.levelup();
if (__debug_enable_value) __debug_live_threads.push(this);
}
changeState(state, ...args) {
this.state = state;
if (state === TraceState.thread_canceled) {
this.promiseState = PromiseState.canceled;
} else if (state === TraceState.thread_completed) {
this.promiseState = PromiseState.fulfilled;
} else if (state === TraceState.thread_error) {
this.promiseState = PromiseState.rejected;
}
if (!this.disposed) plugins.onState(state, this);
if (this.plugins) this.plugins.forEach(plugin => {
plugin.onState(state, this);
});
if (this.onState) this.onState(state, this);
if (this.trace && this.name) {
/* istanbul ignore else */
if (state === TraceState.thread_starting || state === TraceState.thread_idle || state === TraceState.thread_completed || state === TraceState.thread_disposed) {
tracing(this.trace, {
state,
name: this.name,
id: this.id
});
} else if (state === TraceState.thread_canceled) {
tracing(this.trace, {
state,
name: this.name,
id: this.id,
reason: args[0]
});
} else if (state === TraceState.thread_error) {
tracing(this.trace, {
state,
name: this.name,
id: this.id,
error: args[0]
});
} else if (state === TraceState.thread_done) {
tracing(this.trace, {
state,
name: this.name,
id: this.id,
value: args[0]
});
} else if (state === TraceState.thread_done_canceled) {
tracing(this.trace, {
state,
name: this.name,
id: this.id,
value: args[0]
});
} else {
throw report();
}
}
}
addChild(thread) {
this.children.push(thread);
}
removeChild(thread) {
this.children.splice(this.children.indexOf(thread), 1);
}
complete(dispose = true) {
/* istanbul ignore next */
if (this.completed) throw report();
this._completed = true;
this.changeState(TraceState.thread_completed);
if (this.parent && !this.parent.hasChildren) {
this.parent.levelup();
}
inThread = Boolean(this.parent);
//@ts-expect-error
this.func = undefined;
//@ts-expect-error
this.creator = undefined;
this.parent = undefined;
this.token = undefined;
this.canceler = undefined;
this.cancellation = undefined;
this.onState = undefined;
this.plugins = undefined;
this.children.length = 0;
//@ts-expect-error
this.children = undefined;
if (dispose) this.dispose();
}
dispose() {
/* istanbul ignore next */
if (!this.completed || this.disposed) throw report();
this._disposed = true;
this.changeState(TraceState.thread_disposed);
//@ts-expect-error
this.trace = undefined;
if (__debug_enable_value) {
__debug_live_threads.splice(__debug_live_threads.indexOf(this), 1);
}
}
}
class Canceled {
constructor(creator, thisArg, args, reason) {
this.creator = creator;
this.thisArg = thisArg;
this.args = args;
this.reason = reason;
}
retry() {
return this.creator.apply(this.thisArg, this.args);
}
}
const defConfig = {
trace: false,
standalone: false,
filter: () => false,
development: 'auto'
};
let config = defConfig;
function configure(options) {
if (options) config = defaults(options, config);else config = defConfig;
}
const tokenCreators = new Map();
const creatorThreads = new Map();
let inDead = false;
let inThread = false;
let currentThread;
function setCurrThread(thread) {
if (__debug_enable_value) {
if (!thread) {
logger.log('[safe-flow] Clear the current thread pointer.');
} else if (thread && thread.name) {
logger.log(`[safe-flow] Change the current thread pointer to [${thread.name}].`);
} else {
logger.log('[safe-flow] The current thread pointer has changed.');
}
}
currentThread = thread;
}
function current() {
return inThread ? currentThread : undefined;
}
/* istanbul ignore next */
const logger = {
log: (...args) => {
console.log(...args);
}
};
const __debug_logger = logger;
const __debug_token_creators = tokenCreators;
const __debug_creator_processes = creatorThreads;
const __debug_get_curr_thread = () => {
return currentThread;
};
function __debug_clear_names() {
names = {};
}
function __debug_clear_threads() {
tokenCreators.clear();
creatorThreads.clear();
__debug_live_threads.forEach(thread => {
thread.complete();
});
__debug_live_threads.length = 0;
currentThread = undefined;
}
let __debug_enable_value = false;
function __debug_enable(value) {
__debug_enable_value = value;
}
const __debug_live_threads = [];
function __debug_in_thread() {
return inThread;
}
let TraceState = /*#__PURE__*/function (TraceState) {
TraceState[TraceState["creator_created"] = 0] = "creator_created";
TraceState[TraceState["thread_starting"] = 1] = "thread_starting";
TraceState[TraceState["thread_idle"] = 2] = "thread_idle";
TraceState[TraceState["thread_canceled"] = 3] = "thread_canceled";
TraceState[TraceState["thread_error"] = 4] = "thread_error";
TraceState[TraceState["thread_done"] = 5] = "thread_done";
TraceState[TraceState["thread_done_canceled"] = 6] = "thread_done_canceled";
TraceState[TraceState["thread_completed"] = 7] = "thread_completed";
TraceState[TraceState["thread_disposed"] = 8] = "thread_disposed";
return TraceState;
}({});
const defTrace = event => {
const {
name,
state
} = event;
let status = 'none';
if (event.state === TraceState.thread_canceled) {
status = 'canceled' + (event.reason === undefined ? '' : ` ${event.reason}`);
} else if (event.state === TraceState.thread_error) {
status = 'error' + ` ${event.error}`;
} else if (event.state === TraceState.thread_done) {
status = 'done' + (event.value ? ` ${event.value}` : '');
} else if (event.state === TraceState.thread_done_canceled) {
status = 'done (canceled)' + (event.value ? ` ${event.value}` : '');
} else {
switch (state) {
case TraceState.thread_starting:
status = 'start';
break;
case TraceState.thread_idle:
status = 'idle';
break;
case TraceState.thread_completed:
status = 'completed';
break;
case TraceState.thread_disposed:
status = 'disposed';
break;
}
}
if (event.state === TraceState.creator_created) {
status = ' Creator is created.';
} else {
status = (event.id ? `(${event.id})` : '') + ': ' + status;
}
logger.log(`[safe-flow] [${name}]` + status);
};
function tracing(trace, event) {
if (trace === true) {
defTrace(event);
} else {
trace(event);
}
}
const breakMsg = '[safe-flow] Do not use try/catch and .catch() on threads. This will cause the parent thread to fail to interrupt when it is cancelled. Use .errf() to receive exceptions instead.';
function getBreakMsg() {
return breakMsg;
}
function flow(func, options) {
if (typeof func !== 'function') {
throw new ReferenceError('[safe-flow] The func is not a function.');
}
return internalFlow(func, options);
}
function internalFlow(func, options, thisArg) {
const opts = defaults(options, config);
let {
token
} = opts;
const {
trace,
autoBreak,
name,
standalone,
onState,
plugins
} = opts;
if (trace && name) {
registerName(name);
tracing(trace, {
name,
state: TraceState.creator_created
});
}
return function safe_flow_creator(...args) {
if (inDead) {
throw manualBreak();
}
const parentThread = current();
const id = trace && !standalone && name ? takeNumber(name) : undefined;
token === undefined && (token = thisArg);
let creators = tokenCreators.get(token);
if (creators) {
if (!creators.includes(safe_flow_creator)) {
creators.push(safe_flow_creator);
} else if (standalone) {
throw new Error('[safe-flow] Standalone mode flow only allows one thread to execute for one creator.');
}
} else {
creators = [];
creators.push(safe_flow_creator);
tokenCreators.set(token, creators);
}
!creatorThreads.has(safe_flow_creator) && creatorThreads.set(safe_flow_creator, []);
const threads = creatorThreads.get(safe_flow_creator);
/* istanbul ignore if */
if (!threads) throw report();
const thread = new Thread(func, safe_flow_creator, parentThread, token, trace, autoBreak, name, id, onState, plugins);
threads.push(thread);
thread.changeState(TraceState.thread_starting);
setCurrThread(thread);
Promise.resolve().then(() => {
inThread = false;
});
inThread = true;
let cancelHandlers = [];
const promise = new Promise((resolve, reject) => {
thread.canceler = (reason, isDropout) => {
if (thread.state === TraceState.thread_starting) {
throw new Error('[safe-flow] Do not cancel the thread while starting.');
}
thread.willCancel = true;
cancelChildren(thread, reason);
if (parentThread) {
parentThread.removeChild(thread);
}
const {
creator,
token
} = thread;
const threads = creatorThreads.get(creator);
const creators = tokenCreators.get(token);
/* istanbul ignore if */
if (!threads || !creators) throw report();
if (isDropout) dropout(thread, threads, creator, creators);
const cancellation = new Canceled(safe_flow_creator, thisArg, args, reason);
thread.cancellation = cancellation;
thread.canceled = true;
thread.changeState(TraceState.thread_canceled, reason);
cancelHandlers.forEach(handler => {
handler(reason);
});
cancelHandlers.length = 0;
//@ts-expect-error
cancelHandlers = undefined;
thread.complete(false);
setCurrThread(parentThread);
if (parentThread && !parentThread.willBreak && parentThread.willCancel && parentThread.autoBreak) reject(getBreakMsg());else resolve([cancellation]);
Promise.resolve().then(() => {
inDead = false;
});
inDead = true;
};
if (parentThread) parentThread.addChild(thread);
safeAwait(func.call(thisArg, ...args)).then(result => {
if (thread.canceled) {
thread.changeState(TraceState.thread_done_canceled, result[1]);
thread.dispose();
return;
} else if (parentThread) {
parentThread.removeChild(thread);
}
/* istanbul ignore if */
if (!creators) throw report();
/* istanbul ignore else */
if (result.length === CatchFirst.caught) {
cancelChildren(thread, 'An error occurred in the parent thread.');
const [caught] = result;
thread.changeState(TraceState.thread_error, caught);
dropout(thread, threads, safe_flow_creator, creators);
thread.complete();
setCurrThread(parentThread);
reject(caught);
} else if (result.length === CatchFirst.done) {
const [, done] = result;
thread.changeState(TraceState.thread_done, done);
dropout(thread, threads, safe_flow_creator, creators);
if (thread.hasChildren) {
cancelChildren(thread, 'An error occurred in the parent thread.');
thread.complete();
setCurrThread(parentThread);
reject(lostControl());
return;
}
thread.complete();
setCurrThread(parentThread);
resolve([null, done]);
} else {
throw report();
}
});
thread.changeState(TraceState.thread_idle);
setCurrThread(parentThread);
if (!thread.parent) inThread = false;
inDead = false;
});
promise.safe_flow_promise = true;
promise.cancel = reason => {
if (thread.completed) throw new Error('[safe-flow] Unable to cancel a thread that has ended.');
internalCancelSelf(thread, reason);
};
promise.canceled = () => thread.canceled;
promise.state = () => thread.promiseState;
promise.onCancel = handler => {
cancelHandlers.push(handler);
return promise;
};
promise.errf = () => {
const errfPromise = promise.then(([canceled, data]) => {
if (canceled === null) return [null, null, data];
return [null, canceled];
}).catch(error => {
if (error === breakMsg || error instanceof Error && error.message === lostControlMsg) throw error;
return [error];
});
errfPromise.safe_flow_promise = true;
errfPromise.cancel = promise.cancel;
errfPromise.canceled = promise.canceled;
errfPromise.state = promise.state;
errfPromise.onCancel = handler => {
cancelHandlers.push(handler);
return errfPromise;
};
return errfPromise;
};
return promise;
};
}
function isInvalid() {
if (!inDead && !inThread) throw new Error('[safe-flow] The called isInvalid method is not in the currently running thread. Don not call it in other asynchronous callback.');
return !current();
}
function isSafeFlowPromise(promise) {
return promise.safe_flow_promise === true;
}
function isCreator(func) {
return typeof func === 'function' && func.name === 'safe_flow_creator';
}
function cancelChildren(thread, reason) {
const {
children
} = thread;
children.concat().forEach(child => {
internalCancelSelf(child, reason);
});
}
function dropout(thread, threads, creator, creators) {
threads.splice(threads.indexOf(thread), 1);
if (threads.length === 0) {
creatorThreads.delete(creator);
creators.splice(creators.indexOf(creator), 1);
if (creators.length === 0) {
tokenCreators.delete(thread.token);
}
}
}
function cancel(tokenOrCreator, reason) {
const currThread = current();
internalCancel(currThread, tokenOrCreator, reason);
}
function cancelAll(reason) {
const currThread = current();
if (currThread) currThread.willBreak = true;
tokenCreators.forEach((_creator, token) => {
internalCancel(currThread, token, reason, true);
});
if (currThread && currThread.autoBreak) {
throw getBreakMsg();
}
}
function internalCancel(currThread, tokenOrCreator, reason, all = false) {
let isThrow = false;
if (isCreator(tokenOrCreator)) {
const threads = creatorThreads.get(tokenOrCreator);
if (threads) {
const token = threads[0].token;
threads.forEach(thread => {
/* istanbul ignore if */
if (!thread.canceler) throw report();
if (!all && currThread && !isThrow) isThrow = currThread.willBreak = inTree(thread, currThread);
thread.canceler(reason, false);
});
threads.length = 0;
creatorThreads.delete(tokenOrCreator);
const creators = tokenCreators.get(token);
/* istanbul ignore if */
if (!creators) throw report();
const i = creators.indexOf(tokenOrCreator);
creators.splice(i, 1);
if (creators.length === 0) {
tokenCreators.delete(token);
}
}
} else {
const creators = tokenCreators.get(tokenOrCreator);
if (creators) {
creators.forEach(creator => {
const threads = creatorThreads.get(creator);
/* istanbul ignore if */
if (!threads) throw report();
threads.forEach(thread => {
/* istanbul ignore if */
if (!thread.canceler) throw report();
if (!all && currThread && !isThrow) isThrow = currThread.willBreak = inTree(thread, currThread);
thread.canceler(reason, false);
});
threads.length = 0;
creatorThreads.delete(creator);
});
tokenCreators.delete(tokenOrCreator);
}
}
if (isThrow && currThread && currThread.autoBreak) {
throw getBreakMsg();
}
}
function inTree(tree, target) {
if (tree === target) return true;
const {
children
} = tree;
return children.some(child => {
if (child !== target) {
return inTree(child, target);
} else {
return true;
}
});
}
function cancelSelf(reason) {
const currThread = current();
if (currThread) {
currThread.willBreak = true;
internalCancelSelf(currThread, reason);
if (currThread.autoBreak) throw getBreakMsg();
} else {
throw cancelSelfErr();
}
}
function internalCancelSelf(thread, reason) {
const {
canceler
} = thread;
/* istanbul ignore if */
if (!canceler) throw report();
canceler(reason, true);
}
function flowed(func) {
return func;
}
function isFlowupObject(target) {
return target.__safe_flow_flowup === true;
}
function flowup(target, options) {
internalFlowup(target, options);
return target;
}
function internalFlowup(target, options) {
if (isFlowupObject(target)) throw new Error(`[safe-flow] flowup can only be used on objects that have not yet flowup.`);
const opts = defaults(options, config);
const {
names
} = opts;
if (names) {
Object.keys(names).forEach(name => {
if (typeof target[name] !== 'function') throw new ReferenceError(`[safe-flow] The specified attribute "${name}" found through the names option is not a function.`);
});
}
const norepeat = {};
Object.getOwnPropertyNames(target).forEach(propertyKey => {
norepeat[propertyKey] = true;
});
Object.getOwnPropertyNames(Object.getPrototypeOf(target)).forEach(propertyKey => {
norepeat[propertyKey] = true;
});
Object.keys(norepeat).forEach(propertyKey => {
flowupProp(target, propertyKey, opts);
});
target.__safe_flow_flowup = true;
}
function flowupProp(target, propertyKey, options) {
if (propertyKey === 'constructor') return;
const isFlowable = target.__safe_flow_flowable && target.__safe_flow_flowable[propertyKey];
const isFlowup = options.names ? options.names[propertyKey] : undefined;
if (!isFlowable && !isFlowup) {
if (options.filter(propertyKey)) {
if (typeof target[propertyKey] !== 'function') return;
} else return;
}
const {
names,
token,
trace,
autoBreak,
standalone,
plugins
} = options;
let name;
if (trace && names) {
/* istanbul ignore else */
if (isFlowup === true) {
name = propertyKey;
} else if (isFlowup) {
name = isFlowup.name;
} else {
throw report();
}
}
const flowOpts = {
token,
trace,
autoBreak,
name,
standalone,
plugins
};
if (typeof isFlowable === 'object') {
target[propertyKey] = internalFlow(target[propertyKey], defaults(isFlowable, flowOpts), target);
} else {
target[propertyKey] = internalFlow(target[propertyKey], flowOpts, target);
}
}
function flowable(targetOrOptions, propertyKey, descriptor) {
if (propertyKey) {
internalFlowable(targetOrOptions, propertyKey, descriptor);
} else {
return (target, propertyKey, descriptor) => {
internalFlowable(target, propertyKey, descriptor, targetOrOptions);
};
}
}
function internalFlowable(target, propertyKey, descriptor,
//The ? mark for ES3
options) {
let func;
if (descriptor) {
func = descriptor.value;
} else {
func = target[propertyKey];
}
if (typeof func !== 'function') {
throw new ReferenceError('[safe-flow] Cannot get the target function to be flowed. The "flowable" method decorator may not be used correctly.');
}
if (!target.__safe_flow_flowable) target.__safe_flow_flowable = {};
target.__safe_flow_flowable[propertyKey] = options ? options : true;
}
function takeNumber(name) {
return ++names[name];
}
let names = {};
function registerName(name) {
if (name in names) {
throw new Error(`[safe-flow] Duplicate flow name "${name}".`);
}
names[name] = 0;
}
export { Canceled, ErrfState, FlowState, PromiseState, Thread, TraceState, __debug_clear_names, __debug_clear_threads, __debug_creator_processes, __debug_enable, __debug_get_curr_thread, __debug_in_thread, __debug_live_threads, __debug_logger, __debug_token_creators, cancel, cancelAll, cancelSelf, configure, flow, flowable, flowed, flowup, internalFlowup, isCreator, isFlowupObject, isInvalid, isSafeFlowPromise, plugins };
//# sourceMappingURL=safe-flow.esm.js.map