diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index 2a03a26..b4b90f4 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -1,7 +1,8 @@ -import { defineProperty, Dict, isNullable } from 'cosmokit' -import { Lifecycle } from './events.ts' -import { Registry } from './registry.ts' -import { getTraceable, isObject, isUnproxyable, resolveConfig, symbols } from './utils.ts' +import { defineProperty, Dict } from 'cosmokit' +import Lifecycle from './events.ts' +import ReflectService from './reflect.ts' +import Registry from './registry.ts' +import { resolveConfig, symbols } from './utils.ts' export namespace Context { export type Parameterized = C & { config: T } @@ -45,6 +46,7 @@ export interface Context { [Context.internal]: Dict root: this lifecycle: Lifecycle + reflect: ReflectService registry: Registry config: any } @@ -70,80 +72,6 @@ export class Context { Context.prototype[Context.is as any] = true } - private static ensureInternal(): Context[typeof symbols.internal] { - const ctx = this.prototype || this - if (Object.prototype.hasOwnProperty.call(ctx, symbols.internal)) { - return ctx[symbols.internal] - } - const parent = Context.ensureInternal.call(Object.getPrototypeOf(this)) - return ctx[symbols.internal] = Object.create(parent) - } - - static resolveInject(ctx: Context, name: string) { - let internal = ctx[symbols.internal][name] - while (internal?.type === 'alias') { - name = internal.name - internal = ctx[symbols.internal][name] - } - return [name, internal] as const - } - - static handler: ProxyHandler = { - get(target, prop, ctx: Context) { - if (typeof prop !== 'string') return Reflect.get(target, prop, ctx) - - if (Reflect.has(target, prop)) { - return getTraceable(ctx, Reflect.get(target, prop, ctx)) - } - - const checkInject = (name: string) => { - // Case 1: a normal property defined on context - if (Reflect.has(target, name)) return - // Case 2: built-in services and special properties - // - prototype: prototype detection - // - then: async function return - if (['prototype', 'then', 'registry', 'lifecycle'].includes(name)) return - // Case 3: `$` or `_` prefix - if (name[0] === '$' || name[0] === '_') return - // Case 4: access directly from root - if (!ctx.runtime.plugin) return - // Case 5: custom inject checks - if (ctx.bail(ctx, 'internal/inject', name)) return - const warning = new Error(`property ${name} is not registered, declare it as \`inject\` to suppress this warning`) - ctx.emit(ctx, 'internal/warning', warning) - } - - const [name, internal] = Context.resolveInject(ctx, prop) - if (!internal) { - checkInject(name) - return Reflect.get(target, name, ctx) - } else if (internal.type === 'accessor') { - return internal.get.call(ctx) - } else { - if (!internal.builtin) checkInject(name) - return ctx.get(name) - } - }, - - set(target, prop, value, ctx: Context) { - if (typeof prop !== 'string') return Reflect.set(target, prop, value, ctx) - - const [name, internal] = Context.resolveInject(ctx, prop) - if (!internal) { - // TODO - return Reflect.set(target, name, value, ctx) - } - if (internal.type === 'accessor') { - if (!internal.set) return false - return internal.set.call(ctx, value) - } else { - // ctx.emit('internal/warning', new Error(`assigning to service ${name} is not recommended, please use \`ctx.set()\` method instead`)) - ctx.set(name, value) - return true - } - }, - } - /** @deprecated use `Service.traceable` instead */ static associate(object: T, name: string) { return object @@ -151,10 +79,12 @@ export class Context { constructor(config?: any) { config = resolveConfig(this.constructor, config) + this[symbols.internal] = Object.create(null) this[symbols.isolate] = Object.create(null) this[symbols.intercept] = Object.create(null) - const self: Context = new Proxy(this, Context.handler) + const self: Context = new Proxy(this, ReflectService.handler) self.root = self + self.reflect = new ReflectService(self) self.registry = new Registry(self, config) self.lifecycle = new Lifecycle(self) self.mixin('scope', ['config', 'runtime', 'effect', 'collect', 'accept', 'decline']) @@ -196,94 +126,6 @@ export class Context { return this.scope } - get(name: K): undefined | this[K] - get(name: string): any - get(name: string) { - const internal = this[symbols.internal][name] - if (internal?.type !== 'service') return - const value = this.root[this[symbols.isolate][name]] - return getTraceable(this, value) - } - - set(name: K, value: undefined | this[K]): () => void - set(name: string, value: any): () => void - set(name: string, value: any) { - this.provide(name) - const key = this[symbols.isolate][name] - const oldValue = this.root[key] - value ??= undefined - let dispose = () => {} - if (oldValue === value) return dispose - - // check override - if (!isNullable(value) && !isNullable(oldValue)) { - throw new Error(`service ${name} has been registered`) - } - const ctx: Context = this - if (!isNullable(value)) { - dispose = ctx.effect(() => () => { - ctx.set(name, undefined) - }) - } - if (isUnproxyable(value)) { - ctx.emit(ctx, 'internal/warning', new Error(`service ${name} is an unproxyable object, which may lead to unexpected behavior`)) - } - - // setup filter for events - const self = Object.create(ctx) - self[symbols.filter] = (ctx2: Context) => { - return ctx[symbols.isolate][name] === ctx2[symbols.isolate][name] - } - - ctx.emit(self, 'internal/before-service', name, value) - ctx.root[key] = value - if (isObject(value)) { - defineProperty(value, symbols.source, ctx) - } - ctx.emit(self, 'internal/service', name, oldValue) - return dispose - } - - /** @deprecated use `ctx.set()` instead */ - provide(name: string, value?: any, builtin?: boolean) { - const internal = Context.ensureInternal.call(this.root) - if (name in internal) return - const key = Symbol(name) - internal[name] = { type: 'service', builtin } - this.root[key] = value - this.root[Context.isolate][name] = key - } - - accessor(name: string, options: Omit) { - const internal = Context.ensureInternal.call(this.root) - internal[name] ||= { type: 'accessor', ...options } - } - - alias(name: string, aliases: string[]) { - const internal = Context.ensureInternal.call(this.root) - for (const key of aliases) { - internal[key] ||= { type: 'alias', name } - } - } - - mixin(name: string, mixins: string[] | Dict) { - const entries = Array.isArray(mixins) ? mixins.map(key => [key, key]) : Object.entries(mixins) - for (const [key, value] of entries) { - this.accessor(value, { - get() { - const service = this[name] - if (isNullable(service)) return service - const value = Reflect.get(service, key) - if (typeof value !== 'function') return value - return value.bind(service) - }, - set(value) { - return Reflect.set(this[name], key, value) - }, - }) - } - } - extend(meta = {}): this { return Object.assign(Object.create(this), meta) } diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 0782f0f..599cff4 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -2,6 +2,7 @@ import { Awaitable, defineProperty, Promisify, remove } from 'cosmokit' import { Context } from './context.ts' import { EffectScope, ForkScope, MainScope, ScopeStatus } from './scope.ts' import { symbols } from './index.ts' +import ReflectService from './reflect.ts' export function isBailed(value: any) { return value !== null && value !== false && value !== undefined @@ -40,7 +41,7 @@ export interface EventOptions { type Hook = [Context, (...args: any[]) => any, EventOptions] -export class Lifecycle { +export default class Lifecycle { isActive = false _tasks = new Set>() _hooks: Record = {} @@ -105,7 +106,7 @@ export class Lifecycle { while (ctx !== ctx.root) { if (Reflect.ownKeys(ctx).includes('scope')) { for (const key of ctx.runtime.inject) { - if (name === Context.resolveInject(ctx, key)[0]) return true + if (name === ReflectService.resolveInject(ctx, key)[0]) return true } } ctx = ctx[symbols.source] ?? Object.getPrototypeOf(ctx) diff --git a/packages/core/src/reflect.ts b/packages/core/src/reflect.ts new file mode 100644 index 0000000..589f396 --- /dev/null +++ b/packages/core/src/reflect.ts @@ -0,0 +1,180 @@ +import { defineProperty, Dict, isNullable } from 'cosmokit' +import { Context } from './context' +import { getTraceable, isObject, isUnproxyable, symbols } from './utils' + +declare module './context' { + interface Context { + get(name: K): undefined | this[K] + get(name: string): any + set(name: K, value: undefined | this[K]): () => void + set(name: string, value: any): () => void + /** @deprecated use `ctx.set()` instead */ + provide(name: string, value?: any, builtin?: boolean): void + accessor(name: string, options: Omit): void + alias(name: string, aliases: string[]): void + mixin(name: string, mixins: string[] | Dict): void + } +} + +export default class ReflectService { + static resolveInject(ctx: Context, name: string) { + let internal = ctx[symbols.internal][name] + while (internal?.type === 'alias') { + name = internal.name + internal = ctx[symbols.internal][name] + } + return [name, internal] as const + } + + static handler: ProxyHandler = { + get(target, prop, ctx: Context) { + if (typeof prop !== 'string') return Reflect.get(target, prop, ctx) + + if (Reflect.has(target, prop)) { + return getTraceable(ctx, Reflect.get(target, prop, ctx)) + } + + const checkInject = (name: string) => { + // Case 1: a normal property defined on context + if (Reflect.has(target, name)) return + // Case 2: built-in services and special properties + // - prototype: prototype detection + // - then: async function return + if (['prototype', 'then', 'registry', 'lifecycle'].includes(name)) return + // Case 3: `$` or `_` prefix + if (name[0] === '$' || name[0] === '_') return + // Case 4: access directly from root + if (!ctx.runtime.plugin) return + // Case 5: custom inject checks + if (ctx.bail(ctx, 'internal/inject', name)) return + const warning = new Error(`property ${name} is not registered, declare it as \`inject\` to suppress this warning`) + ctx.emit(ctx, 'internal/warning', warning) + } + + const [name, internal] = ReflectService.resolveInject(ctx, prop) + if (!internal) { + checkInject(name) + return Reflect.get(target, name, ctx) + } else if (internal.type === 'accessor') { + return internal.get.call(ctx) + } else { + if (!internal.builtin) checkInject(name) + return ctx.reflect.get(name) + } + }, + + set(target, prop, value, ctx: Context) { + if (typeof prop !== 'string') return Reflect.set(target, prop, value, ctx) + + const [name, internal] = ReflectService.resolveInject(ctx, prop) + if (!internal) { + // TODO + return Reflect.set(target, name, value, ctx) + } + if (internal.type === 'accessor') { + if (!internal.set) return false + return internal.set.call(ctx, value) + } else { + // ctx.emit('internal/warning', new Error(`assigning to service ${name} is not recommended, please use \`ctx.set()\` method instead`)) + ctx.reflect.set(name, value) + return true + } + }, + } + + constructor(public ctx: Context) { + defineProperty(this, symbols.tracker, { + associate: 'reflect', + property: 'ctx', + }) + + this.mixin('reflect', ['get', 'set', 'provide', 'accessor', 'mixin', 'alias']) + } + + trace(value: any) { + return getTraceable(this.ctx, value) + } + + get(name: string) { + const internal = this.ctx[symbols.internal][name] + if (internal?.type !== 'service') return + const value = this.ctx.root[this.ctx[symbols.isolate][name]] + return getTraceable(this.ctx, value) + } + + set(name: string, value: any) { + this.provide(name) + const key = this.ctx[symbols.isolate][name] + const oldValue = this.ctx.root[key] + value ??= undefined + let dispose = () => {} + if (oldValue === value) return dispose + + // check override + if (!isNullable(value) && !isNullable(oldValue)) { + throw new Error(`service ${name} has been registered`) + } + const ctx: Context = this.ctx + if (!isNullable(value)) { + dispose = ctx.effect(() => () => { + ctx.set(name, undefined) + }) + } + if (isUnproxyable(value)) { + ctx.emit(ctx, 'internal/warning', new Error(`service ${name} is an unproxyable object, which may lead to unexpected behavior`)) + } + + // setup filter for events + const self = Object.create(ctx) + self[symbols.filter] = (ctx2: Context) => { + return ctx[symbols.isolate][name] === ctx2[symbols.isolate][name] + } + + ctx.emit(self, 'internal/before-service', name, value) + ctx.root[key] = value + if (isObject(value)) { + defineProperty(value, symbols.source, ctx) + } + ctx.emit(self, 'internal/service', name, oldValue) + return dispose + } + + provide(name: string, value?: any, builtin?: boolean) { + const internal = this.ctx.root[symbols.internal] + if (name in internal) return + const key = Symbol(name) + internal[name] = { type: 'service', builtin } + this.ctx.root[key] = value + this.ctx.root[symbols.isolate][name] = key + } + + accessor(name: string, options: Omit) { + const internal = this.ctx.root[symbols.internal] + internal[name] ||= { type: 'accessor', ...options } + } + + alias(name: string, aliases: string[]) { + const internal = this.ctx.root[symbols.internal] + for (const key of aliases) { + internal[key] ||= { type: 'alias', name } + } + } + + mixin(name: string, mixins: string[] | Dict) { + const entries = Array.isArray(mixins) ? mixins.map(key => [key, key]) : Object.entries(mixins) + for (const [key, value] of entries) { + this.accessor(value, { + get() { + const service = this[name] + if (isNullable(service)) return service + const value = Reflect.get(service, key) + if (typeof value !== 'function') return value + return value.bind(service) + }, + set(value) { + return Reflect.set(this[name], key, value) + }, + }) + } + } +} diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index fe0f5b0..40f6da5 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -61,7 +61,7 @@ declare module './context.ts' { } } -export class Registry { +export default class Registry { private _counter = 0 private _internal = new Map>() protected context: Context diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index fba4cfc..614ce86 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -68,7 +68,7 @@ function isTraceable(value: any): value is {} { return isObject(value) && !isUnproxyable(value) && symbols.tracker in value } -export function getTraceable(ctx: any, value: any) { +export function getTraceable(ctx: Context, value: any) { if (isTraceable(value)) { return createTraceable(ctx, value, value[symbols.tracker]) } else { @@ -76,7 +76,7 @@ export function getTraceable(ctx: any, value: any) { } } -function createTraceable(ctx: any, value: any, tracer: Tracker) { +function createTraceable(ctx: Context, value: any, tracer: Tracker) { const proxy = new Proxy(value, { get: (target, prop, receiver) => { if (typeof prop === 'symbol') { diff --git a/packages/core/tests/associate.spec.ts b/packages/core/tests/associate.spec.ts index 3b31ef0..5f7319e 100644 --- a/packages/core/tests/associate.spec.ts +++ b/packages/core/tests/associate.spec.ts @@ -1,4 +1,4 @@ -import { Context, getTraceable, Service } from '../src' +import { Context, Service } from '../src' import { expect } from 'chai' import {} from './utils' @@ -65,14 +65,12 @@ describe('Association', () => { } class Foo extends Service { - session: any = Session.prototype - constructor(ctx: Context) { super(ctx, 'foo', true) } createSession() { - return getTraceable(this.ctx, new this.session.constructor(this.ctx)) + return this.ctx.reflect.trace(new Session(this.ctx)) } } @@ -83,10 +81,9 @@ describe('Association', () => { class Bar extends Service { constructor(ctx: Context) { super(ctx, 'bar', true) - ctx.provide('session.bar') - ctx['session.bar'] = function (this: Session) { + ctx.set('session.bar', function (this: Session) { return this - } + }) } }