feat(lib/patcher): hook priority
This commit is contained in:
parent
96ea57c559
commit
bdf2b142d7
8 changed files with 375 additions and 18 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue