feat(lib/patcher): hook priority

This commit is contained in:
PalmDevs 2025-10-24 21:27:07 +07:00
parent 96ea57c559
commit bdf2b142d7
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
8 changed files with 375 additions and 18 deletions

View file

@ -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:

View file

@ -32,6 +32,7 @@ export interface HookNode<Hook extends UnknownFunction = UnknownFunction> {
unpatched: boolean
/** Can be undefined only in instances that are not in a hook node list. */
hook: Hook | undefined
priority: number
prev: HookNode<Hook> | undefined
next: HookNode<Hook> | undefined
}
@ -40,6 +41,7 @@ export interface InsteadHookNode<T extends UnknownFunction = UnknownFunction>
extends HookNode<InsteadHook<T>>,
FunctionProxyState<T> {
hook: InsteadHook<T> | undefined
priority: number
prev: InsteadHookNode<T> | undefined
next: InsteadHookNode<T> | undefined
}

View file

@ -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<T extends UnknownFunction>(
* @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, UnknownFunction>,
Key extends keyof Parent,
>(parent: Parent, key: Key, hook: AfterHook<Parent[Key]>): UnpatchFunction
>(
parent: Parent,
key: Key,
hook: AfterHook<Parent[Key]>,
options?: HookOptions,
): UnpatchFunction
export function after<Key extends PropertyKey, Value extends UnknownFunction>(
parent: Record<Key, Value>,
key: FiniteDomain<Key>,
hook: AfterHook<Value>,
options?: HookOptions,
): UnpatchFunction {
const target = parent[key]
const priority = options?.priority ?? 0
let state = patchedFunctionProxyStates.get(target)
let hookNode: HookNode<typeof hook>
if (state?.parent === parent && state.key === key) {
const head = state.after
let current = state.after
let prev: HookNode<typeof hook> | 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,

View file

@ -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<T extends UnknownFunction>(
* @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, UnknownFunction>,
Key extends keyof Parent,
>(parent: Parent, key: Key, hook: BeforeHook<Parent[Key]>): UnpatchFunction
>(
parent: Parent,
key: Key,
hook: BeforeHook<Parent[Key]>,
options?: HookOptions,
): UnpatchFunction
export function before<Key extends PropertyKey, Value extends UnknownFunction>(
parent: Record<Key, Value>,
key: FiniteDomain<Key>,
hook: BeforeHook<Value>,
options?: HookOptions,
): UnpatchFunction {
const target = parent[key]
const priority = options?.priority ?? 0
let state = patchedFunctionProxyStates.get(target)
let hookNode: HookNode<typeof hook>
if (state?.parent === parent && state.key === key) {
const head = state.before
let current = state.before
let prev: HookNode<typeof hook> | 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,

View file

@ -13,6 +13,7 @@ import type {
AbstractNewable,
Callable,
FiniteDomain,
HookOptions,
InsteadHook,
UnknownFunction,
UnpatchFunction,
@ -105,19 +106,27 @@ function unpatchInstead<T extends UnknownFunction>(
* @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, UnknownFunction>,
Key extends keyof Parent,
>(parent: Parent, key: Key, hook: InsteadHook<Parent[Key]>): UnpatchFunction
>(
parent: Parent,
key: Key,
hook: InsteadHook<Parent[Key]>,
options?: HookOptions,
): UnpatchFunction
export function instead<Key extends PropertyKey, Value extends UnknownFunction>(
parent: Record<Key, Value>,
key: FiniteDomain<Key>,
hook: InsteadHook<Value>,
options?: HookOptions,
): UnpatchFunction {
const target = parent[key]
const priority = options?.priority ?? 0
let state = patchedFunctionProxyStates.get(target)
@ -128,13 +137,30 @@ export function instead<Key extends PropertyKey, Value extends UnknownFunction>(
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<Value> | 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(

View file

@ -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])
})
})
})

View file

@ -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'

View file

@ -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
}