Skip to content

Commit

Permalink
feat(loader): refa entry options, decouple service logic
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed May 31, 2024
1 parent f970d3c commit c9c6b3a
Show file tree
Hide file tree
Showing 8 changed files with 316 additions and 263 deletions.
2 changes: 1 addition & 1 deletion packages/cordis/src/worker/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function apply(ctx: Context, config: Config = {}) {
new Logger('app').warn(error)
})

ctx.on('loader/entry', (type, entry) => {
ctx.on('loader/entry-fork', (entry, type) => {
if (entry.options.group) return
ctx.logger('loader').info('%s plugin %c', type, entry.options.name)
})
Expand Down
236 changes: 35 additions & 201 deletions packages/loader/src/entry.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,18 @@
import { Context, ForkScope, Inject } from '@cordisjs/core'
import { Dict, isNullable } from 'cosmokit'
import { ForkScope } from '@cordisjs/core'
import { isNullable } from 'cosmokit'
import { Loader } from './loader.ts'
import { EntryGroup } from './group.ts'
import { EntryTree } from './tree.ts'

export namespace Entry {
export interface Options {
id: string
name: string
config?: any
group?: boolean | null
disabled?: boolean | null
intercept?: Dict | null
isolate?: Dict<true | string> | null
inject?: string[] | Inject | null
}
export interface EntryOptions {
id: string
name: string
config?: any
group?: boolean | null
disabled?: boolean | null
}

function swap<T extends {}>(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 interface EntryUpdateMeta {}

function takeEntries(object: {}, keys: string[]) {
const result: [string, any][] = []
Expand All @@ -43,66 +31,15 @@ function sortKeys<T extends {}>(object: T, prepend = ['id', 'name'], append = ['
return Object.assign(object, Object.fromEntries([...part1, ...rest, ...part2]))
}

export abstract class Realm {
protected store: Dict<symbol> = 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]
}
}

export class LocalRealm extends Realm {
constructor(private entry: Entry) {
super()
}

get suffix() {
return '#' + this.entry.options.id
}
}

export class GlobalRealm extends Realm {
constructor(private loader: Loader, private label: string) {
super()
}

get suffix() {
return '@' + this.label
}

gc(key: string) {
// realm garbage collection
for (const entry of this.loader.entries()) {
// has reference to this realm
if (entry.options.isolate?.[key] === this.label) return
}
this.delete(key)
if (!Object.keys(this.store).length) {
delete this.loader.realms[this.suffix]
}
}
}

export class Entry {
static readonly key = Symbol.for('cordis.entry')

public fork?: ForkScope
public suspend = false
public parent!: EntryGroup
public options!: Entry.Options
public options!: EntryOptions
public subgroup?: EntryGroup
public subtree?: EntryTree
public realm = new LocalRealm(this)

constructor(public loader: Loader) {}

Expand All @@ -114,21 +51,6 @@ export class Entry {
return id
}

get requiredDeps() {
return Array.isArray(this.options.inject)
? this.options.inject
: this.options.inject?.required ?? []
}

get deps() {
return Array.isArray(this.options.inject)
? this.options.inject
: [
...this.options.inject?.required ?? [],
...this.options.inject?.optional ?? [],
]
}

get disabled() {
// group is always enabled
if (this.options.group) return false
Expand All @@ -142,93 +64,30 @@ export class Entry {

_check() {
if (this.disabled) return false
for (const name of this.requiredDeps) {
let key: symbol | undefined = this.parent.ctx[Context.isolate][name]
const label = this.options.isolate?.[name]
if (label) key = this.access(name, label)
if (!key || isNullable(this.parent.ctx[key])) return false
}
return true
}

access(key: string, label: string | true, create: true): symbol
access(key: string, label: string | true, create?: boolean): symbol | undefined
access(key: string, label: string | true, create = false) {
let realm: Realm | undefined
if (label === true) {
realm = this.realm
} else if (create) {
realm = this.loader.realms[label] ??= new GlobalRealm(this.loader, label)
} else {
realm = this.loader.realms[label]
}
return realm?.access(key, create)
return !this.parent.ctx.bail('loader/entry-check', this)
}

async checkService(name: string) {
if (!this.requiredDeps.includes(name)) return
const ready = this._check()
if (ready && !this.fork) {
await this.start()
} else if (!ready && this.fork) {
await this.stop()
}
createContext() {
const ctx = this.parent.ctx.extend()
ctx.emit('loader/context-init', this, ctx)
return ctx
}

patch(options: Partial<Entry.Options> = {}) {
patch(options: Partial<EntryOptions> = {}) {
// step 1: prepare isolate map
const ctx = this.fork?.parent ?? this.parent.ctx.extend({
[Context.intercept]: Object.create(this.parent.ctx[Context.intercept]),
[Context.isolate]: Object.create(this.parent.ctx[Context.isolate]),
})
const newMap: Dict<symbol> = Object.create(this.parent.ctx[Context.isolate])
for (const [key, label] of Object.entries(this.options.isolate ?? {})) {
newMap[key] = this.access(key, label, true)
}
const ctx = this.fork?.parent ?? this.createContext()
const meta = {} as EntryUpdateMeta
ctx.emit(meta, 'loader/before-patch', this, ctx)

// step 2: generate service diff
const diff: [string, symbol, symbol, symbol, symbol][] = []
const oldMap = ctx[Context.isolate]
for (const key in { ...oldMap, ...newMap, ...this.loader.delims }) {
if (newMap[key] === oldMap[key]) continue
const delim = this.loader.delims[key] ??= Symbol(`delim:${key}`)
ctx[delim] = Symbol(`${key}#${this.id}`)
for (const symbol of [oldMap[key], 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
}
diff.push([key, oldMap[key], 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 diff) {
const self = Object.create(ctx)
self[Context.filter] = (target: Context) => {
if (![symbol1, symbol2].includes(target[Context.isolate][key])) return false
return (flag1 === target[this.loader.delims[key]]) !== (flag1 === flag2)
}
ctx.emit(self, 'internal/before-service', key)
}

// step 4: update
// step 4.1: patch context
// step 1: set prototype for transferred context
Object.setPrototypeOf(ctx, this.parent.ctx)
Object.setPrototypeOf(ctx[Context.isolate], this.parent.ctx[Context.isolate])
Object.setPrototypeOf(ctx[Context.intercept], this.parent.ctx[Context.intercept])
swap(ctx[Context.isolate], newMap)
swap(ctx[Context.intercept], this.options.intercept)

// step 4.2: update fork (only when options.config is updated)
if (this.fork && 'config' in options) {
// step 2: update fork (when options.config is updated)
this.suspend = true
this.fork.update(this.options.config)
} else if (this.subgroup && 'disabled' in options) {
// step 3: check children (when options.disabled is updated)
const tree = this.subtree ?? this.parent.tree
for (const options of this.subgroup.data) {
tree.store[options.id].update({
Expand All @@ -237,40 +96,25 @@ export class Entry {
}
}

// step 4.3: replace service impl
for (const [, symbol1, symbol2, flag1, flag2] of diff) {
if (flag1 === flag2 && ctx[symbol1] && !ctx[symbol2]) {
ctx.root[symbol2] = ctx.root[symbol1]
delete ctx.root[symbol1]
}
}

// step 5: emit internal/service
for (const [key, symbol1, symbol2, flag1, flag2] of diff) {
const self = Object.create(ctx)
self[Context.filter] = (target: Context) => {
if (![symbol1, symbol2].includes(target[Context.isolate][key])) return false
return (flag1 === target[this.loader.delims[key]]) !== (flag1 === flag2)
}
ctx.emit(self, 'internal/service', key)
}
ctx.emit(meta, 'loader/after-patch', this, ctx)
return ctx
}

// step 6: clean up delimiters
for (const key in this.loader.delims) {
if (!Reflect.ownKeys(newMap).includes(key)) {
delete ctx[this.loader.delims[key]]
}
async refresh() {
const ready = this._check()
if (ready && !this.fork) {
await this.start()
} else if (!ready && this.fork) {
await this.stop()
}

return ctx
}

async update(options: Partial<Entry.Options>, override = false) {
async update(options: Partial<EntryOptions>, override = false) {
const legacy = { ...this.options }

// step 1: update options
if (override) {
this.options = options as Entry.Options
this.options = options as EntryOptions
} else {
for (const [key, value] of Object.entries(options)) {
if (isNullable(value)) {
Expand All @@ -286,10 +130,7 @@ export class Entry {
if (!this._check()) {
await this.stop()
} else if (this.fork) {
for (const [key, label] of Object.entries(legacy.isolate ?? {})) {
if (this.options.isolate?.[key] === label || label === true) continue
this.loader.realms[label]?.gc(key)
}
this.parent.ctx.emit('loader/partial-dispose', this, legacy, true)
this.patch(options)
} else {
await this.start()
Expand All @@ -306,18 +147,11 @@ export class Entry {
const ctx = this.patch()
ctx[Entry.key] = this
this.fork = ctx.plugin(plugin, this.options.config)
ctx.emit('loader/entry', 'apply', this)
ctx.emit('loader/entry-fork', this, 'apply')
}

async stop() {
this.fork?.dispose()
this.fork = undefined
}

dispose() {
for (const [key, label] of Object.entries(this.options.isolate ?? {})) {
if (label === true) continue
this.loader.realms[label]?.gc(key)
}
}
}
12 changes: 6 additions & 6 deletions packages/loader/src/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { access, constants, readdir, readFile, stat, writeFile } from 'node:fs/p
import { fileURLToPath, pathToFileURL } from 'node:url'
import { remove } from 'cosmokit'
import * as yaml from 'js-yaml'
import { Entry } from './entry.ts'
import { EntryOptions } from './entry.ts'
import { Loader } from './loader.ts'
import { EntryTree } from './tree.ts'
import { JsExpr } from './utils.ts'
Expand Down Expand Up @@ -46,7 +46,7 @@ export class LoaderFile {
}
}

async read(): Promise<Entry.Options[]> {
async read(): Promise<EntryOptions[]> {
if (this.type === 'application/yaml') {
return yaml.load(await readFile(this.name, 'utf8'), { schema }) as any
} else if (this.type === 'application/json') {
Expand All @@ -58,7 +58,7 @@ export class LoaderFile {
}
}

private async _write(config: Entry.Options[]) {
private async _write(config: EntryOptions[]) {
this.suspend = true
if (this.readonly) {
throw new Error(`cannot overwrite readonly config`)
Expand All @@ -70,8 +70,7 @@ export class LoaderFile {
}
}

write(config: Entry.Options[]) {
this.loader.ctx.emit('config')
write(config: EntryOptions[]) {
clearTimeout(this._writeTask)
this._writeTask = setTimeout(() => {
this._writeTask = undefined
Expand Down Expand Up @@ -123,7 +122,8 @@ export class ImportTree extends EntryTree {
}

write() {
return this.file!.write(this.root.data)
this.ctx.emit('loader/config-update')
return this.file.write(this.root.data)
}

_createFile(filename: string, type: string) {
Expand Down
Loading

0 comments on commit c9c6b3a

Please sign in to comment.