Skip to content

Commit

Permalink
feat(core): support Context.isolate for service isolation
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Feb 22, 2024
1 parent 0041327 commit a763e1c
Show file tree
Hide file tree
Showing 11 changed files with 73 additions and 97 deletions.
12 changes: 6 additions & 6 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,8 +527,8 @@ ctx.custom // undefined
Registering multiple services will only override themselves. In order to limit the scope of a service (so that multiple services may exist at the same time), simply create an isolated scope:

```ts
const ctx1 = ctx.isolate(['foo'])
const ctx2 = ctx.isolate(['bar'])
const ctx1 = ctx.isolate('foo')
const ctx2 = ctx.isolate('bar')

ctx.foo = { value: 1 }
ctx1.foo // undefined
Expand All @@ -539,7 +539,7 @@ ctx.bar // { value: 2 }
ctx2.bar // undefined
```

`ctx.isolate()` accepts a parameter `keys` and returns a new context. Services included in `keys` will be isolated in the new context, while services not included in `keys` are still shared with the parent context.
`ctx.isolate()` accepts a parameter `key` and returns a new context. Service named `key` will be isolated in the new context, while other services are still shared with the parent context.

> Note: there is an edge case when using service isolation, service dependencies and `fork` events at the same time. Forks from a partially reusable plugin are **not** responsive to isolated service changes, because it may cause unexpected reloading across forks. If you want to write reusable plugin with service dependencies, just use `reusable` property instead of listening to `fork` event.
Expand Down Expand Up @@ -587,14 +587,14 @@ Mixins from services will still support service features such as [disposable](#w

Create a new context with the current context as the prototype. Properties specified in `meta` will be assigned to the new context.

#### ctx.isolate(keys)
#### ctx.isolate(key)

> Note: this is an experimental API and may be changed in the future.
- keys: `string[]` service names
- key: `string` service name
- returns: `Context`

Create a new context with the current context as the prototype. Services included in `keys` will be isolated in the new context, while services not included in `keys` are still shared with the parent context.
Create a new context with the current context as the prototype. Service named `key` will be isolated in the new context, while other services are still shared with the parent context.

See: [Service isolation](#service-isolation-)

Expand Down
83 changes: 33 additions & 50 deletions packages/core/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineProperty, Dict, isNullable } from 'cosmokit'
import { Lifecycle } from './events.ts'
import { Registry } from './registry.ts'
import { createTraceable, isConstructor, isUnproxyable, resolveConfig, symbols } from './utils.ts'
import { createTraceable, isUnproxyable, resolveConfig, symbols } from './utils.ts'

export namespace Context {
export type Parameterized<C, T = any> = C & { config: T }
Expand All @@ -18,7 +18,6 @@ export namespace Context {
export namespace Internal {
export interface Service {
type: 'service'
key: symbol
builtin?: boolean
prototype?: {}
}
Expand All @@ -44,11 +43,10 @@ export namespace Context {
export interface Intercept<C extends Context = Context> {}

export interface Context {
[Context.shadow]: Dict<symbol>
[Context.isolate]: Dict<symbol>
[Context.intercept]: Intercept<this>
[Context.internal]: Dict<Context.Internal>
root: this
realms: Record<string, Record<string, symbol>>
lifecycle: Lifecycle
registry: Registry<this>
config: any
Expand All @@ -60,7 +58,7 @@ export class Context {
static readonly static: unique symbol = symbols.static as any
static readonly filter: unique symbol = symbols.filter as any
static readonly expose: unique symbol = symbols.expose as any
static readonly shadow: unique symbol = symbols.shadow as any
static readonly isolate: unique symbol = symbols.isolate as any
static readonly internal: unique symbol = symbols.internal as any
static readonly intercept: unique symbol = symbols.intercept as any
/** @deprecated use `Context.trace` instead */
Expand All @@ -75,31 +73,20 @@ export class Context {
Context.prototype[Context.is as any] = true
}

private static ensureInternal(): Context[typeof Context.internal] {
private static ensureInternal(): Context[typeof symbols.internal] {
const ctx = this.prototype || this
if (Object.prototype.hasOwnProperty.call(ctx, Context.internal)) {
return ctx[Context.internal]
if (Object.prototype.hasOwnProperty.call(ctx, symbols.internal)) {
return ctx[symbols.internal]
}
const parent = Context.ensureInternal.call(Object.getPrototypeOf(this))
return ctx[Context.internal] = Object.create(parent)
}

/** @deprecated */
static service(name: string, options: string[] | Context.MixinOptions = {}) {
const internal = this.ensureInternal()
if (name in internal) return
const key = typeof name === 'symbol' ? name : Symbol(name)
internal[name] = { type: 'service', key }
if (isConstructor(options)) {
internal[name]['prototype'] = options.prototype
}
return ctx[symbols.internal] = Object.create(parent)
}

static resolveInject(ctx: Context, name: string) {
let internal = ctx[Context.internal][name]
let internal = ctx[symbols.internal][name]
while (internal?.type === 'alias') {
name = internal.name
internal = ctx[Context.internal][name]
internal = ctx[symbols.internal][name]
}
return [name, internal] as const
}
Expand Down Expand Up @@ -155,7 +142,7 @@ export class Context {
}

// service
const key = ctx[Context.shadow][name] || internal.key
const key = ctx[symbols.isolate][name]
const oldValue = ctx.root[key]
if (oldValue === value) return true

Expand All @@ -172,15 +159,15 @@ export class Context {

// setup filter for events
const self = Object.create(null)
self[Context.filter] = (ctx2: Context) => {
self[symbols.filter] = (ctx2: Context) => {
// TypeScript is not smart enough to infer the type of `name` here
return ctx[Context.shadow][name as string] === ctx2[Context.shadow][name as string]
return ctx[symbols.isolate][name as string] === ctx2[symbols.isolate][name as string]
}

ctx.root.emit(self, 'internal/before-service', name, value)
ctx.root[key] = value
if (value instanceof Object) {
defineProperty(value, Context.trace, ctx)
defineProperty(value, symbols.trace, ctx)
}
ctx.root.emit(self, 'internal/service', name, oldValue)
return true
Expand All @@ -191,14 +178,14 @@ export class Context {
return new Proxy(object, {
get(target, key, receiver) {
if (typeof key === 'symbol' || key in target) return Reflect.get(target, key, receiver)
const caller: Context = receiver[Context.trace]
if (!caller?.[Context.internal][`${name}.${key}`]) return Reflect.get(target, key, receiver)
const caller: Context = receiver[symbols.trace]
if (!caller?.[symbols.internal][`${name}.${key}`]) return Reflect.get(target, key, receiver)
return caller.get(`${name}.${key}`)
},
set(target, key, value, receiver) {
if (typeof key === 'symbol' || key in target) return Reflect.set(target, key, value, receiver)
const caller: Context = receiver[Context.trace]
if (!caller?.[Context.internal][`${name}.${key}`]) return Reflect.set(target, key, value, receiver)
const caller: Context = receiver[symbols.trace]
if (!caller?.[symbols.internal][`${name}.${key}`]) return Reflect.set(target, key, value, receiver)
caller[`${name}.${key}`] = value
return true
},
Expand All @@ -208,27 +195,26 @@ export class Context {
constructor(config?: any) {
const self: Context = new Proxy(this, Context.handler)
config = resolveConfig(this.constructor, config)
self[Context.shadow] = Object.create(null)
self[Context.intercept] = Object.create(null)
self[symbols.isolate] = Object.create(null)
self[symbols.intercept] = Object.create(null)
self.root = self
self.realms = Object.create(null)
self.mixin('scope', ['config', 'runtime', 'effect', 'collect', 'accept', 'decline'])
self.mixin('registry', ['using', 'inject', 'plugin', 'dispose'])
self.mixin('lifecycle', ['on', 'once', 'off', 'after', 'parallel', 'emit', 'serial', 'bail', 'start', 'stop'])
self.provide('registry', new Registry(self, config!), true)
self.provide('lifecycle', new Lifecycle(self), true)

const attach = (internal: Context[typeof Context.internal]) => {
const attach = (internal: Context[typeof symbols.internal]) => {
if (!internal) return
attach(Object.getPrototypeOf(internal))
for (const key of Object.getOwnPropertyNames(internal)) {
const constructor = internal[key]['prototype']?.constructor
if (!constructor) continue
self[internal[key]['key']] = new constructor(self, config)
defineProperty(self[internal[key]['key']], Context.trace, self)
defineProperty(self[internal[key]['key']], symbols.trace, self)
}
}
attach(this[Context.internal])
attach(this[symbols.internal])
return self
}

Expand Down Expand Up @@ -256,13 +242,12 @@ export class Context {
get<K extends string & keyof this>(name: K): undefined | this[K]
get(name: string): any
get(name: string) {
const internal = this[Context.internal][name]
const internal = this[symbols.internal][name]
if (internal?.type !== 'service') return
const key: symbol = this[Context.shadow][name] || internal.key
const value = this.root[key]
const value = this.root[this[symbols.isolate][name]]
if (!value || typeof value !== 'object' && typeof value !== 'function') return value
if (isUnproxyable(value)) {
defineProperty(value, Context.trace, this)
defineProperty(value, symbols.trace, this)
return value
}
return createTraceable(this, value)
Expand All @@ -272,8 +257,9 @@ export class Context {
const internal = Context.ensureInternal.call(this.root)
if (name in internal) return
const key = Symbol(name)
internal[name] = { type: 'service', key, builtin }
internal[name] = { type: 'service', builtin }
this.root[key] = value
this.root[Context.isolate][name] = key
}

accessor(name: string, options: Omit<Context.Internal.Accessor, 'type'>) {
Expand Down Expand Up @@ -309,19 +295,16 @@ export class Context {
return Object.assign(Object.create(this), meta)
}

isolate(names: string[], label?: string) {
const self = this.extend()
self[Context.shadow] = Object.create(this[Context.shadow])
for (const name of names) {
self[Context.shadow][name] = label ? ((this.realms[label] ??= Object.create(null))[name] ??= Symbol(name)) : Symbol(name)
}
return self
isolate(name: string, label?: symbol) {
const shadow = Object.create(this[symbols.isolate])
shadow[name] = label ?? Symbol(name)
return this.extend({ [symbols.isolate]: shadow })
}

intercept<K extends keyof Intercept>(name: K, config: Intercept[K]) {
const intercept = Object.create(this[Context.intercept])
const intercept = Object.create(this[symbols.intercept])
intercept[name] = config
return this.extend({ [Context.intercept]: intercept })
return this.extend({ [symbols.intercept]: intercept })
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,8 @@ export interface Events<in C extends Context = Context> {
'internal/info'(this: C, format: any, ...param: any[]): void
'internal/error'(this: C, format: any, ...param: any[]): void
'internal/warning'(this: C, format: any, ...param: any[]): void
'internal/before-service'(name: string, value: any): void
'internal/service'(name: string, oldValue: any): void
'internal/before-service'(name: string): void
'internal/service'(name: string): void
'internal/before-update'(fork: ForkScope<C>, config: any): void
'internal/update'(fork: ForkScope<C>, oldConfig: any): void
'internal/listener'(this: C, name: string, listener: any, prepend: boolean): void
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export abstract class Service<T = unknown, C extends Context = Context> {
}

protected [symbols.filter](ctx: Context) {
return ctx[symbols.shadow][this.name] === this.ctx[symbols.shadow][this.name]
return ctx[symbols.isolate][this.name] === this.ctx[symbols.isolate][this.name]
}

protected [symbols.setup]() {
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 @@ -8,7 +8,7 @@ export const symbols = {
static: Symbol.for('cordis.static') as typeof Context.static,
filter: Symbol.for('cordis.filter') as typeof Context.filter,
expose: Symbol.for('cordis.expose') as typeof Context.expose,
shadow: Symbol.for('cordis.shadow') as typeof Context.shadow,
isolate: Symbol.for('cordis.isolate') as typeof Context.isolate,
internal: Symbol.for('cordis.internal') as typeof Context.internal,
intercept: Symbol.for('cordis.intercept') as typeof Context.intercept,

Expand Down
44 changes: 16 additions & 28 deletions packages/core/tests/extend.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,25 @@ describe('Extend', () => {
s1: S1
s2: S2
s3: S3
constructor() {
super()
this.provide('s1', new S1())
}
}

class C2 extends C1 {}
class C3 extends C1 {}
class C2 extends C1 {
constructor() {
super()
this.provide('s2', new S2())
}
}

C2.service('s2', S2)
C1.service('s1', S1)
C3.service('s3', S3)
class C3 extends C1 {
constructor() {
super()
this.provide('s3', new S3())
}
}

const c1 = new C1()
expect(c1.s1).to.be.ok
Expand All @@ -36,27 +47,4 @@ describe('Extend', () => {
expect(c3.s2).to.be.undefined
expect(c3.s3).to.be.ok
})

test('service isolation', () => {
class Temp {}
class C1 extends Context {
temp: Temp
}

class C2 extends C1 {}
C2.service('temp')

const plugin = (ctx: C1) => {
ctx.temp = new Temp()
}

const c1 = new C1()
c1.plugin(plugin)
const c2 = new C2()
c2.plugin(plugin)

// `temp` is not a service of C1
expect(c1.temp).to.be.not.ok
expect(c2.temp).to.be.ok
})
})
1 change: 1 addition & 0 deletions packages/core/tests/fork.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe('Fork', () => {

test('deferred execution', () => {
const root = new Context()
root.provide('foo')
const listener = mock.fn()
const callback = mock.fn((ctx: Context) => {
ctx.on(event, listener)
Expand Down
Loading

0 comments on commit a763e1c

Please sign in to comment.