feat(lib): add initial impl of plugins

This commit is contained in:
PalmDevs 2025-04-29 21:38:14 +07:00
parent 1dab1ead79
commit 020045f2eb
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
6 changed files with 348 additions and 0 deletions

16
lib/plugins/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "@revenge-mod/plugins",
"private": true,
"type": "module",
"exports": {
".": {
"default": "./src/index.ts"
},
"./init": {
"default": "./src/init.ts"
},
"./_": {
"default": "./src/_internal.ts"
}
}
}

View file

@ -0,0 +1,137 @@
import * as DiscordLibrary from '@revenge-mod/discord'
import * as ModulesLibrary from '@revenge-mod/modules'
import * as PatcherLibrary from '@revenge-mod/patcher'
import * as PluginsLibrary from '@revenge-mod/plugins'
import * as ReactLibrary from '@revenge-mod/react'
const { PluginFlags } = PluginsLibrary
export const _uapi: PluginsLibrary.UnscopedInitPluginApi | PluginsLibrary.UnscopedPluginApi = {
discord: DiscordLibrary,
modules: ModulesLibrary,
plugins: PluginsLibrary,
react: ReactLibrary,
patcher: PatcherLibrary,
// utils: UtilsLibrary,
}
const _plugins = new Map<PluginsLibrary.PluginManifest['id'], InternalPlugin>()
export function registerPlugin(
manifest: PluginsLibrary.PluginManifest,
lifecycles: PluginsLibrary.PluginLifecycles,
flags: PluginsLibrary.PluginFlags = 0,
) {
// TODO(plugins): verify plugin manifest
if (_plugins.has(manifest.id)) throw new Error(`Plugin with ID "${manifest.id}" already registered`)
const handleError = (e: unknown) => {
plugin.errors.push(e)
plugin.flags |= PluginFlags.Errored
return plugin.disable()
}
const plugin: InternalPlugin = {
cleanups: [],
errors: [],
manifest,
lifecycles,
flags,
prepareStart: () => {
// const sapi = api as PluginsLibrary.PluginApi
// sapi.settings ??= ...
},
init: async () => {
plugin.flags |= PluginFlags.StatusInit
plugin.flags |= PluginFlags.StatusActioning
try {
await lifecycles.init?.(api)
} catch (e) {
handleError(e)
}
// Not in finally block because plugin.disable() already handles
plugin.flags &= ~PluginFlags.StatusInit
plugin.flags &= ~PluginFlags.StatusActioning
},
start: async () => {
plugin.flags |= PluginFlags.StatusStart
plugin.flags |= PluginFlags.StatusActioning
plugin.prepareStart()
try {
await lifecycles.start?.(api as PluginsLibrary.PluginApi)
} catch (e) {
handleError(e)
}
// Not in finally block because plugin.disable() already handles
plugin.flags &= ~PluginFlags.StatusStart
plugin.flags &= ~PluginFlags.StatusActioning
},
stop: async () => {
plugin.flags &= ~PluginFlags.StatusInit
plugin.flags &= ~PluginFlags.StatusStart
plugin.flags |= PluginFlags.StatusActioning
// In case the plugin only has init() and stop()
plugin.prepareStart()
try {
await lifecycles.stop?.(api as PluginsLibrary.PluginApi)
} catch (e) {
handleError(e)
} finally {
// Run cleanups
const results = await Promise.allSettled(plugin.cleanups.map(cleanup => cleanup()))
for (const result of results) if (result.status === 'rejected') plugin.errors.push(result.reason)
plugin.flags &= ~PluginFlags.StatusActioning
}
},
enable: async () => {
plugin.flags |= PluginFlags.Enabled
if (lifecycles.init) plugin.flags |= PluginFlags.ReloadRequired
},
disable: async () => {
plugin.flags &= ~PluginFlags.Enabled
// If plugin is initialized/started, we need to stop it
if (plugin.flags & (PluginFlags.StatusInit | PluginFlags.StatusStart)) await plugin.stop()
},
}
const api: PluginsLibrary.InitPluginApi | PluginsLibrary.PluginApi = {
cleanup: (...items) => {
plugin.cleanups.push(...items)
},
logger: new DiscordLibrary.Logger(`Revenge > ${plugin.manifest.name}`),
plugin,
unscoped: _uapi,
}!
_plugins.set(manifest.id, plugin)
}
export function initPlugins() {
const promises: Promise<void>[] = []
for (const plugin of _plugins.values()) if (plugin.flags & PluginFlags.Enabled) promises.push(plugin.init())
// These Promises never throw, so it is fine
return Promise.all(promises)
}
export function startPlugins() {
const promises: Promise<void>[] = []
for (const plugin of _plugins.values()) if (plugin.flags & PluginFlags.Enabled) promises.push(plugin.start())
// These Promises never throw, so it is fine
return Promise.all(promises)
}
export interface InternalPlugin extends PluginsLibrary.Plugin {
cleanups: PluginsLibrary.PluginCleanup[]
prepareStart(): void
init(): Promise<void>
start(): Promise<void>
stop(): Promise<void>
}

