/* eslint-disable @typescript-eslint/strict-boolean-expressions */ import { IContainer, ILogger } from '@aurelia/kernel'; import { IWindow } from "@aurelia/runtime-html"; import { BehaviorSubject, Observable } from 'rxjs'; import { jump, applyLimits, HistoryOptions, isStateHistory, StateHistory } from './history'; import { Middleware, MiddlewarePlacement, CallingAction } from './middleware'; import { LogDefinitions, LogLevel, getLogType } from './logging'; import { DevToolsOptions, Action, DevToolsExtension, DevTools } from './devtools'; export type Reducer = (state: T, ...params: P) => T | false | Promise; export enum PerformanceMeasurement { StartEnd = 'startEnd', All = 'all' } export interface StoreOptions { history?: Partial; logDispatchedActions?: boolean; measurePerformance?: PerformanceMeasurement; propagateError?: boolean; logDefinitions?: LogDefinitions; devToolsOptions?: DevToolsOptions; } export interface PipedDispatch { pipe:

(reducer: Reducer | string, ...params: P) => PipedDispatch; dispatch: () => Promise; } interface DispatchAction { reducer: Reducer; params: unknown[]; } interface DispatchQueueItem { actions: DispatchAction[]; resolve: (value?: void | PromiseLike | undefined) => void; reject: (reason?: unknown) => void; } export const STORE: { container: IContainer } = { container: null! }; export interface IStoreWindow extends Window { devToolsExtension?: DevToolsExtension; __REDUX_DEVTOOLS_EXTENSION__?: DevToolsExtension; } export class UnregisteredActionError extends Error { public constructor(reducer?: string | Reducer) { super(`Tried to dispatch an unregistered action ${reducer !== undefined && (typeof reducer === "string" ? reducer : reducer.name)}`); } } export class DevToolsRemoteDispatchError extends Error {} export class ActionRegistrationError extends Error {} export class ReducerNoStateError extends Error {} export interface MiddlewareSettings { placement: MiddlewarePlacement; settings?: unknown; } export class Store { public readonly state: Observable; // TODO: need an alternative for the Reporter which supports multiple log levels private devToolsAvailable: boolean = false; private devTools?: DevTools; private readonly actions: Map, Action> = new Map, Action>(); private readonly middlewares: Map, MiddlewareSettings> = new Map, MiddlewareSettings>(); private readonly _state: BehaviorSubject; private readonly options: Partial; private readonly dispatchQueue: DispatchQueueItem[] = []; public constructor( private readonly initialState: T, private readonly logger: ILogger, private readonly _window: IStoreWindow, options?: Partial ) { this.options = options ?? {}; const isUndoable = this.options?.history?.undoable === true; this._state = new BehaviorSubject(initialState); this.state = this._state.asObservable(); if (this.options?.devToolsOptions?.disable !== true) { this.setupDevTools(); } if (isUndoable) { this.registerHistoryMethods(); } } public registerMiddleware(reducer: Middleware, placement: MiddlewarePlacement): void; // eslint-disable-next-line @typescript-eslint/no-explicit-any public registerMiddleware>(reducer: Middleware, placement: MiddlewarePlacement, settings: S): void; public registerMiddleware(reducer: Middleware, placement: MiddlewarePlacement, settings?: S): void { this.middlewares.set(reducer, { placement, settings }); } public unregisterMiddleware(reducer: Middleware): void { if (this.middlewares.has(reducer)) { this.middlewares.delete(reducer); } } public isMiddlewareRegistered(middleware: Middleware): boolean { return this.middlewares.has(middleware); } public registerAction(name: string, reducer: Reducer): void { if (reducer.length === 0) { // The reducer is expected to have one or more parameters, where the first will be the present state throw new ActionRegistrationError("The reducer is expected to have one or more parameters, where the first will be the present state"); } this.actions.set(reducer, { type: name }); } public unregisterAction(reducer: Reducer): void { if (this.actions.has(reducer)) { this.actions.delete(reducer); } } public isActionRegistered(reducer: Reducer | string): boolean { if (typeof reducer === 'string') { return Array.from(this.actions).find((action) => action[1].type === reducer) !== undefined; } return this.actions.has(reducer); } public resetToState(state: T): void { this._state.next(state); } public async dispatch

(reducer: Reducer | string, ...params: P): Promise { const action = this.lookupAction(reducer as Reducer | string); if (!action) { return Promise.reject(new UnregisteredActionError(reducer)); } return this.queueDispatch([{ reducer: action, params }]); } public pipe

(reducer: Reducer | string, ...params: P): PipedDispatch { const pipeline: DispatchAction[] = []; const dispatchPipe: PipedDispatch = { dispatch: async () => this.queueDispatch(pipeline), pipe: (nextReducer: Reducer | string, ...nextParams: NextP) => { const action = this.lookupAction(nextReducer as Reducer | string); if (!action) { throw new UnregisteredActionError(reducer); } pipeline.push({ reducer: action, params: nextParams }); return dispatchPipe; } }; return dispatchPipe.pipe(reducer, ...params); } private lookupAction(reducer: Reducer | string): Reducer | undefined { if (typeof reducer === "string") { const result = Array.from(this.actions).find(([_, action]) => action.type === reducer); if (result) { return result[0]; } } else if (this.actions.has(reducer)) { return reducer; } return undefined; } private async queueDispatch(actions: DispatchAction[]) { return new Promise((resolve, reject) => { this.dispatchQueue.push({ actions, resolve, reject }); if (this.dispatchQueue.length === 1) { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.handleQueue(); } }); } private async handleQueue() { if (this.dispatchQueue.length > 0) { const queueItem = this.dispatchQueue[0]; try { await this.internalDispatch(queueItem.actions); queueItem.resolve(); } catch (e) { queueItem.reject(e); } this.dispatchQueue.shift(); // eslint-disable-next-line @typescript-eslint/no-floating-promises this.handleQueue(); } } private async internalDispatch(actions: DispatchAction[]) { const unregisteredAction = actions.find((a) => !this.actions.has(a.reducer)); if (unregisteredAction) { throw new UnregisteredActionError(unregisteredAction.reducer); } STORE.container.get(IWindow).performance.mark("dispatch-start"); const pipedActions = actions.map((a) => ({ type: this.actions.get(a.reducer)!.type, params: a.params, reducer: a.reducer })); const callingAction: CallingAction = { name: pipedActions.map((a) => a.type).join("->"), params: pipedActions.reduce((p, a) => p.concat(a.params), []), pipedActions: pipedActions.map((a) => ({ name: a.type, params: a.params })) }; if (this.options.logDispatchedActions) { this.logger[getLogType(this.options, "dispatchedActions", LogLevel.info)](`Dispatching: ${callingAction.name}`); } // eslint-disable-next-line @typescript-eslint/await-thenable const beforeMiddleswaresResult = await this.executeMiddlewares( this._state.getValue(), MiddlewarePlacement.Before, callingAction ); if (beforeMiddleswaresResult === false) { STORE.container.get(IWindow).performance.clearMarks(); STORE.container.get(IWindow).performance.clearMeasures(); return; } let result: T | false = beforeMiddleswaresResult; for (const action of pipedActions) { // eslint-disable-next-line no-await-in-loop result = await action.reducer(result, ...action.params); if (result === false) { STORE.container.get(IWindow).performance.clearMarks(); STORE.container.get(IWindow).performance.clearMeasures(); return; } STORE.container.get(IWindow).performance.mark(`dispatch-after-reducer-${action.type}`); if (!result && typeof result !== "object") { throw new ReducerNoStateError("The reducer has to return a new state"); } } // eslint-disable-next-line @typescript-eslint/await-thenable let resultingState: false | T | Partial> = await this.executeMiddlewares( result, MiddlewarePlacement.After, callingAction ); if (resultingState === false) { STORE.container.get(IWindow).performance.clearMarks(); STORE.container.get(IWindow).performance.clearMeasures(); return; } if (isStateHistory(resultingState as Partial>) && this.options.history?.limit) { resultingState = applyLimits(resultingState as Partial>, this.options.history.limit); } this._state.next(resultingState as T); STORE.container.get(IWindow).performance.mark("dispatch-end"); if (this.options.measurePerformance === PerformanceMeasurement.StartEnd) { STORE.container.get(IWindow).performance.measure( "startEndDispatchDuration", "dispatch-start", "dispatch-end" ); const measures = STORE.container.get(IWindow).performance.getEntriesByName("startEndDispatchDuration"); this.logger[getLogType(this.options, "performanceLog", LogLevel.info)]( `Total duration ${measures[0].duration} of dispatched action ${callingAction.name}:`, measures ); } else if (this.options.measurePerformance === PerformanceMeasurement.All) { const marks = STORE.container.get(IWindow).performance.getEntriesByType("mark"); const totalDuration = marks[marks.length - 1].startTime - marks[0].startTime; this.logger[getLogType(this.options, "performanceLog", LogLevel.info)]( `Total duration ${totalDuration} of dispatched action ${callingAction.name}:`, marks ); } STORE.container.get(IWindow).performance.clearMarks(); STORE.container.get(IWindow).performance.clearMeasures(); this.updateDevToolsState({ type: callingAction.name, params: callingAction.params }, resultingState as T); } private executeMiddlewares(state: T, placement: MiddlewarePlacement, action: CallingAction): T | false { return Array.from(this.middlewares) .filter((middleware) => middleware[1].placement === placement) // eslint-disable-next-line @typescript-eslint/no-explicit-any .reduce(async (prev: any, curr, _) => { try { const result = await curr[0](await prev, this._state.getValue(), curr[1].settings, action); if (result === false) { return false; } return result || await prev; } catch (e) { if (this.options.propagateError) { throw e; } // eslint-disable-next-line @typescript-eslint/return-await return await prev; } finally { STORE.container.get(IWindow).performance.mark(`dispatch-${placement}-${curr[0].name}`); } }, state); } private setupDevTools() { // TODO: needs a better solution for global override if (this._window.__REDUX_DEVTOOLS_EXTENSION__) { this.logger[getLogType(this.options, "devToolsStatus", LogLevel.debug)]("DevTools are available"); this.devToolsAvailable = true; // TODO: needs a better solution for global override this.devTools = this._window.__REDUX_DEVTOOLS_EXTENSION__.connect(this.options.devToolsOptions); this.devTools.init(this.initialState); this.devTools.subscribe((message) => { this.logger[getLogType(this.options, "devToolsStatus", LogLevel.debug)](`DevTools sent change ${message.type}`); if (message.type === "ACTION" && message.payload !== undefined) { const byName = Array.from(this.actions).find(function ([reducer]) { return reducer.name === message.payload?.name; }); const action = this.lookupAction(message.payload?.name) ?? byName?.[0]; if (!action) { throw new DevToolsRemoteDispatchError("Tried to remotely dispatch an unregistered action"); } if (!message.payload.args || message.payload.args.length < 1) { throw new DevToolsRemoteDispatchError("No action arguments provided"); } this.dispatch(action, ...message.payload.args.slice(1).map((arg: string) => JSON.parse(arg) as T)).catch(() => { throw new DevToolsRemoteDispatchError("Issue when trying to dispatch an action through devtools"); }); return; } if (message.type === "DISPATCH" && message.payload) { switch (message.payload.type) { case "JUMP_TO_STATE": case "JUMP_TO_ACTION": this._state.next(JSON.parse(message.state)); return; case "COMMIT": this.devTools!.init(this._state.getValue()); return; case "RESET": this.devTools!.init(this.initialState); this.resetToState(this.initialState); return; case "ROLLBACK": { const parsedState = JSON.parse(message.state) as T; this.resetToState(parsedState); this.devTools!.init(parsedState); return; } } } }); } } private updateDevToolsState(action: Action, state: T) { if (this.devToolsAvailable && this.devTools) { this.devTools.send(action, state); } } private registerHistoryMethods() { // eslint-disable-next-line @typescript-eslint/no-explicit-any this.registerAction("jump", jump as Reducer); } } export function dispatchify(action: Reducer | string): (...params: P) => Promise { const store = STORE.container.get>(Store); return async function (...params: P): Promise { return store.dispatch(action, ...params); }; }