diff --git a/packages/cordis/src/worker/logger.ts b/packages/cordis/src/worker/logger.ts index 83a7f86..1ccc4ad 100644 --- a/packages/cordis/src/worker/logger.ts +++ b/packages/cordis/src/worker/logger.ts @@ -35,7 +35,7 @@ export function apply(ctx: Context, config: Config = {}) { }) ctx.on('loader/entry', (type, entry) => { - ctx.logger('loader').info('%s plugin %c', type, entry.name) + ctx.logger('loader').info('%s plugin %c', type, entry.options.name) }) ctx.loader.prolog = [] diff --git a/packages/hmr/src/index.ts b/packages/hmr/src/index.ts index a8ef749..90029dd 100644 --- a/packages/hmr/src/index.ts +++ b/packages/hmr/src/index.ts @@ -96,7 +96,7 @@ class Watcher extends Service { this.watcher.on('change', async (path) => { const filename = pathToFileURL(resolve(this.base, path)).href - const isEntry = filename === this.initialURL // || loader.envFiles.includes(filename) + const isEntry = filename === this.initialURL if (loader.suspend && isEntry) { loader.suspend = false return @@ -105,17 +105,15 @@ class Watcher extends Service { this.ctx.logger.debug('change detected:', path) if (isEntry) { - if (this.ctx.loader.internal!.loadCache.has(filename)) { - this.ctx.loader.exit() + if (loader.internal!.loadCache.has(filename)) { + loader.exit() } else { - const config = await loader.readConfig() - loader.entry.update(config) - this.ctx.emit('config') + await loader.reload() } } else { if (this.externals.has(filename)) { - this.ctx.loader.exit() - } else if (this.ctx.loader.internal!.loadCache.has(filename)) { + loader.exit() + } else if (loader.internal!.loadCache.has(filename)) { this.stashed.add(filename) triggerLocalReload() } @@ -206,7 +204,7 @@ class Watcher extends Service { // Plugin entry files should be "atomic". // Which means, reloading them will not cause any other reloads. - const names = new Set(Object.values(this.ctx.loader.states).map(state => state.entry.name)) + const names = new Set(Object.values(this.ctx.loader.entries).map(entry => entry.options.name)) for (const name of names) { try { const { url } = await this.ctx.loader.internal!.resolve(name, this.initialURL, {}) @@ -246,15 +244,15 @@ class Watcher extends Service { isMarked = true break } - for (const state of runtime.children) { - queued.push(state.runtime) + for (const fork of runtime.children) { + queued.push(fork.runtime) } } if (!isMarked) { const children: ForkScope[] = [] reloads.set(plugin, { filename: job.url, children }) - for (const state of runtime.children) { - children.push(state) + for (const fork of runtime.children) { + children.push(fork) } } } else { @@ -309,7 +307,8 @@ class Watcher extends Service { try { for (const oldFork of children) { const fork = oldFork.parent.plugin(attempts[filename], oldFork.config) - fork.id = oldFork.id + fork.entry = oldFork.entry + fork.entry.fork = fork } this.ctx.logger.info('reload plugin at %c', path) } catch (err) { @@ -326,7 +325,8 @@ class Watcher extends Service { this.ctx.registry.delete(attempts[filename]) for (const oldFork of children) { const fork = oldFork.parent.plugin(plugin, oldFork.config) - fork.id = oldFork.id + fork.entry = oldFork.entry + fork.entry.fork = fork } } catch (err) { this.ctx.logger.warn(err) diff --git a/packages/loader/src/shared.ts b/packages/loader/src/shared.ts index 63654d4..0afecd2 100644 --- a/packages/loader/src/shared.ts +++ b/packages/loader/src/shared.ts @@ -23,19 +23,10 @@ declare module '@cordisjs/core' { // Theoretically, these properties will only appear on `ForkScope`. // We define them directly on `EffectScope` for typing convenience. interface EffectScope { - id?: string + entry?: Entry } } -export interface Entry { - id: string - name: string - config?: any - when?: any -} - -const kUpdate = Symbol('update') - const writable = { '.json': 'application/json', '.yaml': 'application/yaml', @@ -51,9 +42,42 @@ if (typeof require !== 'undefined') { } } -interface State { - entry: Entry - fork?: ForkScope +export namespace Entry { + export interface Options { + id: string + name: string + config?: any + disabled?: boolean + when?: any + } +} + +export class Entry { + public fork: ForkScope | null = null + public isUpdate = false + + constructor(public loader: Loader, public parent: Context, public options: Entry.Options) {} + + stop() { + if (!this.fork) return + this.parent.emit('loader/entry', 'unload', this) + this.fork.dispose() + this.fork = null + } + + async start() { + if (this.fork) { + this.isUpdate = true + this.fork.update(this.options.config) + } else { + this.parent.emit('loader/entry', 'apply', this) + const plugin = await this.loader.resolve(this.options.name) + if (!plugin) return + const ctx = this.parent.extend() + this.fork = ctx.plugin(plugin, this.loader.interpolate(this.options.config)) + this.fork.entry = this + } + } } export namespace Loader { @@ -63,7 +87,7 @@ export namespace Loader { } } -export abstract class Loader extends Service { +export abstract class Loader extends Service { // process public baseDir = process.cwd() public envData = process.env.CORDIS_SHARED @@ -74,12 +98,12 @@ export abstract class Loader extends env: process.env, } - public entry!: ForkScope + public entryFork!: ForkScope public suspend = false public writable = false public mimeType!: string public filename!: string - public states: Dict = Object.create(null) + public entries: Dict = Object.create(null) private tasks = new Set>() private store = new WeakMap() @@ -157,6 +181,12 @@ export abstract class Loader extends if (!silent) this.app.emit('config') } + async reload() { + const config = await this.readConfig() + this.entryFork.update(config) + this.app.emit('config') + } + interpolate(source: any) { if (typeof source === 'string') { return interpolate(source, this.params, /\$\{\{(.+?)\}\}/g) @@ -196,42 +226,27 @@ export abstract class Loader extends return !!this.interpolate(`\${{ ${expr} }}`) } - async reload(parent: Context, entry: Entry) { - if (!entry.id) { + async updateEntry(parent: Context, options: Entry.Options) { + if (!options.id) { do { - entry.id = Math.random().toString(36).slice(2, 8) - } while (this.states[entry.id]) + options.id = Math.random().toString(36).slice(2, 8) + } while (this.entries[options.id]) } - let state = this.states[entry.id] - if (state?.fork) { - if (!this.isTruthyLike(entry.when)) { - this.unload(parent, entry) - return - } - state.fork[kUpdate] = true - state.fork.update(entry.config) + const entry = this.entries[options.id] ??= new Entry(this, parent, options) + entry.options = options + if (!this.isTruthyLike(options.when) || options.disabled) { + entry.stop() } else { - if (!this.isTruthyLike(entry.when)) return - parent.emit('loader/entry', 'apply', entry) - const plugin = await this.resolve(entry.name) - if (!plugin) return - const ctx = parent.extend() - state = { - entry, - fork: ctx.plugin(plugin, this.interpolate(entry.config)), - } - state.fork!.id = entry.id - this.states[entry.id] = state + entry.start() } } - unload(parent: Context, entry: Entry) { - const state = this.states[entry.id] - if (state?.fork) { - parent.emit('loader/entry', 'unload', entry) - state.fork.dispose() - } + removeEntry(parent: Context, options: Entry.Options) { + const entry = this.entries[options.id] + if (!entry) return + entry.stop() + delete this.entries[options.id] } paths(scope: EffectScope): string[] { @@ -243,31 +258,42 @@ export abstract class Loader extends return ([] as string[]).concat(...scope.runtime.children.map(child => this.paths(child))) } - if (scope.id) return [scope.id] + if (scope.entry) return [scope.entry.options.id] return this.paths(scope.parent.scope) } async start() { await this.readConfig() - this.entry = this.app.plugin(group, this.config) + this.entryFork = this.app.plugin(group, this.config) this.app.on('dispose', () => { this.exit() }) this.app.on('internal/update', (fork) => { - const state = this.states[fork.id!] - if (!state) return - fork.parent.emit('loader/entry', 'reload', state.entry) + const entry = this.entries[fork.entry?.options.id!] + if (!entry) return + fork.parent.emit('loader/entry', 'reload', entry) }) this.app.on('internal/before-update', (fork, config) => { - if (fork[kUpdate]) return delete fork[kUpdate] - if (!fork.id) return + if (!fork.entry) return + if (fork.entry.isUpdate) return fork.entry.isUpdate = false const { schema } = fork.runtime - const entry = fork.parent.scope.config?.find((entry: Entry) => entry.id === fork.id) - if (!entry) return - entry.config = schema ? schema.simplify(config) : config + fork.entry.options.config = schema ? schema.simplify(config) : config + this.writeConfig() + }) + + this.app.on('internal/fork', (fork) => { + // fork.uid: fork is created (we only care about fork dispose event) + // fork.parent.runtime.plugin !== group: fork is not tracked by loader + if (fork.uid || !fork.entry) return + fork.parent.emit('loader/entry', 'unload', fork.entry) + // fork is disposed by main scope (e.g. hmr plugin) + // normal: ctx.dispose() -> fork / runtime dispose -> delete(plugin) + // hmr: delete(plugin) -> runtime dispose -> fork dispose + if (!this.app.registry.has(fork.runtime.plugin)) return + fork.entry.options.disabled = true this.writeConfig() }) @@ -283,26 +309,32 @@ export abstract class Loader extends exit() {} } -export function group(ctx: Context, config: Entry[]) { +export function group(ctx: Context, config: Entry.Options[]) { for (const entry of config) { - ctx.loader.reload(ctx, entry) + ctx.loader.updateEntry(ctx, entry) } - ctx.accept((neo: Entry[]) => { + ctx.accept((neo: Entry.Options[]) => { // update config reference - const old = ctx.scope.config as Entry[] + const old = ctx.scope.config as Entry.Options[] const oldMap = Object.fromEntries(old.map(entry => [entry.id, entry])) const neoMap = Object.fromEntries(neo.map(entry => [entry.id, entry])) // update inner plugins for (const id in { ...oldMap, ...neoMap }) { if (!neoMap[id]) { - ctx.loader.unload(ctx, oldMap[id]) + ctx.loader.removeEntry(ctx, oldMap[id]) } else { - ctx.loader.reload(ctx, neoMap[id]) + ctx.loader.updateEntry(ctx, neoMap[id]) } } }, { passive: true }) + + ctx.on('dispose', () => { + for (const entry of ctx.scope.config as Entry.Options[]) { + ctx.loader.removeEntry(ctx, entry) + } + }) } defineProperty(group, 'inject', ['loader']) diff --git a/packages/loader/tests/index.spec.ts b/packages/loader/tests/index.spec.ts index 321b1a5..c89e7b9 100644 --- a/packages/loader/tests/index.spec.ts +++ b/packages/loader/tests/index.spec.ts @@ -48,7 +48,7 @@ describe('@cordisjs/loader', () => { name: 'foo', }] - root.loader.entry.update(root.loader.config) + root.loader.entryFork.update(root.loader.config) await new Promise((resolve) => setTimeout(resolve, 0)) expect(root.registry.get(foo)).to.be.ok expect(root.registry.get(bar)).to.be.not.ok @@ -58,7 +58,7 @@ describe('@cordisjs/loader', () => { test('plugin update', async () => { const runtime = root.registry.get(foo) - runtime?.update({ a: 3 }) + runtime!.update({ a: 3 }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(root.loader.config).to.deep.equal([{ id: '1', @@ -66,4 +66,16 @@ describe('@cordisjs/loader', () => { config: { a: 3 }, }]) }) + + test('plugin dispose', async () => { + const runtime = root.registry.get(foo) + runtime!.dispose() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(root.loader.config).to.deep.equal([{ + id: '1', + name: 'foo', + disabled: true, + config: { a: 3 }, + }]) + }) }) diff --git a/packages/loader/tests/utils.ts b/packages/loader/tests/utils.ts index 6baed0f..368b11c 100644 --- a/packages/loader/tests/utils.ts +++ b/packages/loader/tests/utils.ts @@ -1,6 +1,6 @@ import { Dict } from 'cosmokit' import { Context, Plugin } from '@cordisjs/core' -import { Entry, group, Loader } from '../src/shared' +import { group, Loader } from '../src/shared' declare module '../src/shared' { interface Loader {