View file

@ -0,0 +1,32 @@
export const PluginFlags = {
/**
* The plugin is enabled.
*/
Enabled: 1 << 0,
/**
* The plugin is an internal plugin.
*/
Internal: 1 << 1,
/**
* The plugin requires a reload to apply changes.
*/
ReloadRequired: 1 << 2,
/**
* The plugin is initialized or initializing.
*/
StatusInit: 1 << 3,
/**
* The plugin is started or starting.
*/
StatusStart: 1 << 4,
/**
* The plugin is actioning something, such as initializing or starting.
*/
StatusActioning: 1 << 5,
/**
* The plugin has errors.
*/
Errored: 1 << 6,
}
export type PluginFlags = (typeof PluginFlags)[keyof typeof PluginFlags]

2
lib/plugins/src/index.ts Normal file
View file

@ -0,0 +1,2 @@
export * from './constants'
export * from './types'

23
lib/plugins/src/init.ts Normal file
View file

@ -0,0 +1,23 @@
import * as AssetsLibrary from '@revenge-mod/assets'
// import * as UiLibrary from '@revenge-mod/ui'
import { after } from '@revenge-mod/patcher'
import { ReactNative } from '@revenge-mod/react'
import { _uapi, initPlugins, startPlugins } from './_internal'
import type { UnscopedPluginApi } from './types'
// Setup non-limited APIs
const uapi = _uapi as UnscopedPluginApi
uapi.assets = AssetsLibrary
// uapi.ui = UiLibrary
initPlugins()
// TODO(plugins/init): move to @revenge-mod/app onAppInitialized
const unpatch = after(ReactNative.AppRegistry, 'runApplication', ret => {
unpatch()
setImmediate(startPlugins)
return ret
})

138
lib/plugins/src/types.ts Normal file
View file

@ -0,0 +1,138 @@
import type { DiscordModules } from '@revenge-mod/discord/types'
import type { PluginFlags } from './constants'
/**
* The unscoped plugin API (limited). This API is available as a global for plugins.
* Available in the `init` phase.
*/
export interface UnscopedInitPluginApi {
discord: typeof import('@revenge-mod/discord')
plugins: typeof import('@revenge-mod/plugins')
modules: typeof import('@revenge-mod/modules')
patcher: typeof import('@revenge-mod/patcher')
react: typeof import('@revenge-mod/react')
}
/**
* The unscoped plugin API. This API is available as a global for plugins.
* Available in the `start` and `stop` phase.
*/
export interface UnscopedPluginApi extends UnscopedInitPluginApi {
assets: typeof import('@revenge-mod/assets')
// ui: typeof import('@revenge-mod/ui')
// utils: typeof import('@revenge-mod/utils')
}
export type PluginCleanup = () => any
export type PluginCleanupApi = (...fns: PluginCleanup[]) => void
/**
* The plugin API (limited).
* Available in the `init` phase.
*/
export interface InitPluginApi {
plugin: Plugin
logger: InstanceType<DiscordModules.Logger>
unscoped: UnscopedInitPluginApi
cleanup: PluginCleanupApi
}
/**
* The plugin API.
* Available in the `start` and `stop` phase.
*/
export interface PluginApi extends InitPluginApi {
// settings: typeof import('@revenge-mod/settings')
unscoped: UnscopedPluginApi
}
// TODO(plugins): support plugin bundles
// export interface PluginBundle {
// /**
// * The unique identifier for the plugin bundle.
// */
// id: string
// /**
// * The author of the plugin bundle.
// */
// author: string
// }
export interface PluginManifest {
/**
* The unique identifier for the plugin.
*/
id: string
/**
* The name of the plugin.
*/
name: string
/**
* The description of the plugin.
*/
description: string
/**
* The icon of the plugin.
*/
icon?: string
}
export interface PluginLifecycles {
/**
* Runs immediately as soon as possible with limited APIs.
*
* @param api Plugin API (limited).
* @param context Plugin context.
*/
init?: (api: InitPluginApi) => any
/**
* Runs when the plugin can be started with all APIs available.
*
* @param api Plugin API.
* @param context Plugin context.
*/
start?: (api: PluginApi) => any
/**
* Runs when the plugin is stopped.
*
* @param api Plugin API.
* @param context Plugin context.
*/
stop?: (api: PluginApi) => any
}
export interface Plugin {
// TODO(plugins): support plugin bundles
// /**
// * The plugin bundle this plugin belongs to.
// */
// bundle: PluginBundle
/**
* The plugin manifest.
*/
manifest: PluginManifest
/**
* The plugin lifecycles.
*/
lifecycles: PluginLifecycles
/**
* The plugin flags.
*/
flags: PluginFlags
/**
* Errors encountered during the plugin lifecycles.
*/
errors: unknown[]
/**
* Disable the plugin. This will also stop the plugin if it is running.
*/
disable(): Promise<void>
/**
* Enable the plugin.
* @internal
*/
enable(): Promise<void>
}