Skip to content

Commit

Permalink
feat(core): allow service using this.ctx with tracibility
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Jun 5, 2024
1 parent 4511e3d commit 0a0da4b
Show file tree
Hide file tree
Showing 8 changed files with 102 additions and 85 deletions.
1 change: 1 addition & 0 deletions packages/core/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export class Context {
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
Expand Down
28 changes: 13 additions & 15 deletions packages/core/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ export class Lifecycle {
_tasks = new Set<Promise<void>>()
_hooks: Record<keyof any, Hook[]> = {}

constructor(private root: Context) {
defineProperty(this, Context.origin, root)
constructor(private ctx: Context) {
defineProperty(this, Context.origin, ctx)

defineProperty(this.on('internal/listener', function (this: Context, name, listener, options: EventOptions) {
const method = options.prepend ? 'unshift' : 'push'
Expand All @@ -62,14 +62,14 @@ export class Lifecycle {
this.scope.runtime.forkables[method](listener as any)
return this.scope.collect('event <fork>', () => remove(this.scope.runtime.forkables, listener))
}
}), Context.static, root.scope)
}), Context.static, ctx.scope)

for (const level of ['info', 'error', 'warning']) {
defineProperty(this.on(`internal/${level}`, (format, ...param) => {
if (this._hooks[`internal/${level}`].length > 1) return
// eslint-disable-next-line no-console
console.info(format, ...param)
}), Context.static, root.scope)
}), Context.static, ctx.scope)
}

// non-reusable plugin forks are not responsive to isolated service changes
Expand All @@ -83,7 +83,7 @@ export class Lifecycle {
scope.reset()
}
}
}, { global: true }), Context.static, root.scope)
}, { global: true }), Context.static, ctx.scope)

defineProperty(this.on('internal/service', function (this: Context, name) {
for (const runtime of this.registry.values()) {
Expand All @@ -94,7 +94,7 @@ export class Lifecycle {
scope.start()
}
}
}, { global: true }), Context.static, root.scope)
}, { global: true }), Context.static, ctx.scope)

// inject in ancestor contexts
defineProperty(this.on('internal/inject', function (name) {
Expand All @@ -105,7 +105,7 @@ export class Lifecycle {
}
parent = parent.scope.parent
}
}, { global: true }), Context.static, root.scope)
}, { global: true }), Context.static, ctx.scope)
}

