diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index 29e0d0c..e168935 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -2,7 +2,7 @@ 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' +import { getTraceable, resolveConfig, symbols } from './utils.ts' export namespace Context { export type Parameterized = C & { config: T } @@ -25,8 +25,8 @@ export namespace Context { export interface Accessor { type: 'accessor' - get: (this: Context) => any - set?: (this: Context, value: any) => boolean + get: (this: Context, receiver: any) => any + set?: (this: Context, value: any, receiver: any) => boolean } export interface Alias { @@ -127,7 +127,10 @@ export class Context { } extend(meta = {}): this { - return Object.assign(Object.create(this), meta) + const source = Reflect.getOwnPropertyDescriptor(this, symbols.shadow)?.value + const self = Object.assign(Object.create(getTraceable(this, this)), meta) + if (!source || Object.hasOwn(meta, symbols.source)) return self + return Object.assign(Object.create(self), { [symbols.shadow]: source }) } isolate(name: string, label?: symbol) { diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 11ecc50..a7558cb 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -112,7 +112,7 @@ export default class Lifecycle { if (name === ReflectService.resolveInject(ctx, key)[0]) return true } } - ctx = ctx[symbols.source] ?? Object.getPrototypeOf(ctx) + ctx = Object.getPrototypeOf(ctx) } }, { global: true }), Context.static, ctx.scope) } diff --git a/packages/core/src/reflect.ts b/packages/core/src/reflect.ts index 4c88c5c..96b38fc 100644 --- a/packages/core/src/reflect.ts +++ b/packages/core/src/reflect.ts @@ -27,6 +27,22 @@ export default class ReflectService { return [name, internal] as const } + static checkInject(ctx: Context, name: string) { + ctx = ctx[symbols.shadow] ?? ctx + // Case 1: built-in services and special properties + // - prototype: prototype detection + // - then: async function return + if (['prototype', 'then', 'registry', 'lifecycle'].includes(name)) return + // Case 2: `$` or `_` prefix + if (name[0] === '$' || name[0] === '_') return + // Case 3: access directly from root + if (!ctx.runtime.plugin) return + // Case 4: 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) + } + static handler: ProxyHandler = { get(target, prop, ctx: Context) { if (typeof prop !== 'string') return Reflect.get(target, prop, ctx) @@ -35,31 +51,14 @@ export default class ReflectService { return getTraceable(ctx, Reflect.get(target, prop, ctx), true) } - 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) + ReflectService.checkInject(ctx, name) return Reflect.get(target, name, ctx) } else if (internal.type === 'accessor') { - return internal.get.call(ctx) + return internal.get.call(ctx, ctx[symbols.target]) } else { - if (!internal.builtin) checkInject(name) + if (!internal.builtin) ReflectService.checkInject(ctx, name) return ctx.reflect.get(name) } }, @@ -69,12 +68,12 @@ export default class ReflectService { const [name, internal] = ReflectService.resolveInject(ctx, prop) if (!internal) { - // TODO + // TODO warning return Reflect.set(target, name, value, ctx) } if (internal.type === 'accessor') { if (!internal.set) return false - return internal.set.call(ctx, value) + return internal.set.call(ctx, value, ctx[symbols.target]) } 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) @@ -166,15 +165,21 @@ export default class ReflectService { const getTarget = typeof source === 'string' ? (ctx: Context) => ctx[source] : () => source for (const [key, value] of entries) { this.accessor(value, { - get() { + get(receiver) { const service = getTarget(this) if (isNullable(service)) return service - const value = Reflect.get(service, key) - if (typeof value !== 'function' || typeof source !== 'string') return value - return value.bind(service) + const mixed = receiver && new Proxy(receiver, { + get: (target, prop, receiver) => { + if (prop in service) return Reflect.get(service, prop, receiver) + return Reflect.get(target, prop, receiver) + }, + }) + const value = Reflect.get(service, key, mixed) + if (typeof value !== 'function') return value + return value.bind(mixed ?? service) }, - set(value) { - return Reflect.set(getTarget(this), key, value) + set(value, receiver) { + return Reflect.set(getTarget(this), key, value, receiver) }, }) } diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 116b3f1..4491b9e 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -7,6 +7,10 @@ export interface Tracker { } export const symbols = { + // internal symbols + shadow: Symbol.for('cordis.shadow'), + target: Symbol.for('cordis.target'), + // context symbols source: Symbol.for('cordis.source') as typeof Context.source, events: Symbol.for('cordis.events') as typeof Context.events, @@ -65,20 +69,26 @@ export function isObject(value: any): value is {} { } export function getTraceable(ctx: Context, value: T, noTrap?: boolean): T { - const tracker = value?.[symbols.tracker] + if (!isObject(value)) return value + if (Object.hasOwn(value, symbols.shadow)) { + return Object.getPrototypeOf(value) + } + const tracker = value[symbols.tracker] if (!tracker) return value return createTraceable(ctx, value, tracker, noTrap) } -function createTrapMethod(ctx: Context, value: any, property: string) { +function createShadowMethod(ctx: Context, value: any, outer: any, property: string) { return new Proxy(value, { apply: (target, thisArg, args) => { - return getTraceable(ctx, Reflect.apply(target, new Proxy(thisArg, { + const isBound = thisArg === outer + + // contravariant + thisArg = new Proxy(thisArg, { get: (target, prop, receiver) => { - if (prop === property) { - // FIXME Can I use target[prop]? + if (prop === property && isBound) { const origin = Reflect.getOwnPropertyDescriptor(target, prop)?.value - return ctx.extend({ [symbols.source]: origin }) + return ctx.extend({ [symbols.shadow]: origin }) } return Reflect.get(target, prop, receiver) }, @@ -86,13 +96,28 @@ function createTrapMethod(ctx: Context, value: any, property: string) { if (prop === property) return false return Reflect.set(target, prop, value, receiver) }, - }), args)) + }) + + // contravariant + args = args.map((arg) => { + if (typeof arg !== 'function') return arg + return new Proxy(arg, { + apply: (target: Function, thisArg, args) => { + // covariant + return Reflect.apply(target, getTraceable(ctx, thisArg), args.map(arg => getTraceable(ctx, arg))) + }, + }) + }) + + // covariant + return getTraceable(ctx, Reflect.apply(target, thisArg, args)) }, }) } +// covariant function createTraceable(ctx: Context, value: any, tracker: Tracker, noTrap?: boolean) { - if (ctx[symbols.source]) { + if (ctx[symbols.shadow]) { ctx = Object.getPrototypeOf(ctx) } const proxy = new Proxy(value, { @@ -102,16 +127,21 @@ function createTraceable(ctx: Context, value: any, tracker: Tracker, noTrap?: bo return Reflect.get(target, prop, receiver) } if (tracker.associate && ctx[symbols.internal][`${tracker.associate}.${prop}`]) { - return Reflect.get(ctx, `${tracker.associate}.${prop}`) + return Reflect.get(ctx, `${tracker.associate}.${prop}`, new Proxy(ctx, { + get: (target2, prop2, receiver2) => { + if (prop2 === symbols.target) return receiver + return Reflect.get(target2, prop2, receiver2) + }, + })) } - const value = Reflect.get(target, prop, receiver) - const innerTracker = value?.[symbols.tracker] + const innerValue = Reflect.get(target, prop, receiver) + const innerTracker = innerValue?.[symbols.tracker] if (innerTracker) { - return createTraceable(ctx, value, innerTracker) - } else if (!noTrap && tracker.property && typeof value === 'function') { - return createTrapMethod(ctx, value, tracker.property) + return createTraceable(ctx, innerValue, innerTracker) + } else if (!noTrap && tracker.property && typeof innerValue === 'function') { + return createShadowMethod(ctx, innerValue, receiver, tracker.property) } else { - return value + return innerValue } }, set: (target, prop, value, receiver) => { @@ -120,7 +150,12 @@ function createTraceable(ctx: Context, value: any, tracker: Tracker, noTrap?: bo return Reflect.set(target, prop, value, receiver) } if (tracker.associate && ctx[symbols.internal][`${tracker.associate}.${prop}`]) { - return Reflect.set(ctx, `${tracker.associate}.${prop}`, value) + return Reflect.set(ctx, `${tracker.associate}.${prop}`, value, new Proxy(ctx, { + get: (target2, prop2, receiver2) => { + if (prop2 === symbols.target) return receiver + return Reflect.get(target2, prop2, receiver2) + }, + })) } return Reflect.set(target, prop, value, receiver) }, diff --git a/packages/core/tests/service.spec.ts b/packages/core/tests/service.spec.ts index 298841c..f48187c 100644 --- a/packages/core/tests/service.spec.ts +++ b/packages/core/tests/service.spec.ts @@ -150,10 +150,12 @@ describe('Service', () => { root.plugin(Foo) expect(root.foo.count()).to.equal(1) expect(root.foo.count()).to.equal(2) + expect(warning.mock.calls).to.have.length(0) const fork = root.inject(['foo'], (ctx) => { expect(ctx.foo.count()).to.equal(3) expect(ctx.foo.count()).to.equal(4) + expect(warning.mock.calls).to.have.length(0) }) fork.dispose() @@ -181,16 +183,17 @@ describe('Service', () => { root.plugin(Foo) expect(root.foo.count()).to.equal(1) expect(root.foo.count()).to.equal(2) - expect(warning.mock.calls).to.have.length(0) // access from root + expect(warning.mock.calls).to.have.length(4) const fork = root.inject(['foo'], (ctx) => { expect(ctx.foo.count()).to.equal(3) expect(ctx.foo.count()).to.equal(4) - expect(warning.mock.calls).to.have.length(4) + expect(warning.mock.calls).to.have.length(8) }) fork.dispose() expect(root.foo.count()).to.equal(3) + expect(warning.mock.calls).to.have.length(10) await checkError(root) })