Skip to content

Commit

Permalink
feat(cli): file level hot plugin replacement!!!
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Apr 14, 2021
1 parent 4ee40b7 commit 753c560
Show file tree
Hide file tree
Showing 4 changed files with 47 additions and 48 deletions.
3 changes: 2 additions & 1 deletion packages/koishi-core/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,10 @@ export class App extends Context {
this.options = merge(options, App.defaultConfig)
this.registry.set(null, {
parent: null,
context: null,
config: null,
children: [],
disposables: [],
dependencies: new Set(),
})

defineProperty(this, '_userCache', {})
Expand Down
6 changes: 4 additions & 2 deletions packages/koishi-core/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ export namespace Plugin {

export interface State extends Meta {
parent: State
context: Context
config: Config<Plugin>
children: Plugin[]
disposables: Disposable[]
dependencies: Set<State>
}

export interface Packages {}
Expand Down Expand Up @@ -201,10 +202,11 @@ export class Context {
const ctx: this = Object.create(this)
defineProperty(ctx, '_plugin', plugin)
this.app.registry.set(plugin, {
context: this,
config: options,
parent: this.state,
children: [],
disposables: [],
dependencies: new Set([this.state]),
})

if (typeof plugin === 'function') {
Expand Down
72 changes: 40 additions & 32 deletions packages/koishi/src/worker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { App, BotOptions, version } from 'koishi-core'
import { App, BotOptions, Plugin, version } from 'koishi-core'
import { resolve, dirname, relative } from 'path'
import { coerce, Logger, noop, LogLevelConfig } from 'koishi-utils'
import { performance } from 'perf_hooks'
Expand All @@ -7,6 +7,7 @@ import { yellow } from 'kleur'
import { AppConfig } from '..'

const logger = new Logger('app')
const cwd = process.cwd()

function handleException(error: any) {
logger.error(error)
Expand All @@ -15,7 +16,7 @@ function handleException(error: any) {

process.on('uncaughtException', handleException)

const configFile = resolve(process.cwd(), process.env.KOISHI_CONFIG_FILE || 'koishi.config')
const configFile = resolve(cwd, process.env.KOISHI_CONFIG_FILE || 'koishi.config')
const configDir = dirname(configFile)

function isErrorModule(error: any) {
Expand Down Expand Up @@ -91,7 +92,6 @@ if (typeof config.logLevel === 'object') {

if (config.logTime === true) config.logTime = 'yyyy/MM/dd hh:mm:ss'
if (config.logTime) Logger.showTime = config.logTime
if (config.logDiff !== undefined) Logger.showDiff = config.logDiff

// cli options have higher precedence
if (process.env.KOISHI_LOG_LEVEL) {
Expand Down Expand Up @@ -173,7 +173,7 @@ app.start().then(() => {
const time = Math.max(0, performance.now() - +process.env.KOISHI_START_TIME).toFixed()
logger.success(`bot started successfully in ${time} ms`)
Logger.timestamp = Date.now()
Logger.showDiff = true
Logger.showDiff = config.logDiff ?? !Logger.showTime

process.send({ type: 'start' })
createWatcher()
Expand All @@ -198,48 +198,59 @@ function createWatcher() {
if (process.env.KOISHI_WATCH_ROOT === undefined && !config.watch) return

const { root = '', ignored = [], fullReload } = config.watch || {}
const watchRoot = resolve(process.cwd(), process.env.KOISHI_WATCH_ROOT ?? root)
const watchRoot = resolve(cwd, process.env.KOISHI_WATCH_ROOT ?? root)
const externals = loadDependencies(__filename, pluginMap)
const watcher = watch(watchRoot, {
...config.watch,
ignored: ['**/node_modules/**', '**/.git/**', ...ignored],
})

const logger = new Logger('watcher')
const logger = new Logger('app:watcher')
function triggerFullReload() {
if (!fullReload) return
logger.info('trigger full reload')
process.exit(114)
}

watcher.on('change', async (path) => {
if (!require.cache[path]) return
logger.debug('change detected:', relative(watchRoot, path))
logger.debug('change detected:', path)

if (externals.has(path)) {
if (!fullReload) return
logger.info('trigger full reload')
process.exit(114)
}
if (externals.has(path)) return triggerFullReload()

const tasks: Promise<void>[] = []
const reloads: [plugin: Plugin, state: Plugin.State, name: string][] = []

/** files that should be reloaded */
const accepted = new Set<string>()
/** files that should not be reloaded */
const declined = new Set([...externals, ...loadDependencies(path, externals)])
declined.delete(path)

const plugins: string[] = []
const tasks: Promise<void>[] = []
for (const [filename, [name]] of pluginMap) {
for (const filename in require.cache) {
// we only detect reloads at plugin level
const module = require.cache[filename]
const state = app.registry.get(module.exports)
if (!state) continue

// check if it is a dependent of the changed file
const dependencies = loadDependencies(filename, declined)
if (dependencies.has(path)) {
dependencies.forEach(dep => accepted.add(dep))
const plugin = require(filename)
const state = app.registry.get(plugin)
if (state?.sideEffect) continue

// dispose installed plugin
plugins.push(filename)
const displayName = plugin.name || name
tasks.push(app.dispose(plugin).catch((err) => {
logger.warn('failed to dispose plugin %c\n' + coerce(err), displayName)
}))
if (!dependencies.has(path)) continue

// accept dependencies to be reloaded
dependencies.forEach(dep => accepted.add(dep))
const plugin = require(filename)
if (state?.sideEffect) {
triggerFullReload()
continue
}

// dispose installed plugin
const displayName = plugin.name || relative(watchRoot, filename)
reloads.push([module.exports, state, displayName])
tasks.push(app.dispose(plugin).catch((err) => {
logger.warn('failed to dispose plugin %c\n' + coerce(err), displayName)
}))
}

await Promise.all(tasks)
Expand All @@ -249,12 +260,9 @@ function createWatcher() {
delete require.cache[path]
})

for (const filename of plugins) {
const plugin = require(filename)
const [name, options] = pluginMap.get(filename)
const displayName = plugin.name || name
for (const [plugin, state, displayName] of reloads) {
try {
app.plugin(plugin, options)
state.context.plugin(plugin, state.config)
logger.info('reload plugin %c', displayName)
} catch (err) {
logger.warn('failed to reload plugin %c\n' + coerce(err), displayName)
Expand Down
14 changes: 1 addition & 13 deletions packages/plugin-webui/src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,24 +201,13 @@ export class Registry implements DataSource<Registry.Payload> {
return this.payload
}

* getDeps(state: Plugin.State): Generator<string> {
for (const dep of state.dependencies) {
if (dep.name) {
yield dep.name
} else {
yield* this.getDeps(dep)
}
}
}

traverse = (plugin: Plugin): Registry.PluginData[] => {
const state = this.ctx.app.registry.get(plugin)
const children = state.children.flatMap(this.traverse, 1)
const { name, sideEffect } = state
if (!name) return children
this.payload.pluginCount += 1
const dependencies = [...new Set(this.getDeps(state))]
return [{ name, sideEffect, children, dependencies }]
return [{ name, sideEffect, children }]
}
}

Expand All @@ -228,7 +217,6 @@ export namespace Registry {

export interface PluginData extends Plugin.Meta {
children: PluginData[]
dependencies: string[]
}

export interface Payload {
Expand Down

0 comments on commit 753c560

Please sign in to comment.