Skip to content

Commit

Permalink
feat(cli): support reusable plugin reload
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed May 26, 2022
1 parent a0a37e5 commit 8f62369
Show file tree
Hide file tree
Showing 12 changed files with 77 additions and 57 deletions.
3 changes: 2 additions & 1 deletion build/dtsc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ async function bundleNodes(nodes: Node[]) {
for (const node of nodes) {
await fs.mkdir(resolve(cwd, node.path, 'lib'), { recursive: true })
console.log('building', node.path)
await spawnAsync(['yarn', 'dtsc'], {
const code = await spawnAsync(['yarn', 'dtsc'], {
cwd: resolve(cwd, node.path),
})
if (code) process.exit(code)
}
}

Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,13 @@
"typescript": "^4.7.2",
"yakumo": "^0.2.4",
"yakumo-mocha": "^0.2.4",
"yakumo-publish": "^0.2.2",
"yakumo-upgrade": "^0.2.2",
"yakumo-version": "^0.2.3"
"yakumo-publish": "^0.2.3",
"yakumo-upgrade": "^0.2.3",
"yakumo-version": "^0.2.4"
},
"yakumo": {
"require": [
"esbuild-register"
]
}
}
}
64 changes: 38 additions & 26 deletions packages/cli/src/worker/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,49 +203,66 @@ class Watcher {
this.analyzeChanges()

/** plugins pending classification */
const pending = new Map<string, Plugin.State>()
const pending = new Map<string, Plugin.Runtime>()

/** plugins that should be reloaded */
const reloads = new Map<Plugin.State, string>()
const reloads = new Map<Plugin.Runtime, string>()

// we assume that plugin entry files are "atomic"
// that is, reloading them will not cause any other reloads
for (const filename in require.cache) {
const module = require.cache[filename]
const plugin = ns.unwrapExports(module.exports)
const state = this.ctx.app.registry.get(plugin)
if (!state || this.declined.has(filename)) continue
pending.set(filename, state)
const runtime = this.ctx.app.registry.get(plugin)
if (!runtime || this.declined.has(filename)) continue
pending.set(filename, runtime)
if (!plugin['sideEffect']) this.declined.add(filename)
}

for (const [filename, state] of pending) {
for (const [filename, runtime] of pending) {
// check if it is a dependent of the changed file
this.declined.delete(filename)
const dependencies = [...loadDependencies(filename, this.declined)]
if (!state.plugin['sideEffect']) this.declined.add(filename)
if (!runtime.plugin['sideEffect']) this.declined.add(filename)

// we only detect reloads at plugin level
// a plugin will be reloaded if any of its dependencies are accepted
if (!dependencies.some(dep => this.accepted.has(dep))) continue
dependencies.forEach(dep => this.accepted.add(dep))

// prepare for reload
let ancestor = state, isMarked = false
while ((ancestor = ancestor.parent?.state) && !(isMarked = reloads.has(ancestor)));
if (!isMarked) reloads.set(state, filename)
let isMarked = false
const visited = new Set<Plugin.Runtime>()
const queued = [runtime]
while (queued.length) {
const runtime = queued.shift()
if (visited.has(runtime)) continue
visited.add(runtime)
if (reloads.has(runtime)) {
isMarked = true
break
}
for (const state of runtime.children) {
queued.push(state.runtime)
}
}
if (!isMarked) reloads.set(runtime, filename)
}

// save require.cache for rollback
// and delete module cache before re-require
const backup: Dict<NodeJS.Module> = {}
for (const filename of this.accepted) {
backup[filename] = require.cache[filename]
delete require.cache[filename]
}

// delete module cache before re-require
this.accepted.forEach((path) => {
delete require.cache[path]
})
/** rollback require.cache */
function rollback() {
for (const filename in backup) {
require.cache[filename] = backup[filename]
}
}

// attempt to load entry files
const attempts = {}
Expand All @@ -254,30 +271,25 @@ class Watcher {
attempts[filename] = ns.unwrapExports(require(filename))
}
} catch (err) {
// rollback require.cache
logger.warn(err)
return rollback()
}

function rollback() {
for (const filename in backup) {
require.cache[filename] = backup[filename]
}
}

try {
for (const [state, filename] of reloads) {
for (const [runtime, filename] of reloads) {
const path = relative(this.root, filename)

try {
this.ctx.dispose(state.plugin)
this.ctx.dispose(runtime.plugin)
} catch (err) {
logger.warn('failed to dispose plugin at %c\n' + coerce(err), path)
}

try {
const plugin = attempts[filename]
state.parent.plugin(plugin, state.config)
for (const state of runtime.children) {
state.parent.plugin(plugin, state.config)
}
logger.info('reload plugin at %c', path)
} catch (err) {
logger.warn('failed to reload plugin at %c\n' + coerce(err), path)
Expand All @@ -287,10 +299,10 @@ class Watcher {
} catch {
// rollback require.cache and plugin states
rollback()
for (const [state, filename] of reloads) {
for (const [runtime, filename] of reloads) {
try {
this.ctx.dispose(attempts[filename])
state.parent.plugin(state.plugin, state.config)
runtime.parent.plugin(runtime.plugin, runtime.config)
} catch (err) {
logger.warn(err)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
},
"dependencies": {
"@koishijs/utils": "^5.4.5",
"cordis": "^1.2.0",
"cordis": "^1.2.1",
"fastest-levenshtein": "^1.0.12",
"minato": "^1.1.0"
}
Expand Down
32 changes: 17 additions & 15 deletions packages/core/tests/command.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,27 +157,28 @@ describe('Command API', () => {
})

it('patch command', () => {
app.plugin((ctx) => {
const dispose = app.plugin((ctx) => {
ctx.command('foo', { patch: true }).alias('fooo').option('opt', 'option 1')
ctx.command('abc', { patch: true }).alias('abcd').option('opt', 'option 1')

const foo = app.$commander._commands.get('foo')
expect(foo).to.be.ok
expect(app.$commander._commands.get('fooo')).to.be.ok
expect(Object.keys(foo._options)).to.have.length(2)
expect(app.$commander._commands.get('abc')).to.be.undefined
expect(app.$commander._commands.get('abcd')).to.be.undefined

ctx.dispose()
expect(app.$commander._commands.get('foo')).to.be.ok
expect(app.$commander._commands.get('fooo')).to.be.undefined
expect(Object.keys(foo._options)).to.have.length(1)
})

const foo = app.$commander._commands.get('foo')
expect(foo).to.be.ok
expect(app.$commander._commands.get('fooo')).to.be.ok
expect(Object.keys(foo._options)).to.have.length(2)
expect(app.$commander._commands.get('abc')).to.be.undefined
expect(app.$commander._commands.get('abcd')).to.be.undefined

dispose()
expect(app.$commander._commands.get('foo')).to.be.ok
expect(app.$commander._commands.get('fooo')).to.be.undefined
expect(Object.keys(foo._options)).to.have.length(1)
})
})

describe('Execute Commands', () => {
const app = new App().plugin(mock)
const app = new App()
app.plugin(mock)
const session = app.mock.session({})
const warn = jest.spyOn(logger, 'warn')
const next = jest.fn(Next.compose)
Expand Down Expand Up @@ -293,7 +294,8 @@ describe('Command API', () => {
})

describe('Bypass Middleware', async () => {
const app = new App().plugin(mock)
const app = new App()
app.plugin(mock)
const client = app.mock.client('123')

app.middleware((session, next) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/tests/middleware.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { expect } from 'chai'
import mock from '@koishijs/plugin-mock'
import * as jest from 'jest-mock'

const app = new App().plugin(mock)
const app = new App()
app.plugin(mock)

const midLogger = new Logger('session')
const midWarn = jest.spyOn(midLogger, 'warn')
Expand Down
3 changes: 2 additions & 1 deletion packages/core/tests/selector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { App } from 'koishi'
import { expect } from 'chai'
import mock from '@koishijs/plugin-mock'

const app = new App().plugin(mock)
const app = new App()
app.plugin(mock)
const guildSession = app.mock.session({ userId: '123', guildId: '456', subtype: 'group' })
const privateSession = app.mock.session({ userId: '123', subtype: 'private' })

Expand Down
6 changes: 4 additions & 2 deletions packages/core/tests/session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import mock from '@koishijs/plugin-mock'

describe('Session API', () => {
describe('Command Execution', () => {
const app = new App().plugin(mock)
const app = new App()
app.plugin(mock)
const client = app.mock.client('456')

app.command('echo [content:text]').action((_, text) => text)
Expand All @@ -30,7 +31,8 @@ describe('Session API', () => {
})

describe('Other Session Methods', () => {
const app = new App({ prefix: '.' }).plugin(mock)
const app = new App({ prefix: '.' })
app.plugin(mock)
const client = app.mock.client('123', '456')

before(() => app.start())
Expand Down
5 changes: 2 additions & 3 deletions packages/koishi/src/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ declare module '@koishijs/core' {

namespace Registry {
interface Delegates {
plugin(path: string, config?: any): Context
plugin(path: string, config?: any): () => boolean
}
}
}
Expand Down Expand Up @@ -38,8 +38,7 @@ Context.prototype.plugin = function (this: Context, entry: any, config?: any) {
if (typeof entry === 'string') {
entry = scope.require(entry)
}
plugin.call(this, entry, config)
return this
return plugin.call(this, entry, config)
}

const start = App.prototype.start
Expand Down
2 changes: 1 addition & 1 deletion packages/utils/src/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function merge<T extends object>(head: T, base: T): T {
return head
}

export function assertProperty<O, K extends keyof O>(config: O, key: K) {
export function assertProperty<O, K extends keyof O & string>(config: O, key: K) {
if (!config[key]) throw new Error(`missing configuration "${key}"`)
return config[key]
}
Expand Down
3 changes: 2 additions & 1 deletion plugins/a11y/schedule/tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { expect } from 'chai'
import 'chai-shape'

describe('@koishijs/plugin-switch', () => {
const app = new App().plugin(mock)
const app = new App()
app.plugin(mock)
const client1 = app.mock.client('123', '456')
const client2 = app.mock.client('123')

Expand Down
3 changes: 2 additions & 1 deletion plugins/common/repeater/tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import mock from '@koishijs/plugin-mock'
import * as repeater from '@koishijs/plugin-repeater'

async function setup(config: repeater.Config) {
const app = new App().plugin(mock)
const app = new App()
app.plugin(mock)
const client1 = app.mock.client('123', '123')
const client2 = app.mock.client('456', '123')
const client3 = app.mock.client('789', '123')
Expand Down

0 comments on commit 8f62369

Please sign in to comment.