1
import { defineProperty, isNullable } from '@deepseek-ai/cosmokit'2
import type { Dict } from '@deepseek-ai/cosmokit'3
import { Context } from './context.ts'4
import { getTraceable, symbols, withProps } from './utils.ts'5
import { Fiber, FiberState } from './fiber.ts'7
declare module './context.ts' {8
interface Context {9
/**10
* Read a service from the store without the inject requirement.11
*12
* @param name — the service name.13
* @param strict — when `true` (default), only return implementations14
* whose providing fiber is currently active.15
* @returns the service value, or `undefined` when not (yet) provided.16
*/17
get<K extends string & keyof this>(name: K, strict?: boolean): undefined | this[K]18
/** Same as above for service names outside the typed `Context` surface. */19
get(name: string, strict?: boolean): any20
/**21
* Overwrite a provided service's value.22
*23
* Only the fiber that provided the service may set it; setting an24
* unprovided name throws.25
*26
* @param name — the service name.27
* @param value — the new service value.28
*/29
set<K extends string & keyof this>(name: K, value: undefined | this[K]): void30
/** Same as above for service names outside the typed `Context` surface. */31
set(name: string, value: any): void32
/**33
* Register a service implementation owned by the current fiber.34
*35
* The service becomes visible to dependents in the same isolation scope36
* once the fiber is active; it is unregistered (waking dependents) when37
* the returned disposer runs or the fiber unloads. Throws if the name is38
* already provided in this scope or declared as an accessor.39
*40
* @param name — the service name.41
* @param value — the service value.42
* @returns a disposer that unregisters the service.43
*/44
provide<K extends string & keyof this>(name: K, value: undefined | this[K]): () => void45
/** Same as above for service names outside the typed `Context` surface. */46
provide(name: string, value?: any): () => void47
/**48
* Define a computed context property backed by get/set hooks.49
*50
* The accessor is removed when the current fiber unloads. Throws if the51
* name is already declared.52
*53
* @param name — the context property name.54
* @param options — the `get` hook and optional `set` hook.55
*/56
accessor(name: string, options: Omit<Property.Accessor, 'type'>): void57
/**58
* Expose selected members of a service directly on `ctx`.59
*60
* Each mixed-in key becomes an accessor that forwards to the service61
* (binding methods to it), so e.g. `ctx.on` forwards to `ctx.events.on`.62
* Mixins are removed when the current fiber unloads.63
*64
* @param name — the context property holding the source service.65
* @param mixins — keys to forward, or a source-key → ctx-key map.66
*/67
mixin<K extends string & keyof this>(name: K, mixins: (keyof this & keyof this[K])[] | Dict<string>): void68
/** Same as above with a source object instead of a context property name. */69
mixin<T extends {}>(source: T, mixins: (keyof this & keyof T)[] | Dict<string>): void70
}71
}73
function enhanceError(error: Error) {74
const lines = error.stack!.split('\n')75
lines.splice(0, 2, `Error: ${error.message}`)76
error.stack = lines.join('\n')77
return error78
}80
const RESERVED_WORDS = ['prototype', 'then']82
// - is a symbol83
// - is a reserved word (prototype, then)84
// - is a number string (0, 1, 2, ...)85
// - starts with `_`86
function isSpecialProperty(prop: string | symbol): prop is symbol {87
return typeof prop === 'symbol'88
|| RESERVED_WORDS.includes(prop)89
|| parseInt(prop).toString() === prop90
|| prop.startsWith('_')91
}93
/** Context property definition known by the reflection service. */94
export type Property = Property.Service | Property.Accessor96
/** Property definition variants understood by `ReflectService`. */97
export namespace Property {98
/** Service property backed by a provided implementation. */99
export interface Service {100
/** Discriminator. */101
type: 'service'102
}104
/** Computed context property backed by custom get/set hooks. */105
export interface Accessor {106
/** Discriminator. */107
type: 'accessor'108
/** Compute the property value; `error` carries the caller stack for diagnostics. */109
get: (this: Context, receiver: any, error: Error) => any110
/** Optional setter; return `false` to reject the write. */111
set?: (this: Context, value: any, receiver: any, error: Error) => boolean112
}113
}115
/** Concrete service implementation record stored in the root reflect service. */116
export interface Impl {117
/** The service name. */118
name: string119
/** The fiber that provided the service (owns its lifetime). */120
fiber: Fiber121
/** The current service value. */122
value?: any123
/** Optional availability predicate consulted before dependents may load. */124
check?: () => boolean125
}127
/**128
* Reflection and service-resolution layer installed as `ctx.reflect`.129
*130
* This service powers the context proxy, service registration, accessors, and131
* the mixins that expose core service methods directly on `ctx`.132
*/133
export class ReflectService {134
/** Proxy traps implementing service resolution for every context object. */135
static handler: ProxyHandler<Context> = {136
get: (target, prop, ctx: Context) => {137
if (isSpecialProperty(prop)) {138
return Reflect.get(target, prop, ctx)139
}140
if (Reflect.has(target, prop)) {141
return getTraceable(ctx, Reflect.get(target, prop, ctx))142
}144
const error = new Error(`cannot get property "${prop}" without inject`)146
try {147
const def = target.reflect.props[prop]148
if (def?.type === 'accessor') {149
return def.get.call(ctx, ctx[symbols.receiver], error)150
}152
if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false)153
return ctx.events.waterfall('internal/get', ctx, prop, error, () => {154
const key = target[symbols.isolate][prop]155
let fiber = (ctx[symbols.shadow] as Context ?? ctx).fiber156
while (true) {157
const impl = fiber.store?.[prop]158
if (impl) return getTraceable(ctx, impl.value)159
if (prop in fiber.inject) {160
error.message = `cannot get required service "${prop}" in inactive context`161
throw error162
}163
if (!fiber.runtime) throw error164
if (fiber.parent[symbols.isolate][prop] !== key) throw error165
fiber = fiber.parent.fiber166
}167
})168
} catch (e: any) {169
throw e === error ? enhanceError(e) : e170
}171
},173
set: (target, prop, value, ctx: Context) => {174
if (isSpecialProperty(prop)) {175
return Reflect.set(target, prop, value, ctx)176
}178
const error = new Error(`cannot set property "${prop}" without provide`)179
const def = target.reflect.props[prop]180
if (!def) {181
if (!ctx.fiber.runtime) return Reflect.set(target, prop, value, ctx)182
throw enhanceError(error)183
}185
try {186
if (def.type === 'accessor') {187
if (!def.set) return false188
return def.set.call(ctx, value, ctx[symbols.receiver], error)189
}191
return ctx.events.waterfall('internal/set', ctx, prop, value, error, () => {192
return ctx.reflect.set(prop, value, error)193
})194
} catch (e: any) {195
throw e === error ? enhanceError(e) : e196
}197
},199
has: (target, prop) => {200
if (isSpecialProperty(prop)) {201
return Reflect.has(target, prop)202
}203
if (Reflect.has(target, prop)) return true204
return !!target.reflect.props[prop]205
},206
}208
/** Service implementations, keyed by isolation label. */209
public store: Dict<Impl, symbol> = Object.create(null)210
/** Declared context properties (services and accessors), by name. */211
public props: Dict<Property> = Object.create(null)213
constructor(public ctx: Context) {214
defineProperty(this, symbols.tracker, {215
property: 'ctx',216
noShadow: true,217
})219
this.mixin('reflect', ['get', 'set', 'provide', 'accessor', 'mixin'])220
this.mixin('fiber', ['runtime', 'effect'])221
this.mixin('registry', ['inject', 'plugin'])222
this.mixin('events', ['on', 'once', 'parallel', 'emit', 'serial', 'bail', 'waterfall'])223
}225
/**226
* Read a service from the store without the inject requirement.227
*228
* @param name — the service name.229
* @param strict — when `true`, only return implementations whose providing230
* fiber is currently active.231
* @returns the service value, or `undefined` when not (yet) provided.232
*/233
get(name: string, strict = true) {234
return getTraceable(this.ctx, this._getImpl(name, strict)?.value)235
}237
_getImpl(name: string, strict = true) {238
const key = this.ctx[symbols.isolate][name]239
const impl = key && this.store[key]240
if (!impl) return241
if (strict && impl.fiber.state !== FiberState.ACTIVE) return242
return impl243
}245
/**246
* Overwrite a provided service's value.247
*248
* @param name — the service name.249
* @param value — the new service value.250
* @param error — carrier for the caller stack in diagnostics.251
* @returns `true` on success.252
* @throws when `name` was never provided, or was provided by another fiber.253
*/254
set(name: string, value: any, error?: Error) {255
const key = this.ctx[symbols.isolate][name]256
const impl = this.store[key]257
if (!impl) {258
throw new Error(`cannot set property "${name}" without provide`)259
}260
if (impl.fiber !== this.ctx.fiber) {261
throw new Error(`cannot set property "${name}" in multiple fibers`)262
}263
impl.value = value264
return true265
}267
/**268
* Register a service implementation owned by the current fiber.269
*270
* See the `ctx.provide()` overload above for the full contract.271
*272
* @param name — the service name.273
* @param value — the service value.274
* @param check — optional availability predicate for dependents.275
* @returns a disposer that unregisters the service.276
*/277
provide(name: string, value?: any, check?: () => boolean) {278
return this.ctx.fiber.effect(() => {279
if (!this.props[name]) {280
this.props[name] ??= { type: 'service' }281
} else if (this.props[name].type !== 'service') {282
throw new Error(`property "${name}" is already declared as ${this.props[name].type}`)283
}284
this.props[name] = { type: 'service' }286
this.ctx.root[symbols.isolate][name] ??= Symbol(name)287
const key = this.ctx[symbols.isolate][name]288
const impl: Impl = { name, value, fiber: this.ctx.fiber, check }289
if (this.store[key]) {290
throw new Error(`service "${name}" has been registered at <${this.store[key].fiber.name}>`)291
}292
this.store[key] = impl293
this.ctx.fiber.store![name] = impl294
if (this.ctx.fiber.state === FiberState.ACTIVE) {295
this.notify([name])296
}297
return async () => {298
delete this.store[key]299
const fibers = this.notify([name])300
await Promise.allSettled(fibers.map(fiber => fiber.await()))301
// ensure self access before dependencies cleanup302
delete this.ctx.fiber.store![name]303
}304
}, `ctx.provide(${JSON.stringify(name)})`)305
}307
/**308
* Re-evaluate every fiber that requires one of the given services.309
*310
* @param names — the service names that changed.311
* @param filter — restricts notification to matching isolation scopes.312
* @returns the fibers whose dependency state was refreshed.313
*/314
notify(names: string[], filter = (ctx: Context, name: string) => ctx[symbols.isolate][name] === this.ctx[symbols.isolate][name]) {315
const fibers: Fiber[] = []316
for (const runtime of this.ctx.registry.values()) {317
for (const fiber of runtime.fibers) {318
let hasUpdate = false319
for (const name of names) {320
if (!(name in fiber.inject)) continue321
if (!filter(fiber.ctx, name)) continue322
hasUpdate = true323
fiber._checkImpl(name)324
}325
if (!hasUpdate) continue326
fiber._refresh()327
fibers.push(fiber)328
}329
}330
for (const name of names) {331
const self: Context = Object.create(this.ctx)332
self[symbols.filter] = (target: Context) => filter(target, name)333
this.ctx.events.emit(self, 'internal/service', name, this._getImpl(name, false)?.value)334
}335
return fibers336
}338
/**339
* Define a computed context property backed by get/set hooks.340
*341
* @param name — the context property name.342
* @param options — the `get` hook and optional `set` hook.343
* @returns a disposer that removes the accessor.344
*/345
accessor(name: string, options: Omit<Property.Accessor, 'type'>) {346
return this.ctx.fiber.effect(() => {347
if (name in this.props) {348
throw new Error(`property "${name}" is already declared as ${this.props[name].type}`)349
}350
this.props[name] = { type: 'accessor', ...options }351
return () => delete this.props[name]352
}, `ctx.accessor(${JSON.stringify(name)})`)353
}355
/**356
* Expose selected members of a service directly on `ctx`.357
*358
* See the `ctx.mixin()` overload above for the full contract.359
*360
* @param source — a context property name or a source object.361
* @param mixins — keys to forward, or a source-key → ctx-key map.362
* @returns a disposer that removes all created accessors.363
*/364
mixin(source: any, mixins: string[] | Dict<string>) {365
const self = this366
return this.ctx.fiber.effect(function* () {367
const entries = Array.isArray(mixins) ? mixins.map(key => [key, key]) : Object.entries(mixins)368
const getTarget = (ctx: Context, error: Error) => {369
// TODO enhance error message370
return ctx[source]371
}372
for (const [key, value] of entries) {373
yield self.accessor(value, {374
get(receiver, error) {375
const service = getTarget(this, error)376
if (isNullable(service)) return service377
const mixin = receiver ? withProps(receiver, service) : service378
const value = Reflect.get(service, key, mixin)379
if (typeof value !== 'function') return value380
return value.bind(mixin ?? service)381
},382
set(value, receiver, error) {383
const service = getTarget(this, error)384
const mixin = receiver ? withProps(receiver, service) : service385
return Reflect.set(service, key, value, mixin)386
},387
})388
}389
}, `ctx.mixin(${JSON.stringify(source)})`)390
}392
/**393
* Attach this context's tracing wrapper to a value.394
*395
* @param value — the value to wrap.396
* @returns the traceable wrapper (or the value itself when not applicable).397
*/398
trace<T>(value: T) {399
return getTraceable(this.ctx, value)400
}402
/**403
* Wrap a callback so calls trace `this` and arguments to this context.404
*405
* @param callback — the function to wrap.406
* @returns a proxy delegating to `callback` with traced values.407
*/408
bind<T extends Function>(callback: T) {409
return new Proxy(callback, {410
apply: (target, thisArg, args) => {411
return Reflect.apply(target, this.trace(thisArg), args.map(arg => this.trace(arg)))412
},413
construct: (target, args, newTarget) => {414
return Reflect.construct(target, args.map(arg => this.trace(arg)), newTarget)415
},416
})417
}418
}