UNPKG

safe-flow

Version:

A safer way to cancel or interrupt the await-async flow.

731 lines (724 loc) 23.3 kB
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