From 9c95eb073ce386e67869300b401af9e1226ecd87 Mon Sep 17 00:00:00 2001 From: Shigma Date: Wed, 29 May 2024 16:38:29 +0800 Subject: [PATCH] feat(loader): basic support for loader tree --- packages/cordis/src/group.ts | 4 +- packages/cordis/src/worker/logger.ts | 3 +- packages/loader/src/entry.ts | 5 +- packages/loader/src/file.ts | 46 ++++++++-------- packages/loader/src/group.ts | 67 +++++++++--------------- packages/loader/src/index.ts | 10 ++-- packages/loader/src/shared.ts | 78 +++------------------------- packages/loader/src/tree.ts | 71 +++++++++++++++++++++++++ packages/loader/tests/utils.ts | 8 +-- 9 files changed, 143 insertions(+), 149 deletions(-) create mode 100644 packages/loader/src/tree.ts diff --git a/packages/cordis/src/group.ts b/packages/cordis/src/group.ts index d4b9b3c..1ee9f7b 100644 --- a/packages/cordis/src/group.ts +++ b/packages/cordis/src/group.ts @@ -1,3 +1,3 @@ -import { group } from '@cordisjs/loader' +import { Group } from '@cordisjs/loader' -export default group +export default Group diff --git a/packages/cordis/src/worker/logger.ts b/packages/cordis/src/worker/logger.ts index 07af2b8..e497ad7 100644 --- a/packages/cordis/src/worker/logger.ts +++ b/packages/cordis/src/worker/logger.ts @@ -1,5 +1,4 @@ import { Logger } from '@cordisjs/logger' -import { kGroup } from '@cordisjs/loader' import { Context } from '../index.ts' declare module '@cordisjs/loader' { @@ -36,7 +35,7 @@ export function apply(ctx: Context, config: Config = {}) { }) ctx.on('loader/entry', (type, entry) => { - if (entry.fork?.runtime.plugin?.[kGroup]) return + if (entry.options.transparent) return ctx.logger('loader').info('%s plugin %c', type, entry.options.name) }) diff --git a/packages/loader/src/entry.ts b/packages/loader/src/entry.ts index f8ce3fd..eb81895 100644 --- a/packages/loader/src/entry.ts +++ b/packages/loader/src/entry.ts @@ -12,6 +12,7 @@ export namespace Entry { intercept?: Dict | null isolate?: Dict | null inject?: string[] | Inject | null + transparent?: boolean | null when?: any } } @@ -43,7 +44,7 @@ function sortKeys(object: T, prepend = ['id', 'name'], append = [' } export class Entry { - static key = Symbol('cordis.entry') + static key = Symbol.for('cordis.entry') public fork?: ForkScope public suspend = false @@ -202,7 +203,7 @@ export class Entry { async start() { const ctx = this.createContext() - const exports = await this.loader.import(this.options.name, this.parent.url).catch((error: any) => { + const exports = await this.loader.import(this.options.name, this.parent.tree.url).catch((error: any) => { ctx.emit('internal/error', new Error(`Cannot find package "${this.options.name}"`)) ctx.emit('internal/error', error) }) diff --git a/packages/loader/src/file.ts b/packages/loader/src/file.ts index ed6656f..e08a805 100644 --- a/packages/loader/src/file.ts +++ b/packages/loader/src/file.ts @@ -7,38 +7,41 @@ import { Entry } from './entry.ts' import { EntryGroup } from './group.ts' import { Loader } from './shared.ts' import { remove } from 'cosmokit' +import { EntryTree } from './tree.ts' export class LoaderFile { public suspend = false - public mutable = false + public readonly: boolean public url: string - public groups: BaseLoader[] = [] + public trees: ImportTree[] = [] private _writeTask?: NodeJS.Timeout constructor(public loader: Loader, public name: string, public type?: string) { this.url = pathToFileURL(name).href - loader.files[name] = this + loader.files[this.url] = this + this.readonly = !type } - ref(group: BaseLoader) { - this.groups.push(group) - group.url = pathToFileURL(this.name).href + ref(tree: ImportTree) { + this.trees.push(tree) + tree.url = pathToFileURL(this.name).href } - unref(group: BaseLoader) { - remove(this.groups, group) - if (this.groups.length) return + unref(tree: ImportTree) { + remove(this.trees, tree) + if (this.trees.length) return clearTimeout(this._writeTask) - delete this.loader.files[this.name] + delete this.loader.files[this.url] } async checkAccess() { if (!this.type) return try { await access(this.name, constants.W_OK) - this.mutable = true - } catch {} + } catch { + this.readonly = true + } } async read(): Promise { @@ -55,7 +58,7 @@ export class LoaderFile { private async _write(config: Entry.Options[]) { this.suspend = true - if (!this.mutable) { + if (this.readonly) { throw new Error(`cannot overwrite readonly config`) } if (this.type === 'application/yaml') { @@ -92,14 +95,16 @@ export namespace LoaderFile { } } -export class BaseLoader extends EntryGroup { +export class ImportTree extends EntryTree { static reusable = true protected file!: LoaderFile constructor(public ctx: Context) { - super(ctx) + super() + this.root = new EntryGroup(ctx, this) ctx.on('ready', () => this.start()) + ctx.on('dispose', () => this.stop()) } async start() { @@ -108,21 +113,20 @@ export class BaseLoader extends EntryGroup { } async refresh() { - this._update(await this.file.read()) + this.root.update(await this.file.read()) } stop() { this.file?.unref(this) - return super.stop() + return this.root.stop() } write() { - return this.file!.write(this.data) + return this.file!.write(this.root.data) } _createFile(filename: string, type: string) { this.file = this.ctx.loader[filename] ??= new LoaderFile(this.ctx.loader, filename, type) - this.url = this.file.url this.file.ref(this) } @@ -179,14 +183,14 @@ export namespace Import { } } -export class Import extends BaseLoader { +export class Import extends ImportTree { constructor(ctx: Context, public config: Import.Config) { super(ctx) } async start() { const { url } = this.config - const filename = fileURLToPath(new URL(url, this.ctx.scope.entry!.parent.url)) + const filename = fileURLToPath(new URL(url, this.ctx.scope.entry!.parent.tree.url)) const ext = extname(filename) if (!LoaderFile.supported.has(ext)) { throw new Error(`extension "${ext}" not supported`) diff --git a/packages/loader/src/group.ts b/packages/loader/src/group.ts index 8c5c5c9..e0bad7b 100644 --- a/packages/loader/src/group.ts +++ b/packages/loader/src/group.ts @@ -1,15 +1,13 @@ import { Context } from '@cordisjs/core' import { Entry } from './entry.ts' +import { EntryTree } from './tree.ts' -export abstract class EntryGroup { +export class EntryGroup { public data: Entry.Options[] = [] - public url!: string - constructor(public ctx: Context) { - ctx.on('dispose', () => this.stop()) - } + constructor(public ctx: Context, public tree: EntryTree) {} - async _create(options: Omit) { + async create(options: Omit) { const id = this.ctx.loader.ensureId(options) const entry = this.ctx.loader.entries[id] ??= new Entry(this.ctx.loader, this) entry.parent = this @@ -17,21 +15,21 @@ export abstract class EntryGroup { return id } - _unlink(options: Entry.Options) { + unlink(options: Entry.Options) { const config = this.data const index = config.indexOf(options) if (index >= 0) config.splice(index, 1) } - _remove(id: string) { + remove(id: string) { const entry = this.ctx.loader.entries[id] if (!entry) return entry.stop() - this._unlink(entry.options) + this.unlink(entry.options) delete this.ctx.loader.entries[id] } - _update(config: Entry.Options[]) { + update(config: Entry.Options[]) { const oldConfig = this.data as Entry.Options[] this.data = config const oldMap = Object.fromEntries(oldConfig.map(options => [options.id, options])) @@ -40,52 +38,35 @@ export abstract class EntryGroup { // update inner plugins for (const id of Reflect.ownKeys({ ...oldMap, ...newMap }) as string[]) { if (newMap[id]) { - this._create(newMap[id]).catch((error) => { + this.create(newMap[id]).catch((error) => { this.ctx.emit('internal/error', error) }) } else { - this._remove(id) + this.remove(id) } } } - write() { - return this.ctx.scope.entry!.parent.write() - } - stop() { for (const options of this.data) { - this._remove(options.id) + this.remove(options.id) } } } -export const kGroup = Symbol.for('cordis.group') - -export interface GroupOptions { - name?: string - initial?: Omit[] - allowed?: string[] -} - -export function defineGroup(config?: Entry.Options[], options: GroupOptions = {}) { - options.initial = config - - class Group extends EntryGroup { - static reusable = true - static [kGroup] = options +export class Group extends EntryGroup { + static key = Symbol('cordis.group') + static reusable = true + static initial: Omit[] = [] - constructor(public ctx: Context) { - super(ctx) - this.url = ctx.scope.entry!.parent.url - ctx.scope.entry!.children = this - ctx.accept((config: Entry.Options[]) => { - this._update(config) - }, { passive: true, immediate: true }) - } + // TODO support options + constructor(public ctx: Context) { + const entry = ctx.scope.entry! + super(ctx, entry.parent.tree) + entry.children = this + ctx.on('dispose', () => this.stop()) + ctx.accept((config: Entry.Options[]) => { + this.update(config) + }, { passive: true, immediate: true }) } - - return Group } - -export const group = defineGroup() diff --git a/packages/loader/src/index.ts b/packages/loader/src/index.ts index 53dcebd..f1de3e3 100644 --- a/packages/loader/src/index.ts +++ b/packages/loader/src/index.ts @@ -50,13 +50,13 @@ class NodeLoader extends Loader { async start() { const originalLoad: ModuleLoad = Module['_load'] Module['_load'] = ((request, parent, isMain) => { - if (request.startsWith('node:')) return originalLoad(request, parent, isMain) try { + // TODO support hmr for cjs-esm interop const result = this.internal?.resolveSync(request, pathToFileURL(parent.filename).href, {}) - if (result?.format === 'module' && this.internal?.loadCache.has(result.url)) { - const job = this.internal?.loadCache.get(result.url) - return job?.module?.getNamespace() - } + const job = result?.format === 'module' + ? this.internal?.loadCache.get(result.url) + : undefined + if (job) return job?.module?.getNamespace() } catch {} return originalLoad(request, parent, isMain) }) as ModuleLoad diff --git a/packages/loader/src/shared.ts b/packages/loader/src/shared.ts index 376bc48..a680138 100644 --- a/packages/loader/src/shared.ts +++ b/packages/loader/src/shared.ts @@ -3,7 +3,7 @@ import { Dict, isNullable, valueMap } from 'cosmokit' import { ModuleLoader } from './internal.ts' import { interpolate } from './utils.ts' import { Entry } from './entry.ts' -import { BaseLoader, LoaderFile } from './file.ts' +import { ImportTree, LoaderFile } from './file.ts' export * from './entry.ts' export * from './file.ts' @@ -41,7 +41,7 @@ export namespace Loader { } } -export abstract class Loader extends BaseLoader { +export abstract class Loader extends ImportTree { // TODO auto inject optional when provided? static inject = { optional: ['loader'], @@ -57,7 +57,6 @@ export abstract class Loader extends BaseLoader { } public files: Dict = Object.create(null) - public entries: Dict = Object.create(null) public realms: Dict> = Object.create(null) public delims: Dict = Object.create(null) public internal?: ModuleLoader @@ -65,7 +64,6 @@ export abstract class Loader extends BaseLoader { constructor(public ctx: Context, public config: Loader.Config) { super(ctx) - const self = this this.ctx.set('loader', this) this.realms['#'] = ctx.root[Context.isolate] @@ -79,7 +77,7 @@ export abstract class Loader extends BaseLoader { if (fork.entry.suspend) return fork.entry.suspend = false const { schema } = fork.runtime fork.entry.options.config = schema ? schema.simplify(config) : config - fork.entry.parent.write() + fork.entry.parent.tree.write() }) this.ctx.on('internal/fork', (fork) => { @@ -98,17 +96,17 @@ export abstract class Loader extends BaseLoader { fork.entry.options.disabled = true fork.entry.fork = undefined fork.entry.stop() - fork.entry.parent.write() + fork.entry.parent.tree.write() }) - this.ctx.on('internal/before-service', function (name) { - for (const entry of Object.values(self.entries)) { + this.ctx.on('internal/before-service', (name) => { + for (const entry of Object.values(this.entries)) { entry.checkService(name) } }, { global: true }) - this.ctx.on('internal/service', function (name) { - for (const entry of Object.values(self.entries)) { + this.ctx.on('internal/service', (name) => { + for (const entry of Object.values(this.entries)) { entry.checkService(name) } }, { global: true }) @@ -149,66 +147,6 @@ export abstract class Loader extends BaseLoader { return !!this.interpolate(`\${{ ${expr} }}`) } - ensureId(options: Partial) { - if (!options.id) { - do { - options.id = Math.random().toString(36).slice(2, 8) - } while (this.entries[options.id]) - } - return options.id! - } - - async update(id: string, options: Partial>) { - const entry = this.entries[id] - if (!entry) throw new Error(`entry ${id} not found`) - const override = { ...entry.options } - for (const [key, value] of Object.entries(options)) { - if (isNullable(value)) { - delete override[key] - } else { - override[key] = value - } - } - entry.parent.write() - return entry.update(override) - } - - resolveGroup(id: string | null) { - const group = id ? this.entries[id]?.children : this - if (!group) throw new Error(`entry ${id} not found`) - return group - } - - async create(options: Omit, parent: string | null = null, position = Infinity) { - const group = this.resolveGroup(parent) - group.data.splice(position, 0, options as Entry.Options) - group.write() - return group._create(options) - } - - remove(id: string) { - const entry = this.entries[id] - if (!entry) throw new Error(`entry ${id} not found`) - entry.parent._remove(id) - entry.parent.write() - } - - transfer(id: string, parent: string | null, position = Infinity) { - const entry = this.entries[id] - if (!entry) throw new Error(`entry ${id} not found`) - const source = entry.parent - const target = this.resolveGroup(parent) - source._unlink(entry.options) - target.data.splice(position, 0, entry.options) - source.write() - target.write() - if (source === target) return - entry.parent = target - if (!entry.fork) return - const ctx = entry.createContext() - entry.patch(entry.fork.parent, ctx) - } - locate(ctx = this[Context.current]) { return this._locate(ctx.scope).map(entry => entry.options.id) } diff --git a/packages/loader/src/tree.ts b/packages/loader/src/tree.ts new file mode 100644 index 0000000..9a474c5 --- /dev/null +++ b/packages/loader/src/tree.ts @@ -0,0 +1,71 @@ +import { Dict, isNullable } from 'cosmokit' +import { Entry } from './entry.ts' +import { EntryGroup } from './group.ts' + +export abstract class EntryTree { + public url!: string + public root!: EntryGroup + public entries: Dict = Object.create(null) + + ensureId(options: Partial) { + if (!options.id) { + do { + options.id = Math.random().toString(36).slice(2, 8) + } while (this.entries[options.id]) + } + return options.id! + } + + async update(id: string, options: Partial>) { + const entry = this.entries[id] + if (!entry) throw new Error(`entry ${id} not found`) + const override = { ...entry.options } + for (const [key, value] of Object.entries(options)) { + if (isNullable(value)) { + delete override[key] + } else { + override[key] = value + } + } + entry.parent.tree.write() + return entry.update(override) + } + + resolveGroup(id: string | null) { + const group = id ? this.entries[id]?.children : this.root + if (!group) throw new Error(`entry ${id} not found`) + return group + } + + async create(options: Omit, parent: string | null = null, position = Infinity) { + const group = this.resolveGroup(parent) + group.data.splice(position, 0, options as Entry.Options) + group.tree.write() + return group.create(options) + } + + remove(id: string) { + const entry = this.entries[id] + if (!entry) throw new Error(`entry ${id} not found`) + entry.parent.remove(id) + entry.parent.tree.write() + } + + transfer(id: string, parent: string | null, position = Infinity) { + const entry = this.entries[id] + if (!entry) throw new Error(`entry ${id} not found`) + const source = entry.parent + const target = this.resolveGroup(parent) + source.unlink(entry.options) + target.data.splice(position, 0, entry.options) + source.tree.write() + target.tree.write() + if (source === target) return + entry.parent = target + if (!entry.fork) return + const ctx = entry.createContext() + entry.patch(entry.fork.parent, ctx) + } + + abstract write(): void +} diff --git a/packages/loader/tests/utils.ts b/packages/loader/tests/utils.ts index 268832d..6b218fb 100644 --- a/packages/loader/tests/utils.ts +++ b/packages/loader/tests/utils.ts @@ -1,6 +1,6 @@ import { Dict } from 'cosmokit' import { Context, ForkScope, Plugin } from '@cordisjs/core' -import { LoaderFile, Entry, group, Loader } from '../src' +import { LoaderFile, Entry, Group, Loader } from '../src' import { Mock, mock } from 'node:test' import { expect } from 'chai' @@ -14,7 +14,6 @@ declare module '../src/shared' { } class MockLoaderFile extends LoaderFile { - mutable = true data: Entry.Options[] = [] async read() { @@ -32,8 +31,9 @@ export default class MockLoader extends Loader { constructor(ctx: Context) { super(ctx, { name: 'cordis' }) - this.file = new MockLoaderFile(this, 'cordis.yml') - this.mock('cordis/group', group) + this.file = new MockLoaderFile(this, 'config-1.yml') + this.file.ref(this) + this.mock('cordis/group', Group) } async start() {