Browse Source

feat: another Mixins overhaul; support async functions

Sv443 4 weeks ago
parent
commit
2a65369d70
3 changed files with 111 additions and 54 deletions
  1. 33 17
      docs.md
  2. 1 0
      eslint.config.mjs
  3. 77 37
      lib/Mixins.ts

+ 33 - 17
docs.md

@@ -1901,19 +1901,27 @@ The properties of the `MixinsConstructorConfig` object in the constructor are:
 
 
 ### Methods:
 ### Methods:
 #### `Mixins.resolve()`
 #### `Mixins.resolve()`
-Signature: `resolve<TArg extends any, TCtx extends any>(mixinKey: string, inputValue: TArg, inputCtx?: TCtx): TArg`  
-Applies all mixin functions that were registered with [`add()`](#mixinsadd) for the given mixin key to resolve the input value.  
-Goes in order of highest to lowest priority and returns the resolved value, which is of the same type as the input value.  
+Signature: `resolve<TArg extends any, TCtx extends any>(mixinKey: string, inputValue: TArg, inputCtx?: TCtx): TArg | Promise<TArg>`  
+Applies all mixin functions that were registered with [`add()`](#mixinsadd) for the given mixin key to transform the input value.  
+Goes in order of highest to lowest priority and returns the transformed value, which has to be of the same type as the input value.  
 If no mixin functions are registered for the given key, the input value will be returned unchanged.  
 If no mixin functions are registered for the given key, the input value will be returned unchanged.  
+  
+If some of the mixins are async (return a Promise), the `resolve()` method will also return a Promise that resolves to the final value.  
+If a mixin is defined as async but none of the registered functions for it return a Promise in [`add()`](#mixinsadd), the returned value will *not* be a Promise. With `await`, this doesn't matter, but the `.then()` method will not work in this case and will need an explicit wrapping in `Promise.resolve()`.  
 
 
 <br>
 <br>
 
 
 #### `Mixins.add()`
 #### `Mixins.add()`
-Signature: `add<TArg extends any, TCtx extends any>(mixinKey: string, mixinFn: (arg: TArg, ctx?: TCtx) => TArg, config?: Partial<MixinConfig>): () => void`  
+Signature: `add<TArg extends any, TCtx extends any>(mixinKey: string, mixinFn: (arg: TArg, ctx?: TCtx) => TArg | Promise<TArg>, config?: Partial<MixinConfig>): () => void`  
 Registers a mixin function for the given key.  
 Registers a mixin function for the given key.  
 The function will be called with the input value (possibly modified by previous mixins) and possibly a context object.  
 The function will be called with the input value (possibly modified by previous mixins) and possibly a context object.  
 When a value for the context parameter is defined in the main generic of the `Mixins` class, the ctx parameter will be required. Otherwise it should always be unspecified.  
 When a value for the context parameter is defined in the main generic of the `Mixins` class, the ctx parameter will be required. Otherwise it should always be unspecified.  
   
   
+If the mixin function is async, it should return a Promise that resolves to the modified value.  
+It will also cause the [`resolve()`](#mixinsresolve) method to return a Promise that resolves to the final value.  
+In TypeScript, it is extremely important to mark the return type of the function as a Promise in the constructor's generic parameter if the function can potentially be async.  
+It doesn't have to necessarily return an async value, but marking it as such means there is a possibility of it.  
+  
 Mixins with the highest priority will be applied first. If two or more mixins share the exact same priority, they will be executed in order of registration (first come, first serve).  
 Mixins with the highest priority will be applied first. If two or more mixins share the exact same priority, they will be executed in order of registration (first come, first serve).  
 If a mixin has `stopPropagation` set, the chain will immediately stop after it has finished and the value resolution will end there.  
 If a mixin has `stopPropagation` set, the chain will immediately stop after it has finished and the value resolution will end there.  
 To conditionally apply mixins (enable/disable them), you can switch between returning the input value (effectively disabled) and the modified value based on a condition supplied by the context object.  
 To conditionally apply mixins (enable/disable them), you can switch between returning the input value (effectively disabled) and the modified value based on a condition supplied by the context object.  
@@ -1948,7 +1956,8 @@ import { Mixins } from "@sv443-network/userutils";
 // create Mixins instance:
 // create Mixins instance:
 const myMixins = new Mixins<{
 const myMixins = new Mixins<{
   /** Here is a perfect place to describe what your value does and give ideas on how to modify it */
   /** Here is a perfect place to describe what your value does and give ideas on how to modify it */
-  myValue: (val: number, ctx: { myFactor: number }) => number;
+  myValue: (val: number, ctx: { myFactor: number }) => Promise<number>;
+  // ^ if a function is declared as returning a Promise<T>, the Mixins.add() method will accept functions that return either T or Promise<T>
 }>();
 }>();
 
 
 // register mixin functions:
 // register mixin functions:
@@ -1956,8 +1965,9 @@ const myMixins = new Mixins<{
 // source 1 (priority 0, index 0):
 // source 1 (priority 0, index 0):
 myMixins.add("myValue", (val, { myFactor }) => val * myFactor);
 myMixins.add("myValue", (val, { myFactor }) => val * myFactor);
 
 
+// myValue returns a Promise in the constructor generic parameter above, so mixin functions can be either sync or async:
 // source 2 (priority 0, index 1):
 // source 2 (priority 0, index 1):
-myMixins.add("myValue", (val) => val + 1);
+myMixins.add("myValue", (val) => Promise.resolve(val + 1));
 
 
 // source 3 (priority 1):
 // source 3 (priority 1):
 myMixins.add("myValue", (val) => val * 2, {
 myMixins.add("myValue", (val) => val * 2, {
@@ -1966,7 +1976,8 @@ myMixins.add("myValue", (val) => val * 2, {
 
 
 // apply mixins and transform the input value:
 // apply mixins and transform the input value:
 
 
-const result = myMixins.resolve("myValue", 10, { myFactor: 0.75 });
+// since some of the mixin functions are async, the result will be a Promise:
+const result = await myMixins.resolve("myValue", 10, { myFactor: 0.75 });
 // order of operations:
 // order of operations:
 // 1. inputVal = 10
 // 1. inputVal = 10
 // 2. 10 * 2 = 20     (source 3 mixin)
 // 2. 10 * 2 = 20     (source 3 mixin)
@@ -1992,7 +2003,7 @@ const myMixins = new Mixins<{
   /** Here is a perfect place to describe what your value does and give ideas on how to modify it */
   /** Here is a perfect place to describe what your value does and give ideas on how to modify it */
   foo: (val: number) => number;
   foo: (val: number) => number;
   /** It is especially useful to document your mixins in an environment with user submitted mods/plugins */
   /** It is especially useful to document your mixins in an environment with user submitted mods/plugins */
-  bar: (v: string, ctx: { baz: number }) => string;
+  bar: (v: string, ctx: { baz: number }) => Promise<string>;
   /**
   /**
    * In this example, to calculate the gravity of the player character in a game engine, mods could interject and modify the gravity value.  
    * In this example, to calculate the gravity of the player character in a game engine, mods could interject and modify the gravity value.  
    * In this JSDoc comment, you should explain the default value, the general range of values and the effect of the value on the game.  
    * In this JSDoc comment, you should explain the default value, the general range of values and the effect of the value on the game.  
@@ -2049,7 +2060,7 @@ getFoo(10); // 10 ** 2 / 2 * 2 + 1 = 101
 var baz = 1337;
 var baz = 1337;
 
 
 // main function:
 // main function:
-function getBar(val: string) {
+async function getBar(val: string) {
   // order of operations:
   // order of operations:
   // 1. val             (source 2 mixin)
   // 1. val             (source 2 mixin)
   // 2. `${val}-${baz}` (source 3 mixin with stopPropagation)
   // 2. `${val}-${baz}` (source 3 mixin with stopPropagation)
@@ -2057,7 +2068,8 @@ function getBar(val: string) {
   // result: "Hello-1337"
   // result: "Hello-1337"
 
 
   // context object is mandatory because of the generic type at `new Mixins<...>()`:
   // context object is mandatory because of the generic type at `new Mixins<...>()`:
-  return myMixins.resolve("bar", val, { baz });
+  // also, resolve returns a Promise because the mixin function signature is async:
+  return await myMixins.resolve("bar", val, { baz });
 }
 }
 
 
 // mixin from source 1 (priority 0):
 // mixin from source 1 (priority 0):
@@ -2078,22 +2090,26 @@ const acBarSrc3 = new AbortController();
 const { abort: removeBarSrc3 } = acBarSrc3;
 const { abort: removeBarSrc3 } = acBarSrc3;
 
 
 // mixin from source 3 (priority 0.5 & stopPropagation):
 // mixin from source 3 (priority 0.5 & stopPropagation):
-myMixins.add("bar", (val, ctx) => {
-  return `${val}-${ctx.baz}`;
-}, {
+myMixins.add("bar", (val, ctx) => new Promise((resolve) => {
+  // async mixin chains allow for lazy-loading and other async operations:
+  setTimeout(() => {
+    resolve(`${val}-${ctx.baz}`);
+  }, 1000);
+}), {
   priority: 0.5,
   priority: 0.5,
   stopPropagation: true,
   stopPropagation: true,
   signal: acBarSrc3.signal,
   signal: acBarSrc3.signal,
 });
 });
 
 
-getBar("Hello"); // "Hello-1337"
+// applies source 2 (practically disabled) and source 3:
+await getBar("Hello"); // "Hello-1337"
 
 
-// remove source 3 from "bar" mixins:
+// remove source 3 from "bar" mixins and set baz < 1000:
 removeBarSrc3();
 removeBarSrc3();
+baz = 999;
 
 
 // only source 2 is left:
 // only source 2 is left:
-baz = 999;
-getBar("Hello"); // "Hello < 1000"
+await getBar("Hello"); // "Hello < 1000"
 ```
 ```
 </details>
 </details>
 
 

+ 1 - 0
eslint.config.mjs

@@ -63,6 +63,7 @@ const config = [
         ignoreRestSiblings: true,
         ignoreRestSiblings: true,
         args: "after-used",
         args: "after-used",
         argsIgnorePattern: "^_",
         argsIgnorePattern: "^_",
+        varsIgnorePattern: "^_",
       }],
       }],
       "no-unused-vars": "off",
       "no-unused-vars": "off",
       "@typescript-eslint/ban-ts-comment": "off",
       "@typescript-eslint/ban-ts-comment": "off",

+ 77 - 37
lib/Mixins.ts

@@ -6,14 +6,31 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
 
 import { purifyObj } from "./misc.js";
 import { purifyObj } from "./misc.js";
+import type { Prettify } from "./types.js";
 
 
-/** Full mixin object as it is stored in the instance's mixin array. */
-export type MixinObj<TArg, TCtx> = {
-  /** The public identifier key (purpose) of the mixin */
-  key: string;
+/** Full mixin object (either sync or async), as it is stored in the instance's mixin array. */
+export type MixinObj<TArg, TCtx> = Prettify<
+  | MixinObjSync<TArg, TCtx>
+  | MixinObjAsync<TArg, TCtx>
+>;
+
+/** Asynchronous mixin object, as it is stored in the instance's mixin array. */
+export type MixinObjSync<TArg, TCtx> = Prettify<{
   /** The mixin function */
   /** The mixin function */
   fn: (arg: TArg, ctx?: TCtx) => TArg;
   fn: (arg: TArg, ctx?: TCtx) => TArg;
-} & MixinConfig;
+} & MixinObjBase>;
+
+/** Synchronous mixin object, as it is stored in the instance's mixin array. */
+export type MixinObjAsync<TArg, TCtx> = Prettify<{
+  /** The mixin function */
+  fn: (arg: TArg, ctx?: TCtx) => TArg | Promise<TArg>;
+} & MixinObjBase>;
+
+/** Base type for mixin objects */
+type MixinObjBase = Prettify<{
+  /** The public identifier key (purpose) of the mixin */
+  key: string;
+} & MixinConfig>;
 
 
 /** Configuration object for a mixin function */
 /** Configuration object for a mixin function */
 export type MixinConfig = {
 export type MixinConfig = {
@@ -50,7 +67,8 @@ export type MixinsConstructorConfig = {
  * const { abort: removeAllMixins } = ac;
  * const { abort: removeAllMixins } = ac;
  * 
  * 
  * const mathMixins = new Mixins<{
  * const mathMixins = new Mixins<{
- *   foo: (val: number, ctx: { baz: string }) => number;
+ *   // supports sync and async functions:
+ *   foo: (val: number, ctx: { baz: string }) => Promise<number>;
  *   // first argument and return value have to be of the same type:
  *   // first argument and return value have to be of the same type:
  *   bar: (val: bigint) => bigint;
  *   bar: (val: bigint) => bigint;
  *   // ...
  *   // ...
@@ -61,25 +79,28 @@ export type MixinsConstructorConfig = {
  * });
  * });
  * 
  * 
  * // will be applied last due to base priority of 0:
  * // will be applied last due to base priority of 0:
- * mathMixins.add("foo", (val, ctx) => val * 2 + ctx.baz.length);
+ * mathMixins.add("foo", (val, ctx) => Promise.resolve(val * 2 + ctx.baz.length));
  * // will be applied second due to manually set priority of 1:
  * // will be applied second due to manually set priority of 1:
  * mathMixins.add("foo", (val) => val + 1, { priority: 1 });
  * mathMixins.add("foo", (val) => val + 1, { priority: 1 });
  * // will be applied first, even though the above ones were called first, because of the auto-incrementing priority of 2:
  * // will be applied first, even though the above ones were called first, because of the auto-incrementing priority of 2:
  * mathMixins.add("foo", (val) => val / 2);
  * mathMixins.add("foo", (val) => val / 2);
  * 
  * 
- * const result = mathMixins.resolve("foo", 10, { baz: "my length is 15" });
+ * const result = await mathMixins.resolve("foo", 10, { baz: "this has a length of 23" });
  * // order of application:
  * // order of application:
  * // input value: 10
  * // input value: 10
  * // 10 / 2 = 5
  * // 10 / 2 = 5
  * // 5 + 1 = 6
  * // 5 + 1 = 6
- * // 6 * 2 + 15 = 27
- * // result = 27
+ * // 6 * 2 + 23 = 35
+ * // result = 35
  * 
  * 
  * // removes all mixins added without a `signal` property:
  * // removes all mixins added without a `signal` property:
  * removeAllMixins();
  * removeAllMixins();
  * ```
  * ```
  */
  */
-export class Mixins<TMixinMap extends Record<string, (arg: any, ctx?: any) => any>> {
+export class Mixins<
+  TMixinMap extends Record<string, (arg: any, ctx?: any) => any>,
+  TMixinKey extends Extract<keyof TMixinMap, string> = Extract<keyof TMixinMap, string>,
+> {
   /** List of all registered mixins */
   /** List of all registered mixins */
   protected mixins: MixinObj<any, any>[] = [];
   protected mixins: MixinObj<any, any>[] = [];
 
 
@@ -87,13 +108,13 @@ export class Mixins<TMixinMap extends Record<string, (arg: any, ctx?: any) => an
   protected readonly defaultMixinCfg: MixinConfig;
   protected readonly defaultMixinCfg: MixinConfig;
 
 
   /** Whether the priorities should auto-increment if not specified */
   /** Whether the priorities should auto-increment if not specified */
-  protected readonly aiPriorityEnabled: boolean;
+  protected readonly autoIncPrioEnabled: boolean;
   /** The current auto-increment priority counter */
   /** The current auto-increment priority counter */
-  protected aiPriorityCounter = new Map<string, number>();
+  protected autoIncPrioCounter = new Map<TMixinKey, number>();
 
 
   /**
   /**
    * Creates a new Mixins instance.
    * Creates a new Mixins instance.
-   * @param config Configuration object to customize the mixin behavior.
+   * @param config Configuration object to customize the behavior.
    */
    */
   constructor(config: Partial<MixinsConstructorConfig> = {}) {
   constructor(config: Partial<MixinsConstructorConfig> = {}) {
     this.defaultMixinCfg = purifyObj({
     this.defaultMixinCfg = purifyObj({
@@ -101,7 +122,7 @@ export class Mixins<TMixinMap extends Record<string, (arg: any, ctx?: any) => an
       stopPropagation: config.defaultStopPropagation ?? false,
       stopPropagation: config.defaultStopPropagation ?? false,
       signal: config.defaultSignal,
       signal: config.defaultSignal,
     });
     });
-    this.aiPriorityEnabled = config.autoIncrementPriority ?? false;
+    this.autoIncPrioEnabled = config.autoIncrementPriority ?? false;
   }
   }
 
 
   //#region public
   //#region public
@@ -115,12 +136,12 @@ export class Mixins<TMixinMap extends Record<string, (arg: any, ctx?: any) => an
    * @returns Returns a cleanup function, to be called when this mixin is no longer needed.
    * @returns Returns a cleanup function, to be called when this mixin is no longer needed.
    */
    */
   public add<
   public add<
-    TMixinKey extends string,
-    TArg extends Parameters<TMixinMap[TMixinKey]>[0],
-    TCtx extends Parameters<TMixinMap[TMixinKey]>[1],
+    TKey extends TMixinKey,
+    TArg extends Parameters<TMixinMap[TKey]>[0],
+    TCtx extends Parameters<TMixinMap[TKey]>[1],
   >(
   >(
-    mixinKey: TMixinKey,
-    mixinFn: (arg: TArg, ...ctx: TCtx extends undefined ? [void] : [TCtx]) => TArg,
+    mixinKey: TKey,
+    mixinFn: (arg: TArg, ...ctx: TCtx extends undefined ? [void] : [TCtx]) => ReturnType<TMixinMap[TKey]> extends Promise<any> ? ReturnType<TMixinMap[TKey]> | Awaited<ReturnType<TMixinMap[TKey]>> : ReturnType<TMixinMap[TKey]>,
     config: Partial<MixinConfig> = purifyObj({}),
     config: Partial<MixinConfig> = purifyObj({}),
   ): () => void {
   ): () => void {
     const calcPrio = this.calcPriority(mixinKey, config);
     const calcPrio = this.calcPriority(mixinKey, config);
@@ -133,12 +154,12 @@ export class Mixins<TMixinMap extends Record<string, (arg: any, ctx?: any) => an
     }) as MixinObj<TArg, TCtx>;
     }) as MixinObj<TArg, TCtx>;
     this.mixins.push(mixin);
     this.mixins.push(mixin);
 
 
-    const clean = (): void => {
+    const rem = (): void => {
       this.mixins = this.mixins.filter((m) => m !== mixin);
       this.mixins = this.mixins.filter((m) => m !== mixin);
     };
     };
-    config.signal?.addEventListener("abort", clean, { once: true });
+    config.signal?.addEventListener("abort", rem, { once: true });
 
 
-    return clean;
+    return rem;
   }
   }
 
 
   /** Returns a list of all added mixins with their keys and configuration objects, but not their functions */
   /** Returns a list of all added mixins with their keys and configuration objects, but not their functions */
@@ -152,52 +173,71 @@ export class Mixins<TMixinMap extends Record<string, (arg: any, ctx?: any) => an
    * @returns The modified value after all mixins have been applied.
    * @returns The modified value after all mixins have been applied.
    */
    */
   public resolve<
   public resolve<
-    TMixinKey extends keyof TMixinMap,
-    TArg extends Parameters<TMixinMap[TMixinKey]>[0],
-    TCtx extends Parameters<TMixinMap[TMixinKey]>[1],
+    TKey extends TMixinKey,
+    TArg extends Parameters<TMixinMap[TKey]>[0],
+    TCtx extends Parameters<TMixinMap[TKey]>[1],
   >(
   >(
-    mixinKey: TMixinKey,
+    mixinKey: TKey,
     inputValue: TArg,
     inputValue: TArg,
     ...inputCtx: TCtx extends undefined ? [void] : [TCtx]
     ...inputCtx: TCtx extends undefined ? [void] : [TCtx]
-  ): TArg {
+  ): ReturnType<TMixinMap[TKey]> extends Promise<any> ? ReturnType<TMixinMap[TKey]> : ReturnType<TMixinMap[TKey]> {
     const mixins = this.mixins.filter((m) => m.key === mixinKey);
     const mixins = this.mixins.filter((m) => m.key === mixinKey);
     const sortedMixins = mixins.sort((a, b) => a.priority - b.priority);
     const sortedMixins = mixins.sort((a, b) => a.priority - b.priority);
     let result = inputValue;
     let result = inputValue;
-    for(const mixin of sortedMixins) {
+
+    // start resolving synchronously:
+    for(let i = 0; i < sortedMixins.length; i++) {
+      const mixin = sortedMixins[i]!;
       result = mixin.fn(result, ...inputCtx);
       result = mixin.fn(result, ...inputCtx);
-      if(mixin.stopPropagation)
+      if(result as unknown instanceof Promise) {
+        // if one of the mixins is async, switch to async resolution:
+        return (async () => {
+          result = await result;
+          if(mixin.stopPropagation)
+            return result;
+          for(let j = i + 1; j < sortedMixins.length; j++) {
+            const mixin = sortedMixins[j]!;
+            result = await mixin.fn(result, ...inputCtx);
+            if(mixin.stopPropagation)
+              break;
+          }
+          return result;
+        })() as ReturnType<TMixinMap[TKey]> extends Promise<any> ? ReturnType<TMixinMap[TKey]> : never;
+      }
+      else if(mixin.stopPropagation)
         break;
         break;
     }
     }
+
     return result;
     return result;
   }
   }
 
 
   //#region protected
   //#region protected
 
 
   /** Calculates the priority for a mixin based on the given configuration and the current auto-increment state of the instance */
   /** Calculates the priority for a mixin based on the given configuration and the current auto-increment state of the instance */
-  protected calcPriority(mixinKey: string, config: Partial<MixinConfig>): number | undefined {
+  protected calcPriority(mixinKey: TMixinKey, config: Partial<MixinConfig>): number | undefined {
     // if prio specified, skip calculation
     // if prio specified, skip calculation
     if(config.priority !== undefined)
     if(config.priority !== undefined)
       return undefined;
       return undefined;
 
 
     // if a-i disabled, use default prio
     // if a-i disabled, use default prio
-    if(!this.aiPriorityEnabled)
+    if(!this.autoIncPrioEnabled)
       return config.priority ?? this.defaultMixinCfg.priority;
       return config.priority ?? this.defaultMixinCfg.priority;
 
 
     // initialize a-i map to default prio
     // initialize a-i map to default prio
-    if(!this.aiPriorityCounter.has(mixinKey))
-      this.aiPriorityCounter.set(mixinKey, this.defaultMixinCfg.priority);
+    if(!this.autoIncPrioCounter.has(mixinKey))
+      this.autoIncPrioCounter.set(mixinKey, this.defaultMixinCfg.priority);
 
 
     // increment a-i prio until unique
     // increment a-i prio until unique
-    let prio = this.aiPriorityCounter.get(mixinKey)!;
+    let prio = this.autoIncPrioCounter.get(mixinKey)!;
     while(this.mixins.some((m) => m.key === mixinKey && m.priority === prio))
     while(this.mixins.some((m) => m.key === mixinKey && m.priority === prio))
       prio++;
       prio++;
-    this.aiPriorityCounter.set(mixinKey, prio + 1);
+    this.autoIncPrioCounter.set(mixinKey, prio + 1);
 
 
     return prio;
     return prio;
   }
   }
 
 
   /** Removes all mixins with the given key */
   /** Removes all mixins with the given key */
-  protected removeAll<TMixinKey extends keyof TMixinMap>(mixinKey: TMixinKey): void {
+  protected removeAll(mixinKey: TMixinKey): void {
     this.mixins.filter((m) => m.key === mixinKey);
     this.mixins.filter((m) => m.key === mixinKey);
     this.mixins = this.mixins.filter((m) => m.key !== mixinKey);
     this.mixins = this.mixins.filter((m) => m.key !== mixinKey);
   }
   }