diff --git a/packages/loader/src/entry.ts b/packages/loader/src/entry.ts index 5a02090..346b022 100644 --- a/packages/loader/src/entry.ts +++ b/packages/loader/src/entry.ts @@ -1,4 +1,4 @@ -import { ForkScope } from '@cordisjs/core' +import { Context, ForkScope } from '@cordisjs/core' import { isNullable } from 'cosmokit' import { Loader } from './loader.ts' import { EntryGroup } from './group.ts' @@ -34,6 +34,7 @@ function sortKeys(object: T, prepend = ['id', 'name'], append = [' export class Entry { static readonly key = Symbol.for('cordis.entry') + public ctx: Context public fork?: ForkScope public suspend = false public parent!: EntryGroup @@ -41,7 +42,10 @@ export class Entry { public subgroup?: EntryGroup public subtree?: EntryTree - constructor(public loader: Loader) {} + constructor(public loader: Loader) { + this.ctx = loader.ctx.extend() + this.ctx.emit('loader/entry-init', this) + } get id() { let id = this.options.id @@ -67,20 +71,13 @@ export class Entry { return !this.parent.ctx.bail('loader/entry-check', this) } - createContext() { - const ctx = this.parent.ctx.extend() - ctx.emit('loader/context-init', this, ctx) - return ctx - } - patch(options: Partial = {}) { // step 1: prepare isolate map - const ctx = this.fork?.parent ?? this.createContext() const meta = {} as EntryUpdateMeta - ctx.emit(meta, 'loader/before-patch', this, ctx) + this.ctx.emit(meta, 'loader/before-patch', this) // step 1: set prototype for transferred context - Object.setPrototypeOf(ctx, this.parent.ctx) + Object.setPrototypeOf(this.ctx, this.parent.ctx) if (this.fork && 'config' in options) { // step 2: update fork (when options.config is updated) @@ -96,8 +93,8 @@ export class Entry { } } - ctx.emit(meta, 'loader/after-patch', this, ctx) - return ctx + this.ctx.emit(meta, 'loader/after-patch', this) + return this.ctx } async refresh() { @@ -144,10 +141,10 @@ export class Entry { }) if (!exports) return const plugin = this.loader.unwrapExports(exports) - const ctx = this.patch() - ctx[Entry.key] = this - this.fork = ctx.plugin(plugin, this.options.config) - ctx.emit('loader/entry-fork', this, 'apply') + this.patch() + this.ctx[Entry.key] = this + this.fork = this.ctx.plugin(plugin, this.options.config) + this.ctx.emit('loader/entry-fork', this, 'apply') } async stop() { diff --git a/packages/loader/src/inject.ts b/packages/loader/src/inject.ts index 2133c41..f40e608 100644 --- a/packages/loader/src/inject.ts +++ b/packages/loader/src/inject.ts @@ -1,189 +1,13 @@ import { Context, EffectScope, Inject } from '@cordisjs/core' -import { Dict, isNullable } from 'cosmokit' import { Entry } from './entry.ts' declare module './entry.ts' { - interface EntryUpdateMeta { - newMap: Dict - diff: [string, symbol, symbol, symbol, symbol][] - } - interface EntryOptions { - intercept?: Dict | null - isolate?: Dict | null inject?: string[] | Inject | null } - - interface Entry { - realm: LocalRealm - } -} - -function swap(target: T, source?: T | null) { - for (const key of Reflect.ownKeys(target)) { - Reflect.deleteProperty(target, key) - } - for (const key of Reflect.ownKeys(source || {})) { - Reflect.defineProperty(target, key, Reflect.getOwnPropertyDescriptor(source!, key)!) - } -} - -export abstract class Realm { - protected store: Dict = Object.create(null) - - abstract get suffix(): string - - access(key: string, create = false) { - if (create) { - return this.store[key] ??= Symbol(`${key}${this.suffix}`) - } else { - return this.store[key] ?? Symbol(`${key}${this.suffix}`) - } - } - - delete(key: string) { - delete this.store[key] - } - - get size() { - return Object.keys(this.store).length - } -} - -export class LocalRealm extends Realm { - constructor(private entry: Entry) { - super() - } - - get suffix() { - return '#' + this.entry.options.id - } -} - -export class GlobalRealm extends Realm { - constructor(public label: string) { - super() - } - - get suffix() { - return '@' + this.label - } } export function apply(ctx: Context) { - const realms: Dict = Object.create(null) - - function access(entry: Entry, key: string, create: true): symbol - function access(entry: Entry, key: string, create?: boolean): symbol | undefined - function access(entry: Entry, key: string, create = false) { - let realm: Realm | undefined - const label = entry.options.isolate?.[key] - if (!label) return - if (label === true) { - realm = entry.realm ??= new LocalRealm(entry) - } else if (create) { - realm = realms[label] ??= new GlobalRealm(label) - } else { - realm = realms[label] - } - return realm?.access(key, create) - } - - ctx.on('loader/context-init', (entry, ctx) => { - ctx[Context.intercept] = Object.create(entry.parent.ctx[Context.intercept]) - ctx[Context.isolate] = Object.create(entry.parent.ctx[Context.isolate]) - }) - - ctx.on('loader/before-patch', function (entry, ctx) { - // step 1: generate new isolate map - this.newMap = Object.create(entry.parent.ctx[Context.isolate]) - for (const key of Object.keys(entry.options.isolate ?? {})) { - this.newMap[key] = access(entry, key, true) - } - - // step 2: generate service diff - this.diff = [] - const oldMap = ctx[Context.isolate] - for (const key in { ...this.newMap, ...entry.loader.delims }) { - if (this.newMap[key] === oldMap[key]) continue - const delim = entry.loader.delims[key] ??= Symbol(`delim:${key}`) - ctx[delim] = Symbol(`${key}#${entry.id}`) - for (const symbol of [oldMap[key], this.newMap[key]]) { - const value = symbol && ctx[symbol] - if (!(value instanceof Object)) continue - const source = Reflect.getOwnPropertyDescriptor(value, Context.origin)?.value - if (!source) { - ctx.emit('internal/warning', new Error(`expected service ${key} to be implemented`)) - continue - } - this.diff.push([key, oldMap[key], this.newMap[key], ctx[delim], source[delim]]) - if (ctx[delim] !== source[delim]) break - } - } - - // step 3: emit internal/before-service - for (const [key, symbol1, symbol2, flag1, flag2] of this.diff) { - const self = Object.create(ctx) - self[Context.filter] = (target: Context) => { - if (![symbol1, symbol2].includes(target[Context.isolate][key])) return false - return (flag1 === target[entry.loader.delims[key]]) !== (flag1 === flag2) - } - ctx.emit(self, 'internal/before-service', key) - } - - // step 4: set prototype for transferred context - Object.setPrototypeOf(ctx[Context.isolate], entry.parent.ctx[Context.isolate]) - Object.setPrototypeOf(ctx[Context.intercept], entry.parent.ctx[Context.intercept]) - swap(ctx[Context.isolate], this.newMap) - swap(ctx[Context.intercept], entry.options.intercept) - }) - - ctx.on('loader/after-patch', function (entry, ctx) { - // step 5: replace service impl - for (const [, symbol1, symbol2, flag1, flag2] of this.diff) { - if (flag1 === flag2 && ctx[symbol1] && !ctx[symbol2]) { - ctx.root[symbol2] = ctx.root[symbol1] - delete ctx.root[symbol1] - } - } - - // step 6: emit internal/service - for (const [key, symbol1, symbol2, flag1, flag2] of this.diff) { - const self = Object.create(ctx) - self[Context.filter] = (target: Context) => { - if (![symbol1, symbol2].includes(target[Context.isolate][key])) return false - return (flag1 === target[entry.loader.delims[key]]) !== (flag1 === flag2) - } - ctx.emit(self, 'internal/service', key) - } - - // step 7: clean up delimiters - for (const key in entry.loader.delims) { - if (!Reflect.ownKeys(this.newMap).includes(key)) { - delete ctx[entry.loader.delims[key]] - } - } - }) - - ctx.on('loader/partial-dispose', (entry, legacy, active) => { - for (const [key, label] of Object.entries(legacy.isolate ?? {})) { - if (label === true) continue - if (active && entry.options.isolate?.[key] === label) continue - const realm = realms[label] - if (!realm) continue - - // realm garbage collection - for (const entry of ctx.loader.entries()) { - // has reference to this realm - if (entry.options.isolate?.[key] === realm.label) return - } - realm.delete(key) - if (!realm.size) { - delete realms[realm.label] - } - } - }) - function getRequired(entry?: Entry) { return Array.isArray(entry?.options.inject) ? entry.options.inject @@ -214,23 +38,20 @@ export function apply(ctx: Context) { ctx.on('loader/entry-check', (entry) => { for (const name of getRequired(entry)) { - let key: symbol | undefined = entry.parent.ctx[Context.isolate][name] - const label = entry.options.isolate?.[name] - if (label) key = access(entry, name) - if (!key || isNullable(entry.parent.ctx[key])) return true + if (!entry.ctx.get(name)) return true } }) ctx.on('internal/before-service', (name) => { for (const entry of ctx.loader.entries()) { - if (!getRequired(entry).includes(name)) return + if (!getRequired(entry).includes(name)) continue entry.refresh() } }, { global: true }) ctx.on('internal/service', (name) => { for (const entry of ctx.loader.entries()) { - if (!getRequired(entry).includes(name)) return + if (!getRequired(entry).includes(name)) continue entry.refresh() } }, { global: true }) diff --git a/packages/loader/src/isolate.ts b/packages/loader/src/isolate.ts new file mode 100644 index 0000000..fcc3a9c --- /dev/null +++ b/packages/loader/src/isolate.ts @@ -0,0 +1,185 @@ +import { Context } from '@cordisjs/core' +import { Dict } from 'cosmokit' +import { Entry } from './entry.ts' + +declare module './entry.ts' { + interface EntryUpdateMeta { + newMap: Dict + diff: [string, symbol, symbol, symbol, symbol][] + } + + interface EntryOptions { + intercept?: Dict | null + isolate?: Dict | null + } + + interface Entry { + realm: LocalRealm + } +} + +function swap(target: T, source?: T | null) { + for (const key of Reflect.ownKeys(target)) { + Reflect.deleteProperty(target, key) + } + for (const key of Reflect.ownKeys(source || {})) { + Reflect.defineProperty(target, key, Reflect.getOwnPropertyDescriptor(source!, key)!) + } +} + +export abstract class Realm { + protected store: Dict = Object.create(null) + + abstract get suffix(): string + + access(key: string, create = false) { + if (create) { + return this.store[key] ??= Symbol(`${key}${this.suffix}`) + } else { + return this.store[key] ?? Symbol(`${key}${this.suffix}`) + } + } + + delete(key: string) { + delete this.store[key] + } + + get size() { + return Object.keys(this.store).length + } +} + +export class LocalRealm extends Realm { + constructor(private entry: Entry) { + super() + } + + get suffix() { + return '#' + this.entry.options.id + } +} + +export class GlobalRealm extends Realm { + constructor(public label: string) { + super() + } + + get suffix() { + return '@' + this.label + } +} + +export function apply(ctx: Context) { + const realms: Dict = Object.create(null) + + function access(entry: Entry, key: string, create: true): symbol + function access(entry: Entry, key: string, create?: boolean): symbol | undefined + function access(entry: Entry, key: string, create = false) { + let realm: Realm | undefined + const label = entry.options.isolate?.[key] + if (!label) return + if (label === true) { + realm = entry.realm ??= new LocalRealm(entry) + } else if (create) { + realm = realms[label] ??= new GlobalRealm(label) + } else { + realm = realms[label] + } + return realm?.access(key, create) + } + + ctx.on('loader/entry-init', (entry) => { + entry.ctx[Context.intercept] = Object.create(entry.ctx[Context.intercept]) + entry.ctx[Context.isolate] = Object.create(entry.ctx[Context.isolate]) + }) + + ctx.on('loader/before-patch', function (entry) { + // step 1: generate new isolate map + this.newMap = Object.create(entry.parent.ctx[Context.isolate]) + for (const key of Object.keys(entry.options.isolate ?? {})) { + this.newMap[key] = access(entry, key, true) + } + + // step 2: generate service diff + this.diff = [] + const oldMap = entry.ctx[Context.isolate] + for (const key in { ...this.newMap, ...entry.loader.delims }) { + if (this.newMap[key] === oldMap[key]) continue + const delim = entry.loader.delims[key] ??= Symbol(`delim:${key}`) + entry.ctx[delim] = Symbol(`${key}#${entry.id}`) + for (const symbol of [oldMap[key], this.newMap[key]]) { + const value = symbol && entry.ctx[symbol] + if (!(value instanceof Object)) continue + const source = Reflect.getOwnPropertyDescriptor(value, Context.origin)?.value + if (!source) { + entry.ctx.emit('internal/warning', new Error(`expected service ${key} to be implemented`)) + continue + } + this.diff.push([key, oldMap[key], this.newMap[key], entry.ctx[delim], source[delim]]) + if (entry.ctx[delim] !== source[delim]) break + } + } + + // step 3: emit internal/before-service + for (const [key, symbol1, symbol2, flag1, flag2] of this.diff) { + const self = Object.create(entry.ctx) + self[Context.filter] = (target: Context) => { + if (![symbol1, symbol2].includes(target[Context.isolate][key])) return false + return (flag1 === target[entry.loader.delims[key]]) !== (flag1 === flag2) + } + entry.ctx.emit(self, 'internal/before-service', key) + } + + // step 4: set prototype for transferred context + Object.setPrototypeOf(entry.ctx[Context.isolate], entry.parent.ctx[Context.isolate]) + Object.setPrototypeOf(entry.ctx[Context.intercept], entry.parent.ctx[Context.intercept]) + swap(entry.ctx[Context.isolate], this.newMap) + swap(entry.ctx[Context.intercept], entry.options.intercept) + }) + + ctx.on('loader/after-patch', function (entry) { + // step 5: replace service impl + for (const [, symbol1, symbol2, flag1, flag2] of this.diff) { + if (flag1 === flag2 && entry.ctx[symbol1] && !entry.ctx[symbol2]) { + entry.ctx.root[symbol2] = entry.ctx.root[symbol1] + delete entry.ctx.root[symbol1] + } + } + + // step 6: emit internal/service + for (const [key, symbol1, symbol2, flag1, flag2] of this.diff) { + const self = Object.create(entry.ctx) + self[Context.filter] = (target: Context) => { + if (![symbol1, symbol2].includes(target[Context.isolate][key])) return false + return (flag1 === target[entry.loader.delims[key]]) !== (flag1 === flag2) + } + entry.ctx.emit(self, 'internal/service', key) + } + + // step 7: clean up delimiters + for (const key in entry.loader.delims) { + if (!Reflect.ownKeys(this.newMap).includes(key)) { + delete entry.ctx[entry.loader.delims[key]] + } + } + }) + + ctx.on('loader/partial-dispose', (entry, legacy, active) => { + for (const [key, label] of Object.entries(legacy.isolate ?? {})) { + if (label === true) continue + if (active && entry.options.isolate?.[key] === label) continue + const realm = realms[label] + if (!realm) continue + + // realm garbage collection + for (const entry of ctx.loader.entries()) { + // has reference to this realm + if (entry.options.isolate?.[key] === realm.label) return + } + realm.delete(key) + if (!realm.size) { + delete realms[realm.label] + } + } + }) +} diff --git a/packages/loader/src/loader.ts b/packages/loader/src/loader.ts index c5ed088..732d350 100644 --- a/packages/loader/src/loader.ts +++ b/packages/loader/src/loader.ts @@ -4,6 +4,7 @@ import { ModuleLoader } from './internal.ts' import { Entry, EntryOptions, EntryUpdateMeta } from './entry.ts' import { ImportTree, LoaderFile } from './file.ts' import * as inject from './inject.ts' +import * as isolate from './isolate.ts' export * from './entry.ts' export * from './file.ts' @@ -14,17 +15,17 @@ declare module '@cordisjs/core' { interface Events { 'exit'(signal: NodeJS.Signals): Promise 'loader/config-update'(): void + 'loader/entry-init'(entry: Entry): void 'loader/entry-fork'(entry: Entry, type: string): void 'loader/entry-check'(entry: Entry): boolean | undefined 'loader/partial-dispose'(entry: Entry, legacy: Partial, active: boolean): void - 'loader/context-init'(entry: Entry, ctx: Context): void - 'loader/before-patch'(this: EntryUpdateMeta, entry: Entry, ctx: Context): void - 'loader/after-patch'(this: EntryUpdateMeta, entry: Entry, ctx: Context): void + 'loader/before-patch'(this: EntryUpdateMeta, entry: Entry): void + 'loader/after-patch'(this: EntryUpdateMeta, entry: Entry): void } interface Context { baseDir: string - loader: Loader + loader: Loader } interface EnvData { @@ -46,12 +47,14 @@ export namespace Loader { } } -export abstract class Loader extends ImportTree { +export abstract class Loader extends ImportTree { // TODO auto inject optional when provided? static inject = { optional: ['loader'], } + private [Context.current]!: C + // process public envData = process.env.CORDIS_SHARED ? JSON.parse(process.env.CORDIS_SHARED) @@ -65,7 +68,7 @@ export abstract class Loader extends ImportTree { public delims: Dict = Object.create(null) public internal?: ModuleLoader - constructor(public ctx: Context, public config: Loader.Config) { + constructor(public ctx: C, public config: Loader.Config) { super(ctx) ctx.set('loader', this) @@ -116,6 +119,7 @@ export abstract class Loader extends ImportTree { }) ctx.plugin(inject) + ctx.plugin(isolate) } async start() { @@ -127,7 +131,7 @@ export abstract class Loader extends ImportTree { return this._locate(ctx.scope).map(entry => entry.id) } - _locate(scope: EffectScope): Entry[] { + _locate(scope: EffectScope): Entry[] { // root scope if (!scope.runtime.plugin) return []