diff --git a/packages/loader/src/shared.ts b/packages/loader/src/shared.ts index 66cd948..63caea1 100644 --- a/packages/loader/src/shared.ts +++ b/packages/loader/src/shared.ts @@ -49,10 +49,20 @@ export namespace Entry { config?: any disabled?: boolean intercept?: Dict + isolate?: Dict when?: any } } +function swapAssign(target: T, source?: T): T { + const result = { ...target } + for (const key in result) { + delete target[key] + } + Object.assign(target, source) + return result +} + export class Entry { public fork: ForkScope | null = null public isUpdate = false @@ -60,10 +70,32 @@ export class Entry { constructor(public loader: Loader, public parent: Context, public options: Entry.Options) {} amend(ctx: Context) { - for (const key of Reflect.ownKeys(ctx[Context.intercept])) { - delete ctx[Context.intercept][key] + swapAssign(ctx[Context.intercept], this.options.intercept) + const neoMap: Dict = Object.create(Object.getPrototypeOf(ctx[Context.isolate])) + for (const [key, label] of Object.entries(this.options.isolate ?? {})) { + if (typeof label === 'string') { + neoMap[key] = (this.loader.realms[label] ??= Object.create(null))[key] ??= Symbol(key) + } else if (label) { + neoMap[key] = Symbol(key) + } + } + for (const key in { ...ctx[Context.isolate], ...neoMap }) { + if (neoMap[key] === ctx[Context.isolate][key]) continue + const self = Object.create(null) + self[Context.filter] = (ctx2: Context) => { + return ctx[Context.isolate][key] === ctx2[Context.isolate][key] + } + ctx.emit(self, 'internal/before-service', key) + } + const oldMap = swapAssign(ctx[Context.isolate], neoMap) + for (const key in { ...oldMap, ...ctx[Context.isolate] }) { + if (oldMap[key] === ctx[Context.isolate][key]) continue + const self = Object.create(null) + self[Context.filter] = (ctx2: Context) => { + return ctx[Context.isolate][key] === ctx2[Context.isolate][key] + } + ctx.emit(self, 'internal/service', key) } - Object.assign(ctx[Context.intercept], this.options.intercept) } // TODO: handle parent change @@ -85,9 +117,9 @@ export class Entry { this.parent.emit('loader/entry', 'apply', this) const plugin = await this.loader.resolve(this.options.name) if (!plugin) return - const intercept = Object.create(this.parent[Context.intercept]) const ctx = this.parent.extend({ - [Context.intercept]: intercept, + [Context.intercept]: Object.create(this.parent[Context.intercept]), + [Context.isolate]: Object.create(this.parent[Context.isolate]), }) this.amend(ctx) this.fork = ctx.plugin(plugin, this.loader.interpolate(this.options.config)) @@ -127,6 +159,7 @@ export abstract class Loader extends public mimeType!: string public filename!: string public entries: Dict = Object.create(null) + public realms: Dict> = Object.create(null) private tasks = new Set>() @@ -134,6 +167,7 @@ export abstract class Loader extends constructor(public app: Context, public options: T) { super(app, 'loader', true) + this.realms.root = app.root[Context.isolate] } async init(filename?: string) { diff --git a/packages/loader/tests/index.spec.ts b/packages/loader/tests/index.spec.ts index 684ac3d..02f9644 100644 --- a/packages/loader/tests/index.spec.ts +++ b/packages/loader/tests/index.spec.ts @@ -1,12 +1,12 @@ import { describe, mock, test } from 'node:test' import { expect } from 'chai' -import { Context } from '@cordisjs/core' +import { Context, Service } from '@cordisjs/core' +import { defineProperty } from 'cosmokit' import MockLoader from './utils' describe('@cordisjs/loader', () => { const root = new Context() root.plugin(MockLoader) - root.loader.writable = true const foo = mock.fn((ctx: Context) => { ctx.accept() @@ -100,4 +100,99 @@ describe('@cordisjs/loader', () => { name: 'qux', }]) }) + + describe('service isolation', async () => { + const root = new Context() + root.plugin(MockLoader) + + const dispose = mock.fn() + const foo = mock.fn((ctx: Context) => { + ctx.on('dispose', dispose) + }) + defineProperty(foo, 'inject', ['bar']) + class Bar extends Service { + static [Service.provide] = 'bar' + static [Service.immediate] = true + } + class Qux extends Service { + static [Service.provide] = 'qux' + static [Service.immediate] = true + } + root.loader.register('foo', foo) + root.loader.register('bar', Bar) + root.loader.register('qux', Qux) + + test('basic support', async () => { + root.loader.config = [{ + id: '1', + name: 'bar', + }, { + id: '2', + name: 'qux', + }, { + id: '3', + name: 'group', + config: [{ + id: '4', + name: 'foo', + }], + }] + + await root.start() + expect(root.registry.get(foo)).to.be.ok + expect(root.registry.get(Bar)).to.be.ok + expect(root.registry.get(Qux)).to.be.ok + expect(foo.mock.calls).to.have.length(1) + }) + + test('isolate', async () => { + root.loader.config = [{ + id: '1', + name: 'bar', + }, { + id: '2', + name: 'qux', + }, { + id: '3', + name: 'group', + isolate: { + bar: true, + }, + config: [{ + id: '4', + name: 'foo', + }], + }] + + expect(dispose.mock.calls).to.have.length(0) + root.loader.entryFork.update(root.loader.config) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(foo.mock.calls).to.have.length(1) + expect(dispose.mock.calls).to.have.length(1) + + root.loader.config = [{ + id: '1', + name: 'bar', + }, { + id: '2', + name: 'qux', + }, { + id: '3', + name: 'group', + isolate: { + bar: false, + qux: true, + }, + config: [{ + id: '4', + name: 'foo', + }], + }] + + root.loader.entryFork.update(root.loader.config) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(foo.mock.calls).to.have.length(2) + expect(dispose.mock.calls).to.have.length(1) + }) + }) }) diff --git a/packages/loader/tests/utils.ts b/packages/loader/tests/utils.ts index 368b11c..120ae74 100644 --- a/packages/loader/tests/utils.ts +++ b/packages/loader/tests/utils.ts @@ -14,6 +14,7 @@ export default class MockLoader extends Loader { constructor(ctx: Context) { super(ctx, { name: 'cordis' }) this.register('group', group) + this.writable = true } register(name: string, plugin: any) {