From bdf2b142d797295d96f2e07a1c5fff1b171f891b Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Fri, 24 Oct 2025 21:27:07 +0700 Subject: [PATCH] feat(lib/patcher): hook priority --- lib/patcher/README.md | 70 +++++++++++ lib/patcher/src/_internal.ts | 2 + lib/patcher/src/hooks/after.ts | 38 +++++- lib/patcher/src/hooks/before.ts | 38 +++++- lib/patcher/src/hooks/instead.ts | 36 +++++- lib/patcher/src/index.test.ts | 195 ++++++++++++++++++++++++++++++- lib/patcher/src/index.ts | 2 + lib/patcher/src/types.ts | 12 ++ 8 files changed, 375 insertions(+), 18 deletions(-) diff --git a/lib/patcher/README.md b/lib/patcher/README.md index c6c8658..73f5183 100644 --- a/lib/patcher/README.md +++ b/lib/patcher/README.md @@ -243,6 +243,76 @@ obj.method(2) Unpatch functions are safe to call multiple times and won't throw errors. +### Hook priority + +All hooks support an optional priority parameter to control execution order. Higher priority hooks execute before lower priority hooks. + +```js +import { before, after, instead, HookPriority } from '@revenge-mod/patcher' + +const obj = { + method: (a) => a +} + +// Before hooks execute in ascending priority order (lowest to highest) +before(obj, 'method', (args) => { + console.log('Low priority before hook') + return args +}, { priority: HookPriority.LOW }) + +before(obj, 'method', (args) => { + console.log('High priority before hook') + return args +}, { priority: HookPriority.HIGH }) + +obj.method(1) +// CONSOLE OUTPUT: +// Low priority before hook +// High priority before hook + +// After hooks execute in descending priority order (highest to lowest) +after(obj, 'method', (result) => { + console.log('High priority after hook') + return result +}, { priority: HookPriority.HIGH }) + +after(obj, 'method', (result) => { + console.log('Low priority after hook') + return result +}, { priority: HookPriority.LOW }) + +obj.method(1) +// CONSOLE OUTPUT: +// High priority after hook +// Low priority after hook + +// Instead hooks execute in descending priority order (highest to lowest) +instead(obj, 'method', function (args, original) { + console.log('High priority instead hook') + return original.apply(this, args) +}, { priority: HookPriority.HIGH }) + +instead(obj, 'method', function (args, original) { + console.log('Low priority instead hook') + return original.apply(this, args) +}, { priority: HookPriority.LOW }) + +obj.method(1) +// CONSOLE OUTPUT: +// High priority instead hook +// Low priority instead hook +``` + +Priority constants: + +- `HookPriority.HIGHEST` (1000) +- `HookPriority.HIGH` (500) +- `HookPriority.NORMAL` (0) - default +- `HookPriority.LOW` (-500) +- `HookPriority.LOWEST` (-1000) + +You can also use custom numeric values for fine-grained control. + ### Constructor patching You can also patch constructor functions: diff --git a/lib/patcher/src/_internal.ts b/lib/patcher/src/_internal.ts index f9fcd76..bb00be5 100644 --- a/lib/patcher/src/_internal.ts +++ b/lib/patcher/src/_internal.ts @@ -32,6 +32,7 @@ export interface HookNode { unpatched: boolean /** Can be undefined only in instances that are not in a hook node list. */ hook: Hook | undefined + priority: number prev: HookNode | undefined next: HookNode | undefined } @@ -40,6 +41,7 @@ export interface InsteadHookNode extends HookNode>, FunctionProxyState { hook: InsteadHook | undefined + priority: number prev: InsteadHookNode | undefined next: InsteadHookNode | undefined } diff --git a/lib/patcher/src/hooks/after.ts b/lib/patcher/src/hooks/after.ts index a4b253b..99d77cf 100644 --- a/lib/patcher/src/hooks/after.ts +++ b/lib/patcher/src/hooks/after.ts @@ -7,6 +7,7 @@ import type { HookNode, PatchedFunctionProxyState } from '../_internal' import type { AfterHook, FiniteDomain, + HookOptions, UnknownFunction, UnpatchFunction, } from '../types' @@ -62,35 +63,60 @@ function unpatchAfter( * @param parent The parent object containing the method to patch. * @param key The key of the method to patch. * @param hook The hook function to execute after the original method. + * @param options Optional configuration including priority. * * @returns A function to unpatch. */ export function after< Parent extends Record, Key extends keyof Parent, ->(parent: Parent, key: Key, hook: AfterHook): UnpatchFunction +>( + parent: Parent, + key: Key, + hook: AfterHook, + options?: HookOptions, +): UnpatchFunction export function after( parent: Record, key: FiniteDomain, hook: AfterHook, + options?: HookOptions, ): UnpatchFunction { const target = parent[key] + const priority = options?.priority ?? 0 let state = patchedFunctionProxyStates.get(target) let hookNode: HookNode if (state?.parent === parent && state.key === key) { - const head = state.after + let current = state.after + let prev: HookNode | undefined + + while (current && current.priority > priority) { + prev = current + current = current.next + } + hookNode = { hook, - next: head, - prev: undefined, + priority, + next: current, + prev, unpatched: false, } - if (head) head.prev = hookNode - state.after = hookNode + + if (prev) { + prev.next = hookNode + } else { + state.after = hookNode + } + + if (current) { + current.prev = hookNode + } } else { hookNode = { hook, + priority, next: undefined, prev: undefined, unpatched: false, diff --git a/lib/patcher/src/hooks/before.ts b/lib/patcher/src/hooks/before.ts index 6b0a858..7cdcbe1 100644 --- a/lib/patcher/src/hooks/before.ts +++ b/lib/patcher/src/hooks/before.ts @@ -7,6 +7,7 @@ import type { HookNode, PatchedFunctionProxyState } from '../_internal' import type { BeforeHook, FiniteDomain, + HookOptions, UnknownFunction, UnpatchFunction, } from '../types' @@ -62,35 +63,60 @@ function unpatchBefore( * @param parent The parent object containing the method to patch. * @param key The key of the method to patch. * @param hook The hook function to execute before the original method. + * @param options Optional configuration including priority. * * @returns A function to unpatch. */ export function before< Parent extends Record, Key extends keyof Parent, ->(parent: Parent, key: Key, hook: BeforeHook): UnpatchFunction +>( + parent: Parent, + key: Key, + hook: BeforeHook, + options?: HookOptions, +): UnpatchFunction export function before( parent: Record, key: FiniteDomain, hook: BeforeHook, + options?: HookOptions, ): UnpatchFunction { const target = parent[key] + const priority = options?.priority ?? 0 let state = patchedFunctionProxyStates.get(target) let hookNode: HookNode if (state?.parent === parent && state.key === key) { - const head = state.before + let current = state.before + let prev: HookNode | undefined + + while (current && current.priority < priority) { + prev = current + current = current.next + } + hookNode = { hook, - next: head, - prev: undefined, + priority, + next: current, + prev, unpatched: false, } - if (head) head.prev = hookNode - state.before = hookNode + + if (prev) { + prev.next = hookNode + } else { + state.before = hookNode + } + + if (current) { + current.prev = hookNode + } } else { hookNode = { hook, + priority, next: undefined, prev: undefined, unpatched: false, diff --git a/lib/patcher/src/hooks/instead.ts b/lib/patcher/src/hooks/instead.ts index 478bad4..a20c1a0 100644 --- a/lib/patcher/src/hooks/instead.ts +++ b/lib/patcher/src/hooks/instead.ts @@ -13,6 +13,7 @@ import type { AbstractNewable, Callable, FiniteDomain, + HookOptions, InsteadHook, UnknownFunction, UnpatchFunction, @@ -105,19 +106,27 @@ function unpatchInstead( * @param parent The parent object containing the method to patch. * @param key The key of the method to patch. * @param hook The hook function to execute instead of the original method. + * @param options Optional configuration including priority. * * @return A function to unpatch. */ export function instead< Parent extends Record, Key extends keyof Parent, ->(parent: Parent, key: Key, hook: InsteadHook): UnpatchFunction +>( + parent: Parent, + key: Key, + hook: InsteadHook, + options?: HookOptions, +): UnpatchFunction export function instead( parent: Record, key: FiniteDomain, hook: InsteadHook, + options?: HookOptions, ): UnpatchFunction { const target = parent[key] + const priority = options?.priority ?? 0 let state = patchedFunctionProxyStates.get(target) @@ -128,13 +137,30 @@ export function instead( hookNode.target = target hookNode.unpatched = false hookNode.hook = hook + hookNode.priority = priority hookNode.prev = undefined if (state?.parent === parent && state.key === key) { - const head = state.instead - hookNode.next = head - if (head) head.prev = hookNode - state.instead = hookNode + let current = state.instead + let prev: InsteadHookNode | undefined + + while (current && current.priority > priority) { + prev = current + current = current.next + } + + hookNode.next = current + + if (prev) { + prev.next = hookNode + hookNode.prev = prev + } else { + state.instead = hookNode + } + + if (current) { + current.prev = hookNode + } } else { hookNode.next = undefined state = createPatchedFunctionProxy( diff --git a/lib/patcher/src/index.test.ts b/lib/patcher/src/index.test.ts index 5da6f69..ec67944 100644 --- a/lib/patcher/src/index.test.ts +++ b/lib/patcher/src/index.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test' -import { after, before, instead } from '.' +import { after, before, HookPriority, instead } from '.' describe('Patcher', () => { let originalMethod: (...args: any[]) => string @@ -469,4 +469,197 @@ describe('Patcher', () => { expect(beforeHook).toHaveBeenCalledWith(['modified']) }) }) + + describe('priority system', () => { + test('before hooks should execute in ascending priority order', () => { + const order: number[] = [] + + before( + obj, + 'method', + args => { + order.push(2) + return args + }, + { priority: HookPriority.NORMAL }, + ) + + before( + obj, + 'method', + args => { + order.push(1) + return args + }, + { priority: HookPriority.LOWEST }, + ) + + before( + obj, + 'method', + args => { + order.push(3) + return args + }, + { priority: HookPriority.HIGHEST }, + ) + + obj.method() + expect(order).toEqual([1, 2, 3]) + }) + + test('after hooks should execute in descending priority order', () => { + const order: number[] = [] + + after( + obj, + 'method', + result => { + order.push(2) + return result + }, + { priority: HookPriority.NORMAL }, + ) + + after( + obj, + 'method', + result => { + order.push(3) + return result + }, + { priority: HookPriority.LOWEST }, + ) + + after( + obj, + 'method', + result => { + order.push(1) + return result + }, + { priority: HookPriority.HIGHEST }, + ) + + obj.method() + expect(order).toEqual([1, 2, 3]) + }) + + test('instead hooks should execute highest priority first', () => { + const order: number[] = [] + + instead( + obj, + 'method', + (args, original) => { + order.push(1) + return `1-${original(...args)}` + }, + { priority: HookPriority.LOWEST }, + ) + + instead( + obj, + 'method', + (args, original) => { + order.push(2) + return `2-${original(...args)}` + }, + { priority: HookPriority.NORMAL }, + ) + + instead( + obj, + 'method', + (args, original) => { + order.push(3) + return `3-${original(...args)}` + }, + { priority: HookPriority.HIGHEST }, + ) + + const result = obj.method() + expect(order).toEqual([3, 2, 1]) + expect(result).toBe('3-2-1-original') + }) + + test('before hooks with custom priority values', () => { + const order: number[] = [] + + before( + obj, + 'method', + args => { + order.push(2) + return args + }, + { priority: 0 }, + ) + + before( + obj, + 'method', + args => { + order.push(1) + return args + }, + { priority: -100 }, + ) + + before( + obj, + 'method', + args => { + order.push(3) + return args + }, + { priority: 100 }, + ) + + before( + obj, + 'method', + args => { + order.push(4) + return args + }, + { priority: 150 }, + ) + + obj.method() + expect(order).toEqual([1, 2, 3, 4]) + }) + + test('hooks without priority should default to 0', () => { + const order: number[] = [] + + before( + obj, + 'method', + args => { + order.push(2) + return args + }, + { priority: -10 }, + ) + + before(obj, 'method', args => { + order.push(3) + return args + }) + + before( + obj, + 'method', + args => { + order.push(4) + return args + }, + { priority: 10 }, + ) + + obj.method() + expect(order).toEqual([2, 3, 4]) + }) + }) }) diff --git a/lib/patcher/src/index.ts b/lib/patcher/src/index.ts index 331f713..2e7dfbb 100644 --- a/lib/patcher/src/index.ts +++ b/lib/patcher/src/index.ts @@ -1,3 +1,5 @@ export * from './hooks/after' export * from './hooks/before' export * from './hooks/instead' +export { HookPriority } from './types' +export type { HookOptions } from './types' diff --git a/lib/patcher/src/types.ts b/lib/patcher/src/types.ts index 5755f1f..2a9d72c 100644 --- a/lib/patcher/src/types.ts +++ b/lib/patcher/src/types.ts @@ -224,3 +224,15 @@ export type OverloadUnionNewableInner< : never export type UnpatchFunction = () => void + +export enum HookPriority { + LOWEST = -1000, + LOW = -500, + NORMAL = 0, + HIGH = 500, + HIGHEST = 1000, +} + +export interface HookOptions { + priority?: number +}