feat(lib): proper caching for assets and modules lib

This commit is contained in:
PalmDevs 2025-08-19 22:05:24 +07:00
parent 67345aa82e
commit dba76a9f81
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
9 changed files with 112 additions and 115 deletions

View file

@ -1,51 +1,50 @@
// TODO(lib/assets): do not depend on Discord's ClientInfoModule for versioning, use native interop
import { ClientInfoModule } from '@revenge-mod/discord/native'
import { getStorage } from '@revenge-mod/storage'
import {
callBridgeMethod,
callBridgeMethodSync,
} from '@revenge-mod/modules/native'
import { debounce } from '@revenge-mod/utils/callback'
import { mergeDeep } from '@revenge-mod/utils/object'
import type { Metro } from '@revenge-mod/modules/types'
import type { Asset } from './types'
const Version = 1
const Key = `${Version}.${ClientInfoModule.getConstants().Build}`
const ExpectedCacheVersion = 2
export const Uncached: Cache = {
data: {},
version: ExpectedCacheVersion,
}
// In-memory cache
export const cache: Cache = {}
export let cache: Cache =
callBridgeMethodSync('revenge.caches.assets.read', []) ?? Uncached
const CacheStorage = getStorage<Cache>(`revenge/assets.${Key}`, {
default: cache,
directory: 'cache',
})
// TODO(lib/assets/caches): This loads way too late and requires native interop to load earlier
export const cached = CacheStorage.get().then(cache_ => {
const cached = cache_ !== cache
// If we are not using the default value, merge it into the in-memory cache, then point the storage cache to the in-memory cache.
if (cached) {
Object.assign(cache, cache_)
CacheStorage.cache = cache
}
return cached
})
if (cache.version !== ExpectedCacheVersion) {
// TODO: Alert to user to update build
cache = Uncached
}
export interface Cache {
[key: Asset['name']]: {
[key: Asset['type']]: Metro.ModuleID
data: {
[key: Asset['name']]: {
[key: Asset['type']]: Metro.ModuleID
}
}
version: number
}
const save = debounce(() => {
CacheStorage.set({})
callBridgeMethod('revenge.caches.assets.write', [cache.data])
}, 1000)
export function cacheAsset(asset: Asset, moduleId: Metro.ModuleID) {
// Merge directly into the cache, only debouncing actual writes
mergeDeep(cache, {
[asset.name]: {
[asset.type]: moduleId,
},
})
const reg = (cache.data[asset.name] ??= {})
reg[asset.type] = moduleId
save()
}
declare module '@revenge-mod/modules/native' {
interface Methods {
'revenge.caches.assets.read': [[], Cache | null]
'revenge.caches.assets.write': [[data: Cache['data']], void]
}
}

View file

@ -48,7 +48,7 @@ export function* getCustomAssets(): Generator<CustomAsset> {
* Yields all registered packager assets, including ones with same name but different types.
*/
export function* getPackagerAssets(): Generator<PackagerAsset> {
for (const reg of Object.values(cache))
for (const reg of Object.values(cache.data))
for (const moduleId of Object.values(reg))
yield AssetsRegistry.getAssetByID(metroRequire(moduleId))
}
@ -77,7 +77,7 @@ export function getAssetByName(
export function getAssetsByName(
name: string,
): Record<Asset['type'], Asset> | undefined {
const reg = cache[name]
const reg = cache.data[name]
if (!reg) return
return Object.entries(reg).reduce(
@ -106,7 +106,7 @@ export function getAssetIdByName(
name: string,
type?: Asset['type'],
): AssetId | undefined {
const reg = cache[name]
const reg = cache.data[name]
if (!reg) return
if (type !== undefined) {
@ -127,7 +127,7 @@ export function getAssetIdByName(
* @returns The asset ID.
*/
export function registerAsset(asset: RegisterableAsset): AssetId {
if (cache[asset.name]?.[asset.type] !== undefined)
if (cache.data[asset.name]?.[asset.type] !== undefined)
throw new Error(
`Asset with name ${asset.name} and type ${asset.type} already exists!`,
)

View file

@ -15,7 +15,7 @@ import {
import { getModuleDependencies } from '@revenge-mod/modules/metro/utils'
import { proxify } from '@revenge-mod/utils/proxy'
import { aOverrides } from './_internal'
import { cacheAsset, cached } from './caches'
import { cache, cacheAsset, Uncached } from './caches'
import type { Metro } from '@revenge-mod/modules/types'
import type { ReactNative } from '@revenge-mod/react/types'
import type { Asset, PackagerAsset } from './types'
@ -52,24 +52,20 @@ const unsubAR = waitForModules(
if (getModuleDependencies(id)!.length) {
unsubAR()
// TODO(lib/assets/caches): Ideally we should not wait for the cache promise here, see the promise impl for more info.
cached.then(cached => {
if (!cached) {
// Resolve reference once and keep in closure
const metroRequire = __r
if (cache === Uncached) {
// Resolve reference once and keep in closure
const metroRequire = __r
// More fragile way, but also more performant:
// There is exactly one asset before the reexported asset registry :/
const firstAssetModuleId = id - 1
for (const mId of mUninitialized) {
if (mId < firstAssetModuleId) continue
// More fragile way, but also more performant:
// There is exactly one asset before the reexported asset registry :/
const firstAssetModuleId = id - 1
for (const mId of mUninitialized) {
if (mId < firstAssetModuleId) continue
const deps = getModuleDependencies(mId)!
if (deps.length === 1 && deps[0] === id)
metroRequire(mId)
}
const deps = getModuleDependencies(mId)!
if (deps.length === 1 && deps[0] === id) metroRequire(mId)
}
})
}
// We already patched the original asset registry, so we don't need to patch again.
return

View file

@ -9,7 +9,7 @@ import {
} from '@revenge-mod/modules/finders/filters'
import { getModuleDependencies } from '@revenge-mod/modules/metro/utils'
import { asap, noop } from '@revenge-mod/utils/callback'
import { cached, cacheFilterResultForId } from '#modules/src/caches'
import { cache, cacheFilterResultForId, Uncached } from '#modules/src/caches'
import { FilterResultFlags } from '#modules/src/finders/_internal'
import { FilterFlag } from '#modules/src/finders/filters/constants'
import type {
@ -140,13 +140,11 @@ waitForModules(withStore(), (store, id) => {
Stores[name] = store
})
cached.then(cached => {
if (!cached)
asap(() => {
const lookup = lookupModules(withStore(), {
uninitialized: true,
})
while (lookup.next().done);
if (cache === Uncached)
asap(() => {
const lookup = lookupModules(withStore(), {
uninitialized: true,
})
})
while (lookup.next().done);
})

View file

@ -21,7 +21,6 @@
"./native": {
"default": "./src/native/index.ts"
},
},
"./types": {
"types": "./src/types.ts"
}

View file

@ -1,59 +1,61 @@
// TODO(lib/modules/caches): do not depend on Discord's ClientInfoModule for versioning, use native interop
import { ClientInfoModule } from '@revenge-mod/discord/native'
import { getStorage } from '@revenge-mod/storage'
import {
callBridgeMethod,
callBridgeMethodSync,
} from '@revenge-mod/modules/native'
import { debounce } from '@revenge-mod/utils/callback'
import type { Metro } from '@revenge-mod/modules/types'
import type { FilterResultFlag } from './finders/_internal'
import type { Filter } from './finders/filters'
const Version = 2
const Key = `${Version}.${ClientInfoModule.getConstants().Build}`
const ExpectedCacheVersion = 3
export const Uncached: Cache = {
blacklist: [],
finds: {},
version: ExpectedCacheVersion,
}
// In-memory cache
const cache: Cache = { b: [], f: {} }
export let cache: Cache =
callBridgeMethodSync('revenge.caches.modules.read', []) ?? Uncached
const CacheStorage = getStorage<Cache>(`revenge/modules.${Key}`, {
default: cache,
directory: 'cache',
})
if (cache.version !== ExpectedCacheVersion) {
// TODO: Alert to user to update build
cache = Uncached
}
// TODO(lib/modules/caches): This loads way too late and requires native interop to load earlier
export const cached = CacheStorage.get().then(cache_ => {
const cached = cache_ !== cache
// If we are not using the default value, merge it into the in-memory cache, then point the storage cache to the in-memory cache.
if (cached) {
Object.assign(cache, cache_)
CacheStorage.cache = cache
}
return cached
})
export type Blacklist = Metro.ModuleID[]
export type Finds = Record<
Filter['key'],
Record<Metro.ModuleID, FilterResultFlag> | null
>
export interface Cache {
b: Metro.ModuleID[]
f: Record<
Filter['key'],
Record<Metro.ModuleID, FilterResultFlag> | null | undefined
>
blacklist: Blacklist
finds: Finds
version: number
}
const save = debounce(() => {
CacheStorage.set({})
callBridgeMethod('revenge.caches.modules.write', [
cache.blacklist,
cache.finds,
])
}, 1000)
export function cacheBlacklistedModule(id: Metro.ModuleID): boolean {
cache.b.push(id)
cache.blacklist.push(id)
save()
return true
}
export function cacheFilterResultForId(
key: Filter['key'],
key: keyof Finds,
id: Metro.ModuleID,
flag: FilterResultFlag,
): FilterResultFlag {
const reg = (cache.f[key] ??= {})
const reg = (cache.finds[key] ??= {})
reg[id] = flag
save()
@ -61,12 +63,22 @@ export function cacheFilterResultForId(
return flag
}
export function cacheFilterNotFound(key: Filter['key']) {
cache.f[key] = null
export function cacheFilterNotFound(key: keyof Finds) {
cache.finds[key] = null
}
export const getCachedFilterRegistry = (
key: Filter['key'],
): Cache['f'][Filter['key']] | undefined => cache.f[key]
export const getFilterMatches = (
key: keyof Finds,
): Finds[keyof Finds] | undefined => cache.finds[key]
export const getCachedBlacklistedModules = () => cache.b
export const getBlacklistedModules = () => cache.blacklist
declare module '@revenge-mod/modules/native' {
interface Methods {
'revenge.caches.modules.read': [[], Cache | null]
'revenge.caches.modules.write': [
[blacklist: Blacklist, finds: Finds],
void,
]
}
}

View file

@ -1,6 +1,6 @@
import { getCurrentStack } from '@revenge-mod/utils/error'
import { proxify } from '@revenge-mod/utils/proxy'
import { cacheFilterNotFound, getCachedFilterRegistry } from '../caches'
import { cacheFilterNotFound, getFilterMatches } from '../caches'
import { metroRequire } from '../metro/custom'
import {
mImportedPaths,
@ -163,7 +163,7 @@ export function* lookupModules(filter: Filter, options?: LookupModulesOptions) {
if (options?.cached ?? true) {
const notInit = !(options?.initialize ?? true)
const reg = getCachedFilterRegistry(filter.key)
const reg = getFilterMatches(filter.key)
// Return early if previous lookup was a full lookup and no modules were found
if (reg === null) return
@ -307,7 +307,7 @@ export function lookupModule(filter: Filter, options?: LookupModulesOptions) {
if (options?.cached ?? true) {
const notInit = !(options?.initialize ?? true)
const reg = getCachedFilterRegistry(filter.key)
const reg = getFilterMatches(filter.key)
// Return early if previous lookup was a full lookup and no modules were found
if (reg === null) return NotFoundResult

View file

@ -1,5 +1,5 @@
import { noop } from '@revenge-mod/utils/callback'
import { getCachedFilterRegistry } from '../caches'
import { getFilterMatches } from '../caches'
import { mInitialized } from '../metro/patches'
import {
onAnyModuleInitialized,
@ -97,13 +97,13 @@ export function waitForModules(
options?: WaitForModulesOptions,
): WaitForModulesUnsubscribeFunction {
if (options?.cached) {
const reg = getCachedFilterRegistry(filter.key)
const reg = getFilterMatches(filter.key)
if (reg === null) return noop
if (reg) {
if (__BUILD_FLAG_DEBUG_MODULE_WAITS__)
nativeLoggingHook(
`\u001b[32mUsing cached results for wait \u001b[33m${filter.key}\u001b[0m`,
`\u001b[32mUsing cached results for wait: \u001b[33m${filter.key}\u001b[0m`,
1,
)

View file

@ -1,8 +1,4 @@
import {
cacheBlacklistedModule,
cached,
getCachedBlacklistedModules,
} from '../caches'
import { cache, cacheBlacklistedModule, Uncached } from '../caches'
import {
global,
metroImportAll,
@ -144,11 +140,8 @@ function handleFactoryCall(
/// MODULE PATCHES AND BLACKLISTS
// Restore blacklists
cached.then(cached => {
if (cached)
for (const id of getCachedBlacklistedModules())
mUninitialized.delete(id)
})
if (cache !== Uncached)
for (const id of cache.blacklist) mUninitialized.delete(id)
const ImportTrackerModuleId = 2