Skip to content

Commit

Permalink
feat(cordis): support mixin receiver
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Jun 10, 2024
1 parent f904dc4 commit 0956cb2
Show file tree
Hide file tree
Showing 5 changed files with 97 additions and 51 deletions.
11 changes: 7 additions & 4 deletions packages/core/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, T = any> = C & { config: T }
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
61 changes: 33 additions & 28 deletions packages/core/src/reflect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Context> = {
get(target, prop, ctx: Context) {
if (typeof prop !== 'string') return Reflect.get(target, prop, ctx)
Expand All @@ -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)
}
},
Expand All @@ -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)
Expand Down Expand Up @@ -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)
},
})
}
Expand Down
67 changes: 51 additions & 16 deletions packages/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -65,34 +69,55 @@ export function isObject(value: any): value is {} {
}

export function getTraceable<T>(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)
},
set: (target, prop, value, receiver) => {
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, {
Expand All @@ -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) => {
Expand All @@ -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)
},
Expand Down
7 changes: 5 additions & 2 deletions packages/core/tests/service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
})
Expand Down

0 comments on commit 0956cb2

Please sign in to comment.