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. 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 ### Constructor patching
You can also patch constructor functions: You can also patch constructor functions:

View file

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

View file

@ -7,6 +7,7 @@ import type { HookNode, PatchedFunctionProxyState } from '../_internal'
import type { import type {
AfterHook, AfterHook,
FiniteDomain, FiniteDomain,
HookOptions,
UnknownFunction, UnknownFunction,
UnpatchFunction, UnpatchFunction,
} from '../types' } from '../types'
@ -62,35 +63,60 @@ function unpatchAfter<T extends UnknownFunction>(
* @param parent The parent object containing the method to patch. * @param parent The parent object containing the method to patch.
* @param key The key of 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 hook The hook function to execute after the original method.
* @param options Optional configuration including priority.
* *
* @returns A function to unpatch. * @returns A function to unpatch.
*/ */
export function after< export function after<
Parent extends Record<Key, UnknownFunction>, Parent extends Record<Key, UnknownFunction>,
Key extends keyof Parent, 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>( export function after<Key extends PropertyKey, Value extends UnknownFunction>(
parent: Record<Key, Value>, parent: Record<Key, Value>,
key: FiniteDomain<Key>, key: FiniteDomain<Key>,
hook: AfterHook<Value>, hook: AfterHook<Value>,
options?: HookOptions,
): UnpatchFunction { ): UnpatchFunction {
const target = parent[key] const target = parent[key]
const priority = options?.priority ?? 0
let state = patchedFunctionProxyStates.get(target) let state = patchedFunctionProxyStates.get(target)
let hookNode: HookNode<typeof hook> let hookNode: HookNode<typeof hook>
if (state?.parent === parent && state.key === key) { 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 = { hookNode = {
hook, hook,
next: head, priority,
prev: undefined, next: current,
prev,
unpatched: false, 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 { } else {
hookNode = { hookNode = {
hook, hook,
priority,
next: undefined, next: undefined,
prev: undefined, prev: undefined,
unpatched: false, unpatched: false,

View file

@ -7,6 +7,7 @@ import type { HookNode, PatchedFunctionProxyState } from '../_internal'
import type { import type {
BeforeHook, BeforeHook,
FiniteDomain, FiniteDomain,
HookOptions,
UnknownFunction, UnknownFunction,
UnpatchFunction, UnpatchFunction,
} from '../types' } from '../types'
@ -62,35 +63,60 @@ function unpatchBefore<T extends UnknownFunction>(
* @param parent The parent object containing the method to patch. * @param parent The parent object containing the method to patch.
* @param key The key of 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 hook The hook function to execute before the original method.
* @param options Optional configuration including priority.
* *
* @returns A function to unpatch. * @returns A function to unpatch.
*/ */
export function before< export function before<
Parent extends Record<Key, UnknownFunction>, Parent extends Record<Key, UnknownFunction>,
Key extends keyof Parent, 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>( export function before<Key extends PropertyKey, Value extends UnknownFunction>(
parent: Record<Key, Value>, parent: Record<Key, Value>,
key: FiniteDomain<Key>, key: FiniteDomain<Key>,
hook: BeforeHook<Value>, hook: BeforeHook<Value>,
options?: HookOptions,
): UnpatchFunction { ): UnpatchFunction {
const target = parent[key] const target = parent[key]
const priority = options?.priority ?? 0
let state = patchedFunctionProxyStates.get(target) let state = patchedFunctionProxyStates.get(target)
let hookNode: HookNode<typeof hook> let hookNode: HookNode<typeof hook>
if (state?.parent === parent && state.key === key) { 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 = { hookNode = {
hook, hook,
next: head, priority,
prev: undefined, next: current,
prev,
unpatched: false, 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 { } else {
hookNode = { hookNode = {
hook, hook,
priority,
next: undefined, next: undefined,
prev: undefined, prev: undefined,
unpatched: false, unpatched: false,

View file

@ -13,6 +13,7 @@ import type {
AbstractNewable, AbstractNewable,
Callable, Callable,
FiniteDomain, FiniteDomain,
HookOptions,
InsteadHook, InsteadHook,
UnknownFunction, UnknownFunction,
UnpatchFunction, UnpatchFunction,
@ -105,19 +106,27 @@ function unpatchInstead<T extends UnknownFunction>(
* @param parent The parent object containing the method to patch. * @param parent The parent object containing the method to patch.
* @param key The key of 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 hook The hook function to execute instead of the original method.
* @param options Optional configuration including priority.
* *
* @return A function to unpatch. * @return A function to unpatch.
*/ */
export function instead< export function instead<
Parent extends Record<Key, UnknownFunction>, Parent extends Record<Key, UnknownFunction>,
Key extends keyof Parent, 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>( export function instead<Key extends PropertyKey, Value extends UnknownFunction>(
parent: Record<Key, Value>, parent: Record<Key, Value>,
key: FiniteDomain<Key>, key: FiniteDomain<Key>,
hook: InsteadHook<Value>, hook: InsteadHook<Value>,
options?: HookOptions,
): UnpatchFunction { ): UnpatchFunction {
const target = parent[key] const target = parent[key]
const priority = options?.priority ?? 0
let state = patchedFunctionProxyStates.get(target) let state = patchedFunctionProxyStates.get(target)
@ -128,13 +137,30 @@ export function instead<Key extends PropertyKey, Value extends UnknownFunction>(
hookNode.target = target hookNode.target = target
hookNode.unpatched = false hookNode.unpatched = false
hookNode.hook = hook hookNode.hook = hook
hookNode.priority = priority
hookNode.prev = undefined hookNode.prev = undefined
if (state?.parent === parent && state.key === key) { if (state?.parent === parent && state.key === key) {
const head = state.instead let current = state.instead
hookNode.next = head let prev: InsteadHookNode<Value> | undefined
if (head) head.prev = hookNode
state.instead = hookNode 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 { } else {
hookNode.next = undefined hookNode.next = undefined
state = createPatchedFunctionProxy( state = createPatchedFunctionProxy(

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test' import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { after, before, instead } from '.' import { after, before, HookPriority, instead } from '.'
describe('Patcher', () => { describe('Patcher', () => {
let originalMethod: (...args: any[]) => string let originalMethod: (...args: any[]) => string
@ -469,4 +469,197 @@ describe('Patcher', () => {
expect(beforeHook).toHaveBeenCalledWith(['modified']) 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/after'
export * from './hooks/before' export * from './hooks/before'
export * from './hooks/instead' export * from './hooks/instead'
export { HookPriority } from './types'
export type { HookOptions } from './types'

View file

@ -224,3 +224,15 @@ export type OverloadUnionNewableInner<
: never : never
export type UnpatchFunction = () => void export type UnpatchFunction = () => void
export enum HookPriority {
LOWEST = -1000,
LOW = -500,
NORMAL = 0,
HIGH = 500,
HIGHEST = 1000,
}
export interface HookOptions {
priority?: number
}