Build NapCat Artifacts / Windows Loader Test (push) Canceled after 0s
Build NapCat Artifacts / Type Check & Test (push) Canceled after 0s
Build NapCat Artifacts / Build NapCat.Framework (push) Canceled after 0s
Build NapCat Artifacts / Build NapCat.Shell (push) Canceled after 0s
568 lines
16 KiB
Plaintext
568 lines
16 KiB
Plaintext
import type { Action, Dispatch, MiddlewareAPI, UnknownAction } from 'redux'
|
|
import { isAction } from '../reduxImports'
|
|
import type { ThunkDispatch } from 'redux-thunk'
|
|
import { createAction } from '../createAction'
|
|
import { nanoid } from '../nanoid'
|
|
|
|
import {
|
|
TaskAbortError,
|
|
listenerCancelled,
|
|
listenerCompleted,
|
|
taskCancelled,
|
|
taskCompleted,
|
|
} from './exceptions'
|
|
import {
|
|
createDelay,
|
|
createPause,
|
|
raceWithSignal,
|
|
runTask,
|
|
validateActive,
|
|
} from './task'
|
|
import type {
|
|
AbortSignalWithReason,
|
|
AddListenerOverloads,
|
|
AnyListenerPredicate,
|
|
CreateListenerMiddlewareOptions,
|
|
FallbackAddListenerOptions,
|
|
ForkOptions,
|
|
ForkedTask,
|
|
ForkedTaskExecutor,
|
|
ListenerEntry,
|
|
ListenerErrorHandler,
|
|
ListenerErrorInfo,
|
|
ListenerMiddleware,
|
|
ListenerMiddlewareInstance,
|
|
TakePattern,
|
|
TaskResult,
|
|
TypedAddListener,
|
|
TypedCreateListenerEntry,
|
|
TypedRemoveListener,
|
|
UnsubscribeListener,
|
|
UnsubscribeListenerOptions,
|
|
} from './types'
|
|
import {
|
|
abortControllerWithReason,
|
|
addAbortSignalListener,
|
|
assertFunction,
|
|
catchRejection,
|
|
noop,
|
|
} from './utils'
|
|
export { TaskAbortError } from './exceptions'
|
|
export type {
|
|
AsyncTaskExecutor,
|
|
CreateListenerMiddlewareOptions,
|
|
ForkedTask,
|
|
ForkedTaskAPI,
|
|
ForkedTaskExecutor,
|
|
ListenerEffect,
|
|
ListenerEffectAPI,
|
|
ListenerErrorHandler,
|
|
ListenerMiddleware,
|
|
ListenerMiddlewareInstance,
|
|
SyncTaskExecutor,
|
|
TaskCancelled,
|
|
TaskRejected,
|
|
TaskResolved,
|
|
TaskResult,
|
|
TypedAddListener,
|
|
TypedRemoveListener,
|
|
TypedStartListening,
|
|
TypedStopListening,
|
|
UnsubscribeListener,
|
|
UnsubscribeListenerOptions,
|
|
} from './types'
|
|
|
|
//Overly-aggressive byte-shaving
|
|
const { assign } = Object
|
|
/**
|
|
* @internal
|
|
*/
|
|
const INTERNAL_NIL_TOKEN = {} as const
|
|
|
|
const alm = 'listenerMiddleware' as const
|
|
|
|
const createFork = (
|
|
parentAbortSignal: AbortSignalWithReason<unknown>,
|
|
parentBlockingPromises: Promise<any>[],
|
|
) => {
|
|
const linkControllers = (controller: AbortController) =>
|
|
addAbortSignalListener(parentAbortSignal, () =>
|
|
abortControllerWithReason(controller, parentAbortSignal.reason),
|
|
)
|
|
|
|
return <T>(
|
|
taskExecutor: ForkedTaskExecutor<T>,
|
|
opts?: ForkOptions,
|
|
): ForkedTask<T> => {
|
|
assertFunction(taskExecutor, 'taskExecutor')
|
|
const childAbortController = new AbortController()
|
|
|
|
linkControllers(childAbortController)
|
|
|
|
const result = runTask<T>(
|
|
async (): Promise<T> => {
|
|
validateActive(parentAbortSignal)
|
|
validateActive(childAbortController.signal)
|
|
const result = (await taskExecutor({
|
|
pause: createPause(childAbortController.signal),
|
|
delay: createDelay(childAbortController.signal),
|
|
signal: childAbortController.signal,
|
|
})) as T
|
|
validateActive(childAbortController.signal)
|
|
return result
|
|
},
|
|
() => abortControllerWithReason(childAbortController, taskCompleted),
|
|
)
|
|
|
|
if (opts?.autoJoin) {
|
|
parentBlockingPromises.push(result.catch(noop))
|
|
}
|
|
|
|
return {
|
|
result: createPause<TaskResult<T>>(parentAbortSignal)(result),
|
|
cancel() {
|
|
abortControllerWithReason(childAbortController, taskCancelled)
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
const createTakePattern = <S>(
|
|
startListening: AddListenerOverloads<UnsubscribeListener, S, Dispatch>,
|
|
signal: AbortSignal,
|
|
): TakePattern<S> => {
|
|
/**
|
|
* A function that takes a ListenerPredicate and an optional timeout,
|
|
* and resolves when either the predicate returns `true` based on an action
|
|
* state combination or when the timeout expires.
|
|
* If the parent listener is canceled while waiting, this will throw a
|
|
* TaskAbortError.
|
|
*/
|
|
const take = async <P extends AnyListenerPredicate<S>>(
|
|
predicate: P,
|
|
timeout: number | undefined,
|
|
) => {
|
|
validateActive(signal)
|
|
|
|
// Placeholder unsubscribe function until the listener is added
|
|
let unsubscribe: UnsubscribeListener = () => {}
|
|
|
|
const tuplePromise = new Promise<[Action, S, S]>((resolve, reject) => {
|
|
// Inside the Promise, we synchronously add the listener.
|
|
let stopListening = startListening({
|
|
predicate: predicate as any,
|
|
effect: (action, listenerApi): void => {
|
|
// One-shot listener that cleans up as soon as the predicate passes
|
|
listenerApi.unsubscribe()
|
|
// Resolve the promise with the same arguments the predicate saw
|
|
resolve([
|
|
action,
|
|
listenerApi.getState(),
|
|
listenerApi.getOriginalState(),
|
|
])
|
|
},
|
|
})
|
|
unsubscribe = () => {
|
|
stopListening()
|
|
reject()
|
|
}
|
|
})
|
|
|
|
const promises: (Promise<null> | Promise<[Action, S, S]>)[] = [tuplePromise]
|
|
|
|
if (timeout != null) {
|
|
promises.push(
|
|
new Promise<null>((resolve) => setTimeout(resolve, timeout, null)),
|
|
)
|
|
}
|
|
|
|
try {
|
|
const output = await raceWithSignal(signal, Promise.race(promises))
|
|
|
|
validateActive(signal)
|
|
return output
|
|
} finally {
|
|
// Always clean up the listener
|
|
unsubscribe()
|
|
}
|
|
}
|
|
|
|
return ((predicate: AnyListenerPredicate<S>, timeout: number | undefined) =>
|
|
catchRejection(take(predicate, timeout))) as TakePattern<S>
|
|
}
|
|
|
|
const getListenerEntryPropsFrom = (options: FallbackAddListenerOptions) => {
|
|
let { type, actionCreator, matcher, predicate, effect } = options
|
|
|
|
if (type) {
|
|
predicate = createAction(type).match
|
|
} else if (actionCreator) {
|
|
type = actionCreator!.type
|
|
predicate = actionCreator.match
|
|
} else if (matcher) {
|
|
predicate = matcher
|
|
} else if (predicate) {
|
|
// pass
|
|
} else {
|
|
throw new Error(
|
|
'Creating or removing a listener requires one of the known fields for matching an action',
|
|
)
|
|
}
|
|
|
|
assertFunction(effect, 'options.listener')
|
|
|
|
return { predicate, type, effect }
|
|
}
|
|
|
|
/** Accepts the possible options for creating a listener, and returns a formatted listener entry */
|
|
export const createListenerEntry: TypedCreateListenerEntry<unknown> =
|
|
/* @__PURE__ */ assign(
|
|
(options: FallbackAddListenerOptions) => {
|
|
const { type, predicate, effect } = getListenerEntryPropsFrom(options)
|
|
|
|
const entry: ListenerEntry<unknown> = {
|
|
id: nanoid(),
|
|
effect,
|
|
type,
|
|
predicate,
|
|
pending: new Set<AbortController>(),
|
|
unsubscribe: () => {
|
|
throw new Error('Unsubscribe not initialized')
|
|
},
|
|
}
|
|
|
|
return entry
|
|
},
|
|
{ withTypes: () => createListenerEntry },
|
|
) as unknown as TypedCreateListenerEntry<unknown>
|
|
|
|
const findListenerEntry = (
|
|
listenerMap: Map<string, ListenerEntry>,
|
|
options: FallbackAddListenerOptions,
|
|
) => {
|
|
const { type, effect, predicate } = getListenerEntryPropsFrom(options)
|
|
|
|
return Array.from(listenerMap.values()).find((entry) => {
|
|
const matchPredicateOrType =
|
|
typeof type === 'string'
|
|
? entry.type === type
|
|
: entry.predicate === predicate
|
|
|
|
return matchPredicateOrType && entry.effect === effect
|
|
})
|
|
}
|
|
|
|
const cancelActiveListeners = (
|
|
entry: ListenerEntry<unknown, Dispatch<UnknownAction>>,
|
|
) => {
|
|
entry.pending.forEach((controller) => {
|
|
abortControllerWithReason(controller, listenerCancelled)
|
|
})
|
|
}
|
|
|
|
const createClearListenerMiddleware = (
|
|
listenerMap: Map<string, ListenerEntry>,
|
|
executingListeners: Map<ListenerEntry, number>,
|
|
) => {
|
|
return () => {
|
|
for (const listener of executingListeners.keys()) {
|
|
cancelActiveListeners(listener)
|
|
}
|
|
listenerMap.clear()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Safely reports errors to the `errorHandler` provided.
|
|
* Errors that occur inside `errorHandler` are notified in a new task.
|
|
* Inspired by [rxjs reportUnhandledError](https://github.com/ReactiveX/rxjs/blob/6fafcf53dc9e557439b25debaeadfd224b245a66/src/internal/util/reportUnhandledError.ts)
|
|
* @param errorHandler
|
|
* @param errorToNotify
|
|
*/
|
|
const safelyNotifyError = (
|
|
errorHandler: ListenerErrorHandler,
|
|
errorToNotify: unknown,
|
|
errorInfo: ListenerErrorInfo,
|
|
): void => {
|
|
try {
|
|
errorHandler(errorToNotify, errorInfo)
|
|
} catch (errorHandlerError) {
|
|
// We cannot let an error raised here block the listener queue.
|
|
// The error raised here will be picked up by `window.onerror`, `process.on('error')` etc...
|
|
setTimeout(() => {
|
|
throw errorHandlerError
|
|
}, 0)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @public
|
|
*/
|
|
export const addListener = /* @__PURE__ */ assign(
|
|
/* @__PURE__ */ createAction(`${alm}/add`),
|
|
{
|
|
withTypes: () => addListener,
|
|
},
|
|
) as unknown as TypedAddListener<unknown>
|
|
|
|
/**
|
|
* @public
|
|
*/
|
|
export const clearAllListeners = /* @__PURE__ */ createAction(
|
|
`${alm}/removeAll`,
|
|
)
|
|
|
|
/**
|
|
* @public
|
|
*/
|
|
export const removeListener = /* @__PURE__ */ assign(
|
|
/* @__PURE__ */ createAction(`${alm}/remove`),
|
|
{
|
|
withTypes: () => removeListener,
|
|
},
|
|
) as unknown as TypedRemoveListener<unknown>
|
|
|
|
const defaultErrorHandler: ListenerErrorHandler = (...args: unknown[]) => {
|
|
console.error(`${alm}/error`, ...args)
|
|
}
|
|
|
|
/**
|
|
* @public
|
|
*/
|
|
export const createListenerMiddleware = <
|
|
StateType = unknown,
|
|
DispatchType extends Dispatch<Action> = ThunkDispatch<
|
|
StateType,
|
|
unknown,
|
|
UnknownAction
|
|
>,
|
|
ExtraArgument = unknown,
|
|
>(
|
|
middlewareOptions: CreateListenerMiddlewareOptions<ExtraArgument> = {},
|
|
) => {
|
|
const listenerMap = new Map<string, ListenerEntry>()
|
|
|
|
// Track listeners whose effect is currently executing so clearListeners can
|
|
// abort even listeners that have become unsubscribed while executing.
|
|
const executingListeners = new Map<ListenerEntry, number>()
|
|
const trackExecutingListener = (entry: ListenerEntry) => {
|
|
const count = executingListeners.get(entry) ?? 0
|
|
executingListeners.set(entry, count + 1)
|
|
}
|
|
const untrackExecutingListener = (entry: ListenerEntry) => {
|
|
const count = executingListeners.get(entry) ?? 1
|
|
if (count === 1) {
|
|
executingListeners.delete(entry)
|
|
} else {
|
|
executingListeners.set(entry, count - 1)
|
|
}
|
|
}
|
|
|
|
const { extra, onError = defaultErrorHandler } = middlewareOptions
|
|
|
|
assertFunction(onError, 'onError')
|
|
|
|
const insertEntry = (entry: ListenerEntry) => {
|
|
entry.unsubscribe = () => listenerMap.delete(entry.id)
|
|
|
|
listenerMap.set(entry.id, entry)
|
|
return (cancelOptions?: UnsubscribeListenerOptions) => {
|
|
entry.unsubscribe()
|
|
if (cancelOptions?.cancelActive) {
|
|
cancelActiveListeners(entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
const startListening = ((options: FallbackAddListenerOptions) => {
|
|
const entry =
|
|
findListenerEntry(listenerMap, options) ??
|
|
createListenerEntry(options as any)
|
|
|
|
return insertEntry(entry)
|
|
}) as AddListenerOverloads<any>
|
|
|
|
assign(startListening, {
|
|
withTypes: () => startListening,
|
|
})
|
|
|
|
const stopListening = (
|
|
options: FallbackAddListenerOptions & UnsubscribeListenerOptions,
|
|
): boolean => {
|
|
const entry = findListenerEntry(listenerMap, options)
|
|
|
|
if (entry) {
|
|
entry.unsubscribe()
|
|
if (options.cancelActive) {
|
|
cancelActiveListeners(entry)
|
|
}
|
|
}
|
|
|
|
return !!entry
|
|
}
|
|
|
|
assign(stopListening, {
|
|
withTypes: () => stopListening,
|
|
})
|
|
|
|
const notifyListener = async (
|
|
entry: ListenerEntry<unknown, Dispatch<UnknownAction>>,
|
|
action: unknown,
|
|
api: MiddlewareAPI,
|
|
getOriginalState: () => StateType,
|
|
) => {
|
|
const internalTaskController = new AbortController()
|
|
const take = createTakePattern(
|
|
startListening as AddListenerOverloads<any>,
|
|
internalTaskController.signal,
|
|
)
|
|
const autoJoinPromises: Promise<any>[] = []
|
|
|
|
try {
|
|
entry.pending.add(internalTaskController)
|
|
trackExecutingListener(entry)
|
|
await Promise.resolve(
|
|
entry.effect(
|
|
action,
|
|
// Use assign() rather than ... to avoid extra helper functions added to bundle
|
|
assign({}, api, {
|
|
getOriginalState,
|
|
condition: (
|
|
predicate: AnyListenerPredicate<any>,
|
|
timeout?: number,
|
|
) => take(predicate, timeout).then(Boolean),
|
|
take,
|
|
delay: createDelay(internalTaskController.signal),
|
|
pause: createPause<any>(internalTaskController.signal),
|
|
extra,
|
|
signal: internalTaskController.signal,
|
|
fork: createFork(internalTaskController.signal, autoJoinPromises),
|
|
unsubscribe: entry.unsubscribe,
|
|
subscribe: () => {
|
|
listenerMap.set(entry.id, entry)
|
|
},
|
|
cancelActiveListeners: () => {
|
|
entry.pending.forEach((controller, _, set) => {
|
|
if (controller !== internalTaskController) {
|
|
abortControllerWithReason(controller, listenerCancelled)
|
|
set.delete(controller)
|
|
}
|
|
})
|
|
},
|
|
cancel: () => {
|
|
abortControllerWithReason(
|
|
internalTaskController,
|
|
listenerCancelled,
|
|
)
|
|
entry.pending.delete(internalTaskController)
|
|
},
|
|
throwIfCancelled: () => {
|
|
validateActive(internalTaskController.signal)
|
|
},
|
|
}),
|
|
),
|
|
)
|
|
} catch (listenerError) {
|
|
if (!(listenerError instanceof TaskAbortError)) {
|
|
safelyNotifyError(onError, listenerError, {
|
|
raisedBy: 'effect',
|
|
})
|
|
}
|
|
} finally {
|
|
await Promise.all(autoJoinPromises)
|
|
|
|
abortControllerWithReason(internalTaskController, listenerCompleted) // Notify that the task has completed
|
|
untrackExecutingListener(entry)
|
|
entry.pending.delete(internalTaskController)
|
|
}
|
|
}
|
|
|
|
const clearListenerMiddleware = createClearListenerMiddleware(
|
|
listenerMap,
|
|
executingListeners,
|
|
)
|
|
|
|
const middleware: ListenerMiddleware<
|
|
StateType,
|
|
DispatchType,
|
|
ExtraArgument
|
|
> = (api) => (next) => (action) => {
|
|
if (!isAction(action)) {
|
|
// we only want to notify listeners for action objects
|
|
return next(action)
|
|
}
|
|
|
|
if (addListener.match(action)) {
|
|
return startListening(action.payload as any)
|
|
}
|
|
|
|
if (clearAllListeners.match(action)) {
|
|
clearListenerMiddleware()
|
|
return
|
|
}
|
|
|
|
if (removeListener.match(action)) {
|
|
return stopListening(action.payload)
|
|
}
|
|
|
|
// Need to get this state _before_ the reducer processes the action
|
|
let originalState: StateType | typeof INTERNAL_NIL_TOKEN = api.getState()
|
|
|
|
// `getOriginalState` can only be called synchronously.
|
|
// @see https://github.com/reduxjs/redux-toolkit/discussions/1648#discussioncomment-1932820
|
|
const getOriginalState = (): StateType => {
|
|
if (originalState === INTERNAL_NIL_TOKEN) {
|
|
throw new Error(
|
|
`${alm}: getOriginalState can only be called synchronously`,
|
|
)
|
|
}
|
|
|
|
return originalState as StateType
|
|
}
|
|
|
|
let result: unknown
|
|
|
|
try {
|
|
// Actually forward the action to the reducer before we handle listeners
|
|
result = next(action)
|
|
|
|
if (listenerMap.size > 0) {
|
|
const currentState = api.getState()
|
|
// Work around ESBuild+TS transpilation issue
|
|
const listenerEntries = Array.from(listenerMap.values())
|
|
for (const entry of listenerEntries) {
|
|
let runListener = false
|
|
|
|
try {
|
|
runListener = entry.predicate(action, currentState, originalState)
|
|
} catch (predicateError) {
|
|
runListener = false
|
|
|
|
safelyNotifyError(onError, predicateError, {
|
|
raisedBy: 'predicate',
|
|
})
|
|
}
|
|
|
|
if (!runListener) {
|
|
continue
|
|
}
|
|
|
|
notifyListener(entry, action, api, getOriginalState)
|
|
}
|
|
}
|
|
} finally {
|
|
// Remove `originalState` store from this scope.
|
|
originalState = INTERNAL_NIL_TOKEN
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
return {
|
|
middleware,
|
|
startListening,
|
|
stopListening,
|
|
clearListeners: clearListenerMiddleware,
|
|
} as ListenerMiddlewareInstance<StateType, DispatchType, ExtraArgument>
|
|
}
|