chore(lib/patcher): add tests and docs

This commit is contained in:
PalmDevs 2025-06-09 22:47:01 +07:00
parent 5c54e3d8e5
commit fc8067d599
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
5 changed files with 768 additions and 3 deletions

View file

@ -8,7 +8,7 @@
"@parcel/watcher": "^2.5.1",
"@react-native-clipboard/clipboard": "^1.16.2",
"@swc/core": "^1.11.31",
"@types/bun": "latest",
"@types/bun": "^1.2.15",
"@types/node": "^22.15.30",
"@unbound-mod/hermesc": "^1.0.3",
"chalk": "^5.4.1",

View file

@ -4,4 +4,268 @@ This is a JavaScript library that allows to monkey patch functions in a way that
It is made by [ryan-0324](https://github.com/ryan-0324) [(source uploaded on Discord)](https://discord.com/channels/1205207689832038522/1284131216156655646/1316076586981789709) and licensed under [CC0](./LICENSE).
This source code is adapted to work with the Revenge Modification Project, and we do not claim any rights to this library. You may use it following the license.
This source code is polished and adapted to work with Revenge. We do not claim any rights to this library. You may use it granted you follow the license.
## How does this work?
Think of hooks as separate functions that are called `before`, `after`, or `instead` of the original function.
You can use them to modify the behavior of the original function, or to add new functionality.
### Before hooks
Before hooks allow you to modify the arguments passed to the original function, or to perform some action before the original function is called.
```js
import { before } from '@revenge-mod/patcher'
const obj = {
method: (a) => console.log('Original method called with:', a)
}
before(obj, 'method', ([a]) => {
console.log('Before method called with:', a)
// Modify arguments by returning new array
return [a + 1]
})
obj.method(2)
// CONSOLE OUTPUT:
// Before method called with: 2
// Original method called with: 3
```
### After hooks
After hooks allow you to modify the return value of the original function, or to perform some action after the original function is called.
```js
import { after } from '@revenge-mod/patcher'
const obj = {
method: (a) => a * 2
}
after(obj, 'method', (result) => {
console.log('After method called with result:', result)
// Modify return value
return result + 1
})
console.log(obj.method(2)) // 5
// CONSOLE OUTPUT:
// After method called with result: 4
// 5
```
### Instead hooks
Instead hooks allow you to completely replace the original function with a new one, while still being able to call the original function if needed.
```js
import { instead } from '@revenge-mod/patcher'
const obj = {
method: (a) => {
console.log('Original method called with:', a)
return 'original result'
}
}
instead(obj, 'method', ([a], original) => {
console.log('Instead method called with:', a)
// Call the original function if needed
const originalResult = original('modified')
console.log('Original method was called')
// Return a new value
return 'new value'
})
console.log(obj.method('test')) // 'new value'
// CONSOLE OUTPUT:
// Instead method called with: test
// Original method called with: modified
// Original method was called
// new value
```
### Combining hooks
You can combine multiple hooks on the same function.
```js
import { before, after, instead } from '@revenge-mod/patcher'
const obj = {
method: (a) => a * 2
}
before(obj, 'method', ([a]) => {
console.log('Before method called with:', a)
return [a + 1] // 2 becomes 3
})
instead(obj, 'method', ([a], original) => {
console.log('Instead method called with:', a)
const result = original(a) // Call original with modified arg (3)
console.log('Original method returned:', result)
return result + 1 // 6 becomes 7
})
after(obj, 'method', (result) => {
console.log('After method called with result:', result)
return result + 1 // 7 becomes 8
})
console.log(obj.method(2)) // 8
// CONSOLE OUTPUT:
// Before method called with: 2
// Instead method called with: 3
// Original method returned: 6
// After method called with result: 7
// 8
```
### Stacking hooks
Stacking multiple hooks of same type will execute them in a specific order:
1. **Before hooks** (execute in reverse order - last added runs first)
2. **Instead hooks** (only the last added executes, unless calling original)
3. **After hooks** (execute in order - first added runs first)
Using multiple `before` hooks will execute them in **reverse order** (LIFO - Last In, First Out):
```js
import { before } from '@revenge-mod/patcher'
const obj = {
method: (a) => console.log('Original method called with:', a)
}
before(obj, 'method', ([a]) => {
console.log('First before hook called with:', a)
return [a + 1]
})
before(obj, 'method', ([a]) => {
console.log('Second before hook called with:', a)
return [a + 2]
})
obj.method(2)
// CONSOLE OUTPUT:
// Second before hook called with: 2
// First before hook called with: 4
// Original method called with: 5
```
Multiple `after` hooks execute in **normal order** (FIFO - First In, First Out):
```js
import { after } from '@revenge-mod/patcher'
const obj = {
method: (a) => a * 2
}
after(obj, 'method', (result) => {
console.log('First after hook called with result:', result)
return result + 1
})
after(obj, 'method', (result) => {
console.log('Second after hook called with result:', result)
return result + 2
})
console.log(obj.method(2)) // 7
// CONSOLE OUTPUT:
// First after hook called with result: 4
// Second after hook called with result: 5
// 7
```
Multiple `instead` hooks will only execute the **last one added**. To chain them, you must call the original function:
```js
import { instead } from '@revenge-mod/patcher'
const obj = {
method: (a) => `original-${a}`
}
instead(obj, 'method', ([a], original) => {
console.log('First instead hook called with:', a)
return `first-${original(a)}`
})
instead(obj, 'method', ([a], original) => {
console.log('Second instead hook called with:', a)
// Calling original will execute the previous instead hook
return `second-${original(a)}`
})
console.log(obj.method('test')) // 'second-first-original-test'
// CONSOLE OUTPUT:
// Second instead hook called with: test
// First instead hook called with: test
// second-first-original-test
```
### Removing hooks
Each hook returns an unpatch function that removes the hook:
```js
import { before } from '@revenge-mod/patcher'
const obj = {
method: (a) => console.log('Original method called with:', a)
}
const unpatch = before(obj, 'method', ([a]) => {
console.log('Before method called with:', a)
return [a + 1]
})
obj.method(2)
// CONSOLE OUTPUT:
// Before method called with: 2
// Original method called with: 3
unpatch() // Remove the hook
obj.method(2)
// CONSOLE OUTPUT:
// Original method called with: 2
```
Unpatch functions are safe to call multiple times and won't throw errors.
### Constructor patching
You can also patch constructor functions:
```js
import { before } from '@revenge-mod/patcher'
class MyClass {
constructor(value) {
this.value = value
}
}
const exports = { MyClass }
before(exports, 'MyClass', ([value]) => {
console.log('Constructor called with:', value)
return [`modified-${value}`]
})
const instance = new exports.MyClass('test')
console.log(instance.value) // 'modified-test'
// CONSOLE OUTPUT:
// Constructor called with: test
// modified-test
```

View file

@ -0,0 +1,472 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { after, before, instead } from '.'
describe('Patcher', () => {
let originalMethod: (...args: any[]) => string
let obj: { method: typeof originalMethod }
beforeEach(() => {
originalMethod = mock(s => s ?? 'original')
obj = {
method: originalMethod,
}
})
describe('before', () => {
test('should execute callback before original method', () => {
const beforeCallback = mock(args => args)
before(obj, 'method', beforeCallback)
obj.method('arg1', 'arg2')
expect(beforeCallback).toHaveBeenCalledWith(['arg1', 'arg2'])
expect(originalMethod).toHaveBeenCalled()
})
test('should allow modifying arguments', () => {
before(obj, 'method', args => {
args[0] = 'modified'
return args
})
obj.method('original')
expect(originalMethod).toHaveBeenCalledWith('modified')
})
test('should unpatch correctly', () => {
const beforeCallback = mock(args => {
args[0] = 'modified'
return args
})
const unpatch = before(obj, 'method', beforeCallback)
// Test that patch is working
obj.method('original')
expect(originalMethod).toHaveBeenCalledWith('modified')
// Unpatch
unpatch()
// Test that patch is removed
obj.method('original')
expect(originalMethod).toHaveBeenCalledWith('original')
expect(beforeCallback).toHaveBeenCalledTimes(1) // Only called before unpatch
})
test('should handle multiple before hooks and unpatch correctly', () => {
const firstHook = mock(args => {
args[0] = 'first'
return args
})
const secondHook = mock(args => {
args[0] = `${args[0]}-second`
return args
})
const unpatch2 = before(obj, 'method', secondHook)
const unpatch1 = before(obj, 'method', firstHook)
obj.method('original')
expect(originalMethod).toHaveBeenCalledWith('first-second')
// Unpatch second hook
unpatch2()
obj.method('original')
expect(originalMethod).toHaveBeenCalledWith('first')
// Unpatch first hook
unpatch1()
obj.method('original')
expect(originalMethod).toHaveBeenCalledWith('original')
})
test('should not crash when unpatch is called multiple times', () => {
const beforeCallback = mock(args => args)
const unpatch = before(obj, 'method', beforeCallback)
unpatch()
expect(() => unpatch()).not.toThrow()
expect(() => unpatch()).not.toThrow()
})
})
describe('after', () => {
test('should execute callback after original method', () => {
const afterCallback = mock(() => 'original')
after(obj, 'method', afterCallback)
const result = obj.method()
expect(afterCallback).toHaveBeenCalledWith('original')
expect(result).toBe('original')
})
test('should allow modifying return value', () => {
after(obj, 'method', () => 'modified')
const result = obj.method()
expect(result).toBe('modified')
})
test('should unpatch correctly', () => {
const afterCallback = mock(() => 'modified')
const unpatch = after(obj, 'method', afterCallback)
// Test that patch is working
let result = obj.method()
expect(result).toBe('modified')
expect(afterCallback).toHaveBeenCalledWith('original')
// Unpatch
unpatch()
// Test that patch is removed
result = obj.method()
expect(result).toBe('original')
expect(afterCallback).toHaveBeenCalledTimes(1) // Only called before unpatch
})
test('should handle multiple after hooks and unpatch correctly', () => {
const firstHook = mock(result => `${result}-first`)
const secondHook = mock(result => `${result}-second`)
const unpatch1 = after(obj, 'method', firstHook)
const unpatch2 = after(obj, 'method', secondHook)
let result = obj.method()
expect(result).toBe('original-second-first')
// Unpatch second hook (most recent)
unpatch2()
result = obj.method()
expect(result).toBe('original-first')
// Unpatch first hook
unpatch1()
result = obj.method()
expect(result).toBe('original')
})
test('should not crash when unpatch is called multiple times', () => {
const afterCallback = mock(result => result)
const unpatch = after(obj, 'method', afterCallback)
unpatch()
expect(() => unpatch()).not.toThrow()
expect(() => unpatch()).not.toThrow()
})
})
describe('instead', () => {
test('should replace original method', () => {
const replacement = mock(() => 'replaced')
instead(obj, 'method', replacement)
const result = obj.method('arg1')
expect(replacement).toHaveBeenCalledWith(
['arg1'],
expect.any(Function),
)
expect(originalMethod).not.toHaveBeenCalled()
expect(result).toBe('replaced')
})
test('should provide access to original method', () => {
instead(obj, 'method', (_args, original) => {
return `${original()} modified`
})
const result = obj.method()
expect(result).toBe('original modified')
})
test('should unpatch correctly', () => {
const replacement = mock(() => 'replaced')
const unpatch = instead(obj, 'method', replacement)
// Test that patch is working
let result = obj.method()
expect(result).toBe('replaced')
expect(originalMethod).not.toHaveBeenCalled()
// Unpatch
unpatch()
// Test that patch is removed
result = obj.method()
expect(result).toBe('original')
expect(originalMethod).toHaveBeenCalled()
expect(replacement).toHaveBeenCalledTimes(1) // Only called before unpatch
})
test('should handle multiple instead hooks and unpatch correctly', () => {
const firstHook = mock((_args, original) => `first-${original()}`)
const secondHook = mock((_args, original) => `second-${original()}`)
const unpatch1 = instead(obj, 'method', firstHook)
const unpatch2 = instead(obj, 'method', secondHook)
let result = obj.method()
expect(result).toBe('second-first-original')
// Unpatch second hook (most recent)
unpatch2()
result = obj.method()
expect(result).toBe('first-original')
// Unpatch first hook
unpatch1()
result = obj.method()
expect(result).toBe('original')
})
test('should not crash when unpatch is called multiple times', () => {
const replacement = mock(() => 'replaced')
const unpatch = instead(obj, 'method', replacement)
unpatch()
expect(() => unpatch()).not.toThrow()
expect(() => unpatch()).not.toThrow()
})
})
describe('mixed hooks', () => {
test('should handle before + after', () => {
const beforeHook = mock(args => {
args[0] = 'modified'
return args
})
const afterHook = mock(result => `${result}-after`)
const unpatchBefore = before(obj, 'method', beforeHook)
const unpatchAfter = after(obj, 'method', afterHook)
let result = obj.method('original')
expect(result).toBe('modified-after')
expect(beforeHook).toHaveBeenCalledWith(['modified'])
expect(afterHook).toHaveBeenCalledWith('modified')
// Unpatch after hook
unpatchAfter()
result = obj.method('original')
expect(result).toBe('modified')
// Unpatch before hook
unpatchBefore()
result = obj.method('original')
expect(result).toBe('original')
})
test('should handle instead + before', () => {
const insteadHook = mock(
(args, original) => `${original(...args)}-instead`,
)
const beforeHook = mock(args => {
args[0] = 'modified'
return args
})
const unpatchInstead = instead(obj, 'method', insteadHook)
const unpatchBefore = before(obj, 'method', beforeHook)
let result = obj.method('original')
expect(result).toBe('modified-instead')
expect(insteadHook).toHaveBeenCalledWith(
['modified'],
expect.any(Function),
)
expect(beforeHook).toHaveBeenCalledWith(['modified'])
// Unpatch before hook
unpatchBefore()
result = obj.method('original')
expect(result).toBe('original-instead')
// Unpatch instead hook
unpatchInstead()
result = obj.method('original')
expect(result).toBe('original')
})
test('should handle instead + after', () => {
const insteadHook = mock(() => 'oops')
const afterHook = mock(result => `${result}-after`)
const unpatchInstead = instead(obj, 'method', insteadHook)
const unpatchAfter = after(obj, 'method', afterHook)
let result = obj.method('original')
expect(result).toBe('oops-after')
expect(insteadHook).toHaveBeenCalledWith(
['original'],
expect.any(Function),
)
expect(afterHook).toHaveBeenCalledWith('oops')
// Unpatch after hook
unpatchAfter()
result = obj.method('original')
expect(result).toBe('oops')
// Unpatch instead hook
unpatchInstead()
result = obj.method('original')
expect(result).toBe('original')
})
test('should handle instead + before + after', () => {
const insteadHook = mock(
(args, original) => `${original(...args)}-instead`,
)
const beforeHook = mock(args => {
args[0] = 'modified'
return args
})
const afterHook = mock(result => `${result}-after`)
const unpatchInstead = instead(obj, 'method', insteadHook)
const unpatchBefore = before(obj, 'method', beforeHook)
const unpatchAfter = after(obj, 'method', afterHook)
let result = obj.method('original')
expect(result).toBe('modified-instead-after')
expect(insteadHook).toHaveBeenCalledWith(
['modified'],
expect.any(Function),
)
expect(beforeHook).toHaveBeenCalledWith(['modified'])
expect(afterHook).toHaveBeenCalledWith('modified-instead')
// Unpatch after hook
unpatchAfter()
result = obj.method('original')
expect(result).toBe('modified-instead')
// Unpatch before hook
unpatchBefore()
result = obj.method('original')
expect(result).toBe('original-instead')
// Unpatch instead hook
unpatchInstead()
result = obj.method('original')
expect(result).toBe('original')
})
test('should handle before + after + instead', () => {
const beforeHook = mock(args => {
args[0] = 'before-modified'
return args
})
const afterHook = mock(result => `${result}-after`)
const insteadHook = mock(
(args, original) => `${original(...args)}-instead`,
)
const unpatchBefore = before(obj, 'method', beforeHook)
const unpatchAfter = after(obj, 'method', afterHook)
const unpatchInstead = instead(obj, 'method', insteadHook)
let result = obj.method('original')
expect(result).toBe('before-modified-instead-after')
expect(beforeHook).toHaveBeenCalledWith(['before-modified'])
expect(afterHook).toHaveBeenCalledWith('before-modified-instead')
expect(insteadHook).toHaveBeenCalledWith(
['before-modified'],
expect.any(Function),
)
// Unpatch after hook
unpatchAfter()
result = obj.method('original')
expect(result).toBe('before-modified-instead')
// Unpatch before hook
unpatchBefore()
result = obj.method('original')
expect(result).toBe('original-instead')
// Unpatch instead hook
unpatchInstead()
result = obj.method('original')
expect(result).toBe('original')
})
test('should restore original method when all hooks are unpatched', () => {
const originalRef = obj.method
const unpatch1 = before(obj, 'method', args => args)
const unpatch2 = after(obj, 'method', result => result)
// Method should be patched
expect(obj.method).not.toBe(originalRef)
unpatch1()
// Still patched because after hook remains
expect(obj.method).not.toBe(originalRef)
unpatch2()
// Should be restored to original
expect(obj.method).toBe(originalRef)
})
})
// describe('error handling', () => {
// test('should throw error for invalid parent', () => {
// expect(() => before(null as any, 'method', () => [])).toThrow()
// expect(() => before(undefined as any, 'method', () => [])).toThrow()
// expect(() => before('string' as any, 'method', () => [])).toThrow()
// })
// test('should throw error for non-existent property', () => {
// expect(() => before(obj, 'nonExistent' as any, () => [])).toThrow(
// "Property 'nonExistent' does not exist on parent",
// )
// })
// test('should throw error for non-function property', () => {
// const objWithNonFunction = { prop: 'not a function' }
// expect(() =>
// before(objWithNonFunction, 'prop' as any, () => []),
// ).toThrow("Property 'prop' is not a function")
// })
// test('should throw error for invalid hook', () => {
// expect(() => before(obj, 'method', null as any)).toThrow()
// expect(() => before(obj, 'method', 'string' as any)).toThrow()
// })
// })
describe('constructor patching', () => {
test('should patch constructor functions', () => {
class TestClass {
value: string
constructor(value: string) {
this.value = value
}
}
const obj = { TestClass }
const beforeHook = mock(args => {
args[0] = 'modified'
return args
})
before(obj, 'TestClass', beforeHook)
const instance = new obj.TestClass('original')
expect(instance.value).toBe('modified')
expect(beforeHook).toHaveBeenCalledWith(['modified'])
})
})
})

29
lib/patcher/tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"include": ["src"]
}

View file

@ -7,7 +7,7 @@
"@parcel/watcher": "^2.5.1",
"@react-native-clipboard/clipboard": "^1.16.2",
"@swc/core": "^1.11.31",
"@types/bun": "latest",
"@types/bun": "^1.2.15",
"@types/node": "^22.15.30",
"@unbound-mod/hermesc": "^1.0.3",
"chalk": "^5.4.1",