async flush() {
Expand All @@ -128,7 +128,7 @@ export class Lifecycle {
if (name !== 'internal/event') {
this.emit('internal/event', type, name, args, thisArg)
}
return [this.getHooks(name, thisArg), thisArg ?? this[Context.origin]] as const
return [this.getHooks(name, thisArg), thisArg ?? this.ctx] as const
}

async parallel(...args: any[]) {
Expand Down Expand Up @@ -162,10 +162,9 @@ export class Lifecycle {
}

register(label: string, hooks: Hook[], listener: any, options: EventOptions) {
const caller = this[Context.origin]
const method = options.prepend ? 'unshift' : 'push'
hooks[method]([caller, listener, options])
return caller.state.collect(label, () => this.unregister(hooks, listener))
hooks[method]([this.ctx, listener, options])
return this.ctx.state.collect(label, () => this.unregister(hooks, listener))
}

unregister(hooks: Hook[], listener: any) {
Expand All @@ -182,9 +181,8 @@ export class Lifecycle {
}

// handle special events
const caller: Context = this[Context.origin]
caller.scope.assertActive()
const result = this.bail(caller, 'internal/listener', name, listener, options)
this.ctx.scope.assertActive()
const result = this.bail(this.ctx, 'internal/listener', name, listener, options)
if (result) return result

const hooks = this._hooks[name] ||= []
Expand Down Expand Up @@ -217,7 +215,7 @@ export class Lifecycle {
async stop() {
this.isActive = false
// `dispose` event is handled by state.disposables
this.root.scope.reset()
this.ctx.scope.reset()
}
}

Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ export class Registry<C extends Context = Context> {
private _counter = 0
private _internal = new Map<Function, MainScope<C>>()

constructor(private root: Context, config: any) {
defineProperty(this, Context.origin, root)
root.scope = new MainScope(this, null!, config)
root.scope.runtime.isReactive = true
constructor(private ctx: Context, config: any) {
defineProperty(this, Context.origin, ctx)
ctx.scope = new MainScope(this, null!, config)
ctx.scope.runtime.isReactive = true
}

get counter() {
Expand Down Expand Up @@ -139,7 +139,7 @@ export class Registry<C extends Context = Context> {
// check if it's a valid plugin
this.resolve(plugin, true)

const context: Context = this[Context.origin]
const context: Context = this.ctx
context.scope.assertActive()

// resolve plugin config
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,13 @@ export abstract class Service<T = unknown, C extends Context = Context> {
}

protected [symbols.extend](props?: any) {
const caller = this[symbols.origin]
let self: any
if (this[Service.invoke]) {
self = createCallable(this.name, this)
} else {
self = Object.create(this)
}
defineProperty(self, symbols.origin, caller)
defineProperty(self, symbols.origin, this.ctx)
return Context.associate<this>(Object.assign(self, props), this.name)
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function joinPrototype(proto1: {}, proto2: {}) {
export function createTraceable(ctx: any, value: any) {
const proxy = new Proxy(value, {
get: (target, name, receiver) => {
if (name === symbols.origin) return ctx
if (name === symbols.origin || name === 'ctx') return ctx
return Reflect.get(target, name, receiver)
},
apply: (target, thisArg, args) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/tests/dispose.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe('Disposables', () => {
expect(dispose.mock.calls).to.have.length(1)
})

it('dispose event', async () => {
it('dispose event error', async () => {
const root = new Context()
const error = mock.fn()
const dispose = mock.fn(() => {
Expand Down
61 changes: 61 additions & 0 deletions packages/core/tests/invoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { expect } from 'chai'
import { Context, Service } from '../src'

describe('functional service', () => {
it('functional service', async () => {
interface Config {}

interface Foo {
(init?: Config): Config
}

class Foo extends Service<Config> {
static [Service.provide] = 'foo'
static [Service.immediate] = true

protected [Service.invoke](init?: Config) {
const caller = this[Context.origin]
expect(caller).to.be.instanceof(Context)
let result = { ...this.config }
let intercept = caller[Context.intercept]
while (intercept) {
Object.assign(result, intercept.foo)
intercept = Object.getPrototypeOf(intercept)
}
Object.assign(result, init)
return result
}

reflect() {
return this()
}

extend(config?: Config) {
return this[Service.extend]({
config: { ...this.config, ...config },
})
}
}

const root = new Context()
root.plugin(Foo, { a: 1 })

// access from context
expect(root.foo()).to.deep.equal({ a: 1 })
const ctx1 = root.intercept('foo', { b: 2 })
expect(ctx1.foo()).to.deep.equal({ a: 1, b: 2 })
const foo1 = ctx1.foo
expect(foo1).to.be.instanceof(Foo)

// create extension
const foo2 = root.foo.extend({ c: 3 })
expect(foo2).to.be.instanceof(Foo)
expect(foo2()).to.deep.equal({ a: 1, c: 3 })
const foo3 = foo1.extend({ d: 4 })
expect(foo3).to.be.instanceof(Foo)
expect(foo3.reflect()).to.deep.equal({ a: 1, b: 2, d: 4 })

// context tracibility
expect(foo1.reflect()).to.deep.equal({ a: 1, b: 2 })
})
})
80 changes: 19 additions & 61 deletions packages/core/tests/service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,19 +128,34 @@ describe('Service', () => {
expect(callback.mock.calls).to.have.length(1)
})

it('Context.origin', async () => {
it('traceable effect', async () => {
class Foo extends Service {
size = 0

constructor(ctx: Context) {
super(ctx, 'foo', true)
}

increse() {
return this.ctx.effect(() => {
this.size++
return () => this.size--
})
}
}

const root = new Context()
root.plugin(Foo)
expect(root.foo[Context.origin]).to.equal(root)
root.foo.increse()
expect(root.foo.size).to.equal(1)

const fork = root.plugin((ctx) => {
ctx.foo.increse()
expect(ctx.foo.size).to.equal(2)
})

const ctx = root.extend()
expect(ctx.foo[Context.origin]).to.equal(ctx)
fork.dispose()
expect(root.foo.size).to.equal(1)
})

it('dependency update', async () => {
Expand Down Expand Up @@ -279,61 +294,4 @@ describe('Service', () => {
expect(bar.mock.calls).to.have.length(1)
expect(qux.mock.calls).to.have.length(1)
})

it('functional service', async () => {
interface Config {}

interface Foo {
(init?: Config): Config
}

class Foo extends Service<Config> {
static [Service.provide] = 'foo'
static [Service.immediate] = true

protected [Service.invoke](init?: Config) {
const caller = this[Context.origin]
expect(caller).to.be.instanceof(Context)
let result = { ...this.config }
let intercept = caller[Context.intercept]
while (intercept) {
Object.assign(result, intercept.foo)
intercept = Object.getPrototypeOf(intercept)
}
Object.assign(result, init)
return result
}

reflect() {
return this()
}

extend(config?: Config) {
return this[Service.extend]({
config: { ...this.config, ...config },
})
}
}

const root = new Context()
root.plugin(Foo, { a: 1 })

// access from context
expect(root.foo()).to.deep.equal({ a: 1 })
const ctx1 = root.intercept('foo', { b: 2 })
expect(ctx1.foo()).to.deep.equal({ a: 1, b: 2 })
const foo1 = ctx1.foo
expect(foo1).to.be.instanceof(Foo)

// create extension
const foo2 = root.foo.extend({ c: 3 })
expect(foo2).to.be.instanceof(Foo)
expect(foo2()).to.deep.equal({ a: 1, c: 3 })
const foo3 = foo1.extend({ d: 4 })
expect(foo3).to.be.instanceof(Foo)
expect(foo3.reflect()).to.deep.equal({ a: 1, b: 2, d: 4 })

// context tracibility
expect(foo1.reflect()).to.deep.equal({ a: 1, b: 2 })
})
})

0 comments on commit 0a0da4b

Please sign in to comment.