From 58528599ee14cf7a0fa328ffbc2272b852cfe3b9 Mon Sep 17 00:00:00 2001 From: Shigma Date: Sat, 21 Oct 2023 14:20:34 +0800 Subject: [PATCH] feat: re-implement service API with proxy --- src/context.ts | 237 ++++++++++++++++++++++++------------------ src/events.ts | 14 +-- src/registry.ts | 6 +- tests/extend.spec.ts | 35 ++++--- tests/isolate.spec.ts | 42 +++++++- tests/plugin.spec.ts | 2 +- 6 files changed, 205 insertions(+), 131 deletions(-) diff --git a/src/context.ts b/src/context.ts index faa9307..778b991 100644 --- a/src/context.ts +++ b/src/context.ts @@ -3,10 +3,44 @@ import { Lifecycle } from './events' import { Registry } from './registry' import { getConstructor, isConstructor, resolveConfig } from './utils' +export namespace Context { + export type Parameterized = Omit & { config: T } + + export interface Config extends Lifecycle.Config, Registry.Config {} + + export interface MixinOptions { + methods?: string[] + accessors?: string[] + prototype?: any + } + + export type Internal = Internal.Service | Internal.Accessor | Internal.Method + + export namespace Internal { + export interface Service { + type: 'service' + key: symbol + prototype?: {} + } + + export interface Accessor { + type: 'accessor' + service: keyof any + } + + export interface Method { + type: 'method' + service: keyof any + } + } +} + export interface Context { [Context.config]: Context.Config - root: Context.Configured + [Context.internal]: Record + root: Context.Parameterized mapping: Record + realms: Record> lifecycle: Lifecycle registry: Registry config: T @@ -22,106 +56,70 @@ export class Context { static readonly current = Symbol('current') static readonly internal = Symbol('internal') - constructor(config?: Context.Config) { - const options = resolveConfig(getConstructor(this), config) - const attach = (internal: {}) => { - if (!internal) return - attach(Object.getPrototypeOf(internal)) - for (const key of Object.getOwnPropertySymbols(internal)) { - const constructor = internal[key] - const name = constructor[Context.expose] - this[key] = new constructor(this, name ? options?.[name] : options) - } + private static ensureInternal(): Context[typeof Context.internal] { + if (Object.prototype.hasOwnProperty.call(this.prototype, this.internal)) { + return this.prototype[this.internal] } - - this.root = this as any - this.mapping = Object.create(null) - attach(this[Context.internal]) - } - - [Symbol.for('nodejs.util.inspect.custom')]() { - return `Context <${this.runtime.name}>` - } - - get events() { - return this.lifecycle + const parent = Object.getPrototypeOf(this).ensureInternal() + return this.prototype[this.internal] = Object.create(parent) } /** @deprecated */ - get state() { - return this.scope - } - - extend(meta = {}): this { - return Object.assign(Object.create(this), meta) - } - - isolate(names: string[]) { - const mapping = Object.create(this.mapping) - for (const name of names) { - mapping[name] = Symbol(name) - } - return this.extend({ mapping }) - } -} - -export namespace Context { - export type Configured = Omit & { config: T } - - export interface Config extends Lifecycle.Config, Registry.Config {} - - export interface MixinOptions { - methods?: string[] - properties?: string[] - } - - export function mixin(name: keyof any, options: MixinOptions) { + static mixin(name: keyof any, options: Context.MixinOptions) { + const internal = this.ensureInternal() for (const key of options.methods || []) { - const method = defineProperty(function (this: Context, ...args: any[]) { - return this[name][key](...args) - }, 'name', key) - defineProperty(this.prototype, key, method) + internal[key] = { type: 'method', service: name } } - - for (const key of options.properties || []) { - Object.defineProperty(this.prototype, key, { - configurable: true, - get(this: Context) { - return this[name][key] - }, - set(this: Context, value: any) { - this[name][key] = value - }, - }) + for (const key of options.accessors || []) { + internal[key] = { type: 'accessor', service: name } } } - export interface ServiceOptions extends MixinOptions { - prototype?: any + /** @deprecated */ + static service(name: keyof any, options: Context.MixinOptions = {}) { + const internal = this.ensureInternal() + const key = typeof name === 'symbol' ? name : Symbol(name) + internal[name] = { type: 'service', key } + if (isConstructor(options)) { + internal[name]['prototype'] = options.prototype + } + this.mixin(name, options) } - export function service(name: keyof any, options: ServiceOptions = {}) { - if (Object.prototype.hasOwnProperty.call(this.prototype, name)) return - const privateKey = typeof name === 'symbol' ? name : Symbol(name) - - Object.defineProperty(this.prototype, name, { - configurable: true, - get(this: Context) { - const key = this.mapping[name as any] || privateKey - const value = this.root[key] + static handler: ProxyHandler = { + get(target, name, receiver) { + if (typeof name !== 'string') return Reflect.get(target, name, receiver) + const internal = receiver[Context.internal][name] + if (!internal) return Reflect.get(target, name, receiver) + if (internal.type === 'accessor') { + return Reflect.get(receiver[internal.service], name) + } else if (internal.type === 'method') { + return defineProperty(function (this: Context, ...args: any[]) { + return this[internal.service][name](...args) + }, 'name', name) + } else if (internal.type === 'service') { + const privateKey = receiver.mapping[name] || internal.key + const value = receiver.root[privateKey] if (!value) return - defineProperty(value, Context.current, this) + defineProperty(value, Context.current, receiver) return value - }, - set(this: Context, value) { - const key = this.mapping[name] || privateKey - const oldValue = this.root[key] - if (oldValue === value) return + } + }, + set(target, name, value, receiver) { + if (typeof name !== 'string') return Reflect.set(target, name, value, receiver) + const internal = receiver[Context.internal][name] + if (!internal) return Reflect.set(target, name, value, receiver) + if (internal.type === 'accessor') { + return Reflect.set(receiver[internal.service], name, value) + } else if (internal.type === 'service') { + const key = receiver.mapping[name] || internal.key + const oldValue = receiver.root[key] + if (oldValue === value) return true // setup filter for events const self = Object.create(null) self[Context.filter] = (ctx: Context) => { - return this.mapping[name] === ctx.mapping[name] + return receiver.mapping[name] === ctx.mapping[name] } // check override @@ -129,33 +127,66 @@ export namespace Context { throw new Error(`service ${name} has been registered`) } - if (typeof name === 'string') { - this.emit(self, 'internal/before-service', name, value) + if (typeof name === 'string' && !internal.prototype) { + receiver.root.emit(self, 'internal/before-service', name, value) } - this.root[key] = value + receiver.root[key] = value if (value && typeof value === 'object') { - defineProperty(value, Context.source, this) + defineProperty(value, Context.source, receiver) } - if (typeof name === 'string') { - this.emit(self, 'internal/service', name, oldValue) + if (typeof name === 'string' && !internal.prototype) { + receiver.root.emit(self, 'internal/service', name, oldValue) } - }, - }) + return true + } + return false + }, + } - if (isConstructor(options)) { - const internal = ensureInternal(this.prototype) - internal[privateKey] = options + constructor(config?: Context.Config) { + const self = new Proxy(Object.create(Object.getPrototypeOf(this)), Context.handler) + config = resolveConfig(getConstructor(this), config) + self.root = self as any + self.mapping = Object.create(null) + self.realms = Object.create(null) + + const attach = (internal: Context[typeof Context.internal]) => { + if (!internal) return + attach(Object.getPrototypeOf(internal)) + for (const key of [...Object.getOwnPropertyNames(internal), ...Object.getOwnPropertySymbols(internal)]) { + const constructor = internal[key]['prototype']?.constructor + if (!constructor) continue + const name = constructor[Context.expose] + self[key] = new constructor(self, name ? config?.[name] : config) + } } + attach(this[Context.internal]) + return self + } - this.mixin(name, options) + [Symbol.for('nodejs.util.inspect.custom')]() { + return `Context <${this.runtime.name}>` + } + + get events() { + return this.lifecycle } - function ensureInternal(prototype: {}) { - if (Object.prototype.hasOwnProperty.call(prototype, Context.internal)) { - return prototype[Context.internal] + /** @deprecated */ + get state() { + return this.scope + } + + extend(meta = {}): this { + return Object.assign(Object.create(this), meta) + } + + isolate(names: string[], label?: string) { + const mapping = Object.create(this.mapping) + for (const name of names) { + mapping[name] = label ? ((this.realms[label] ??= Object.create(null))[name] ??= Symbol(name)) : Symbol(name) } - const parent = ensureInternal(Object.getPrototypeOf(prototype)) - return prototype[Context.internal] = Object.create(parent) + return this.extend({ mapping }) } } @@ -164,7 +195,7 @@ Context.prototype[Context.internal] = Object.create(null) Context.service('registry', Registry) Context.service('lifecycle', Lifecycle) -Context.mixin('state', { - properties: ['config', 'runtime'], +Context.mixin('scope', { + accessors: ['config', 'runtime'], methods: ['collect', 'accept', 'decline'], }) diff --git a/src/events.ts b/src/events.ts index 6a48078..45fcedd 100644 --- a/src/events.ts +++ b/src/events.ts @@ -14,6 +14,7 @@ export type GetEvents = C[typeof Context.events] declare module './context' { export interface Context { + /* eslint-disable max-len */ [Context.events]: Events parallel>(name: K, ...args: Parameters[K]>): Promise parallel>(thisArg: ThisType[K]>, name: K, ...args: Parameters[K]>): Promise @@ -28,6 +29,7 @@ declare module './context' { off>(name: K, listener: GetEvents[K]): boolean start(): Promise stop(): Promise + /* eslint-enable max-len */ } } @@ -38,7 +40,7 @@ export namespace Lifecycle { } export class Lifecycle { - static readonly methods = ['on', 'once', 'off', 'before', 'after', 'parallel', 'emit', 'serial', 'bail', 'start', 'stop'] + static readonly methods = ['on', 'once', 'off', 'after', 'parallel', 'emit', 'serial', 'bail', 'start', 'stop'] isActive = false _tasks = new Set>() @@ -180,13 +182,13 @@ export interface Events { 'fork': Plugin.Function 'ready'(): Awaitable 'dispose'(): Awaitable - 'internal/fork'(fork: ForkScope>): void - 'internal/runtime'(runtime: MainScope>): void - 'internal/status'(scope: EffectScope>, oldValue: ScopeStatus): void + 'internal/fork'(fork: ForkScope>): void + 'internal/runtime'(runtime: MainScope>): void + 'internal/status'(scope: EffectScope>, oldValue: ScopeStatus): void 'internal/warning'(format: any, ...param: any[]): void 'internal/before-service'(name: string, value: any): void 'internal/service'(name: string, oldValue: any): void - 'internal/before-update'(fork: ForkScope>, config: any): void - 'internal/update'(fork: ForkScope>, oldConfig: any): void + 'internal/before-update'(fork: ForkScope>, config: any): void + 'internal/update'(fork: ForkScope>, oldConfig: any): void 'internal/hook'(this: Lifecycle, name: string, listener: Function, prepend: boolean): () => boolean } diff --git a/src/registry.ts b/src/registry.ts index 1a3666f..e1a522a 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -37,10 +37,10 @@ export namespace Plugin { declare module './context' { export interface Context { - using(deps: readonly string[], callback: Plugin.Function>): ForkScope> - plugin>, T extends Plugin.Config>(plugin: S, config?: boolean | T): ForkScope> + using(deps: readonly string[], callback: Plugin.Function>): ForkScope> + plugin>, T extends Plugin.Config>(plugin: S, config?: T): ForkScope> /** @deprecated use `ctx.registry.delete()` instead */ - dispose(plugin?: Plugin>): boolean + dispose(plugin?: Plugin>): boolean } } diff --git a/tests/extend.spec.ts b/tests/extend.spec.ts index ddf38ce..a8d1190 100644 --- a/tests/extend.spec.ts +++ b/tests/extend.spec.ts @@ -3,20 +3,18 @@ import { Context, Service } from '../src' describe('Extend', () => { it('basic support', () => { - interface C1 { + class S1 {} + class S2 {} + class S3 {} + + class C1 extends Context { s1: S1 s2: S2 s3: S3 } - class C1 extends Context {} - class S1 {} - class C2 extends C1 {} - class S2 {} - class C3 extends C1 {} - class S3 {} C2.service('s2', S2) C1.service('s1', S1) @@ -39,20 +37,25 @@ describe('Extend', () => { }) it('service isolation', () => { - class Inherited extends Context { + class Temp {} + class C1 extends Context { temp: Temp } - class Temp extends Service { - constructor(ctx: Context) { - super(ctx, 'temp', true) - } + class C2 extends C1 {} + C2.service('temp') + + const plugin = (ctx: C1) => { + ctx.temp = new Temp() } - const ctx = new Inherited() - ctx.plugin(Temp) + const c1 = new C1() + c1.plugin(plugin) + const c2 = new C2() + c2.plugin(plugin) - expect(Object.getOwnPropertyDescriptors(Inherited.prototype)).to.have.property('temp') - expect(Object.getOwnPropertyDescriptors(Context.prototype)).to.not.have.property('temp') + // `temp` is not a service of C1 + expect(c1.temp).to.be.not.ok + expect(c2.temp).to.be.ok }) }) diff --git a/tests/isolate.spec.ts b/tests/isolate.spec.ts index 9ab8030..c17d7d7 100644 --- a/tests/isolate.spec.ts +++ b/tests/isolate.spec.ts @@ -40,11 +40,15 @@ describe('Isolation', () => { it('isolated fork', () => { const root = new Context() - const callback = jest.fn() + const callback = jest.fn(() => {}) + const dispose = jest.fn(() => {}) const plugin = { reusable: true, inject: ['foo'], - apply: callback, + apply: (ctx: Context) => { + callback() + ctx.on('dispose', dispose) + }, } const ctx1 = root.isolate(['foo']) @@ -59,5 +63,39 @@ describe('Isolation', () => { expect(callback.mock.calls).to.have.length(1) ctx2.foo = { bar: 300 } expect(callback.mock.calls).to.have.length(2) + expect(dispose.mock.calls).to.have.length(0) + }) + + it('shared service', () => { + const root = new Context() + const callback = jest.fn(() => {}) + const dispose = jest.fn(() => {}) + const plugin = { + reusable: true, + inject: ['foo'], + apply: (ctx: Context) => { + callback() + ctx.on('dispose', dispose) + }, + } + + const ctx1 = root.isolate(['foo'], 'test') + ctx1.plugin(plugin) + const ctx2 = root.isolate(['foo'], 'test') + ctx2.plugin(plugin) + expect(callback.mock.calls).to.have.length(0) + + root.foo = { bar: 100 } + expect(callback.mock.calls).to.have.length(0) + ctx1.foo = { bar: 200 } + expect(callback.mock.calls).to.have.length(2) + expect(dispose.mock.calls).to.have.length(0) + ctx2.foo = null + expect(dispose.mock.calls).to.have.length(2) + ctx2.foo = { bar: 300 } + expect(callback.mock.calls).to.have.length(4) + expect(dispose.mock.calls).to.have.length(2) + ctx1.foo = null + expect(dispose.mock.calls).to.have.length(4) }) }) diff --git a/tests/plugin.spec.ts b/tests/plugin.spec.ts index 659c540..e175e44 100644 --- a/tests/plugin.spec.ts +++ b/tests/plugin.spec.ts @@ -75,7 +75,7 @@ describe('Plugin', () => { root.plugin({ name: 'bar', - apply: (ctx) => { + apply: (ctx: Context, config: {foo: 1}) => { expect(inspect(ctx)).to.equal('Context ') }, })