Skip to content

Commit f94752d

Browse files
committed
feat(loader): support multi-referenced config files
1 parent 5f8cbb5 commit f94752d

File tree

7 files changed

+84
-82
lines changed

7 files changed

+84
-82
lines changed

packages/hmr/src/index.ts

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ class Watcher extends Service {
7272
constructor(ctx: Context, public config: Watcher.Config) {
7373
super(ctx, 'hmr')
7474
this.base = resolve(ctx.baseDir, config.base || '')
75-
this.initialURL = ctx.loader.file.url
75+
// FIXME resolve deps based on different files
76+
this.initialURL = ctx.loader.url
7677
}
7778

7879
relative(filename: string) {
@@ -90,33 +91,29 @@ class Watcher extends Service {
9091
})
9192

9293
// files independent from any plugins will trigger a full reload
93-
const mainJob = await loader.internal!.getModuleJob('cordis/worker', this.initialURL, {})!
94+
const parentURL = pathToFileURL(process.cwd()).href
95+
const mainJob = await loader.internal!.getModuleJob('cordis/worker', parentURL, {})!
9496
this.externals = await loadDependencies(mainJob)
9597
const triggerLocalReload = this.ctx.debounce(() => this.triggerLocalReload(), this.config.debounce)
9698

9799
this.watcher.on('change', async (path) => {
100+
this.ctx.logger.debug('change detected:', path)
98101
const filename = pathToFileURL(resolve(this.base, path)).href
99-
const isEntry = filename === this.initialURL
100-
if (loader.file.suspend && isEntry) {
101-
loader.file.suspend = false
102-
return
103-
}
102+
if (this.externals.has(filename)) return loader.exit()
104103

105-
this.ctx.logger.debug('change detected:', path)
104+
if (loader.internal!.loadCache.has(filename)) {
105+
this.stashed.add(filename)
106+
return triggerLocalReload()
107+
}
106108

107-
if (isEntry) {
108-
if (loader.internal!.loadCache.has(filename)) {
109-
loader.exit()
110-
} else {
111-
await loader.refresh()
112-
}
113-
} else {
114-
if (this.externals.has(filename)) {
115-
loader.exit()
116-
} else if (loader.internal!.loadCache.has(filename)) {
117-
this.stashed.add(filename)
118-
triggerLocalReload()
119-
}
109+
const file = this.ctx.loader.files[filename]
110+
if (!file) return
111+
if (file.suspend) {
112+
file.suspend = false
113+
return
114+
}
115+
for (const loader of file.groups) {
116+
loader.refresh()
120117
}
121118
})
122119
}

packages/loader/src/entry.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,13 @@ export class Entry {
158158
}
159159
this.patch(this.fork.parent)
160160
} else {
161-
const plugin = await this.loader.resolve(this.options.name)
162-
if (!plugin) return
163161
const ctx = this.createContext()
162+
const exports = await this.loader.import(this.options.name).catch((error: any) => {
163+
ctx.emit('internal/error', new Error(`Cannot find package "${this.options.name}"`))
164+
ctx.emit('internal/error', error)
165+
})
166+
if (!exports) return
167+
const plugin = this.loader.unwrapExports(exports)
164168
this.patch(ctx)
165169
ctx[Entry.key] = this
166170
this.fork = ctx.plugin(plugin, this.options.config)

packages/loader/src/file.ts

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,31 @@ import * as yaml from 'js-yaml'
66
import { Entry } from './entry.ts'
77
import { EntryGroup } from './group.ts'
88
import { Loader } from './shared.ts'
9+
import { remove } from 'cosmokit'
910

1011
export class LoaderFile {
11-
public url: string
1212
public suspend = false
1313
public mutable = false
14+
public url: string
15+
public groups: BaseLoader[] = []
1416

1517
private _writeTask?: NodeJS.Timeout
1618

17-
constructor(public ctx: Context, public name: string, public type?: string) {
19+
constructor(public loader: Loader, public name: string, public type?: string) {
1820
this.url = pathToFileURL(name).href
21+
loader.files[name] = this
22+
}
23+
24+
ref(group: BaseLoader) {
25+
this.groups.push(group)
26+
group.url = pathToFileURL(this.name).href
27+
}
28+
29+
unref(group: BaseLoader) {
30+
remove(this.groups, group)
31+
if (this.groups.length) return
32+
clearTimeout(this._writeTask)
33+
delete this.loader.files[this.name]
1934
}
2035

2136
async checkAccess() {
@@ -51,25 +66,13 @@ export class LoaderFile {
5166
}
5267

5368
write(config: Entry.Options[]) {
54-
this.ctx.emit('config')
69+
this.loader.ctx.emit('config')
5570
clearTimeout(this._writeTask)
5671
this._writeTask = setTimeout(() => {
5772
this._writeTask = undefined
5873
this._write(config)
5974
}, 0)
6075
}
61-
62-
async import(name: string) {
63-
if (this.ctx.loader.internal) {
64-
return this.ctx.loader.internal.import(name, this.url, {})
65-
} else {
66-
return import(name)
67-
}
68-
}
69-
70-
dispose() {
71-
clearTimeout(this._writeTask)
72-
}
7376
}
7477

7578
export namespace LoaderFile {
@@ -90,7 +93,9 @@ export namespace LoaderFile {
9093
}
9194

9295
export class BaseLoader extends EntryGroup {
93-
public file!: LoaderFile
96+
static reusable = true
97+
98+
protected file!: LoaderFile
9499

95100
constructor(public ctx: Context) {
96101
super(ctx)
@@ -107,14 +112,20 @@ export class BaseLoader extends EntryGroup {
107112
}
108113

109114
stop() {
110-
this.file?.dispose()
115+
this.file?.unref(this)
111116
return super.stop()
112117
}
113118

114119
write() {
115120
return this.file!.write(this.data)
116121
}
117122

123+
_createFile(filename: string, type: string) {
124+
this.file = this.ctx.loader[filename] ??= new LoaderFile(this.ctx.loader, filename, type)
125+
this.url = this.file.url
126+
this.file.ref(this)
127+
}
128+
118129
async init(baseDir: string, options: Loader.Config) {
119130
if (options.filename) {
120131
const filename = resolve(baseDir, options.filename)
@@ -126,18 +137,18 @@ export class BaseLoader extends EntryGroup {
126137
if (!LoaderFile.supported.has(ext)) {
127138
throw new Error(`extension "${ext}" not supported`)
128139
}
129-
this.file = new LoaderFile(this.ctx, filename, type)
140+
this._createFile(filename, type)
130141
} else {
131142
baseDir = filename
132-
await this.findConfig(baseDir, options)
143+
await this._init(baseDir, options)
133144
}
134145
} else {
135-
await this.findConfig(baseDir, options)
146+
await this._init(baseDir, options)
136147
}
137148
this.ctx.provide('baseDir', baseDir, true)
138149
}
139150

140-
private async findConfig(baseDir: string, options: Loader.Config) {
151+
private async _init(baseDir: string, options: Loader.Config) {
141152
const { name, initial } = options
142153
const dirents = await readdir(baseDir, { withFileTypes: true })
143154
for (const extension of LoaderFile.supported) {
@@ -148,13 +159,13 @@ export class BaseLoader extends EntryGroup {
148159
}
149160
const type = LoaderFile.writable[extension]
150161
const filename = resolve(baseDir, name + extension)
151-
this.file = new LoaderFile(this.ctx, filename, type)
162+
this._createFile(filename, type)
152163
return
153164
}
154165
if (initial) {
155166
const type = LoaderFile.writable['.yml']
156167
const filename = resolve(baseDir, name + '.yml')
157-
this.file = new LoaderFile(this.ctx, filename, type)
168+
this._createFile(filename, type)
158169
return this.file.write(initial as any)
159170
}
160171
throw new Error('config file not found')
@@ -175,12 +186,12 @@ export class Import extends BaseLoader {
175186

176187
async start() {
177188
const { url } = this.config
178-
const filename = fileURLToPath(new URL(url, this.ctx.loader.file.url))
189+
const filename = fileURLToPath(new URL(url, this.ctx.scope.entry!.parent.url))
179190
const ext = extname(filename)
180191
if (!LoaderFile.supported.has(ext)) {
181192
throw new Error(`extension "${ext}" not supported`)
182193
}
183-
this.file = new LoaderFile(this.ctx, filename, LoaderFile.writable[ext])
194+
this._createFile(filename, LoaderFile.writable[ext])
184195
await super.start()
185196
}
186197
}

packages/loader/src/group.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { Context } from '@cordisjs/core'
22
import { Entry } from './entry.ts'
33

4-
export class EntryGroup {
4+
export abstract class EntryGroup {
55
public data: Entry.Options[] = []
6+
public url!: string
67

78
constructor(public ctx: Context) {
89
ctx.on('dispose', () => this.stop())
@@ -76,6 +77,7 @@ export function defineGroup(config?: Entry.Options[], options: GroupOptions = {}
7677

7778
constructor(public ctx: Context) {
7879
super(ctx)
80+
this.url = ctx.scope.entry!.parent.url
7981
ctx.scope.entry!.children = this
8082
ctx.accept((config: Entry.Options[]) => {
8183
this._update(config)

packages/loader/src/shared.ts

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Dict, isNullable, valueMap } from 'cosmokit'
33
import { ModuleLoader } from './internal.ts'
44
import { interpolate } from './utils.ts'
55
import { Entry } from './entry.ts'
6-
import { BaseLoader } from './file.ts'
6+
import { BaseLoader, LoaderFile } from './file.ts'
77

88
export * from './entry.ts'
99
export * from './file.ts'
@@ -56,13 +56,12 @@ export abstract class Loader extends BaseLoader {
5656
env: process.env,
5757
}
5858

59+
public files: Dict<LoaderFile> = Object.create(null)
5960
public entries: Dict<Entry> = Object.create(null)
6061
public realms: Dict<Dict<symbol>> = Object.create(null)
6162
public delims: Dict<symbol> = Object.create(null)
6263
public internal?: ModuleLoader
6364

64-
protected tasks = new Set<Promise<any>>()
65-
6665
constructor(public ctx: Context, public config: Loader.Config) {
6766
super(ctx)
6867
this.ctx.set('loader', this)
@@ -104,9 +103,6 @@ export abstract class Loader extends BaseLoader {
104103
async start() {
105104
await this.init(process.cwd(), this.config)
106105
await super.start()
107-
while (this.tasks.size) {
108-
await Promise.all(this.tasks)
109-
}
110106
}
111107

112108
interpolate(source: any) {
@@ -121,16 +117,6 @@ export abstract class Loader extends BaseLoader {
121117
}
122118
}
123119

124-
async resolve(name: string) {
125-
const task = this.file.import(name).catch((error) => {
126-
this.ctx.emit('internal/error', new Error(`Cannot find package "${name}"`))
127-
this.ctx.emit('internal/error', error)
128-
})
129-
this.tasks.add(task)
130-
task.finally(() => this.tasks.delete(task))
131-
return this.unwrapExports(await task)
132-
}
133-
134120
isTruthyLike(expr: any) {
135121
if (isNullable(expr)) return true
136122
return !!this.interpolate(`\${{ ${expr} }}`)
@@ -213,6 +199,16 @@ export abstract class Loader extends BaseLoader {
213199
return this._locate(scope.parent.scope)
214200
}
215201

202+
async import(name: string, baseURL = this.url) {
203+
if (this.internal) {
204+
return this.internal.import(name, baseURL, {})
205+
} else {
206+
return import(name)
207+
}
208+
}
209+
210+
exit() {}
211+
216212
unwrapExports(exports: any) {
217213
if (isNullable(exports)) return exports
218214
exports = exports.default ?? exports
@@ -222,8 +218,6 @@ export abstract class Loader extends BaseLoader {
222218
return exports.default ?? exports
223219
}
224220

225-
exit() {}
226-
227221
_clearRealm(key: string, name: string) {
228222
const hasRef = Object.values(this.entries).some((entry) => {
229223
if (!entry.fork) return false

packages/loader/tests/index.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe('loader: basic support', () => {
3232
disabled: true,
3333
}],
3434
}])
35-
await loader.refresh()
35+
await loader.start()
3636

3737
loader.expectEnable(foo, {})
3838
loader.expectEnable(bar, { a: 1 })
@@ -52,7 +52,7 @@ describe('loader: basic support', () => {
5252
id: '4',
5353
name: 'qux',
5454
}])
55-
await loader.refresh()
55+
await loader.start()
5656

5757
loader.expectEnable(foo, {})
5858
loader.expectDisable(bar)

packages/loader/tests/utils.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,31 +24,25 @@ class MockLoaderFile extends LoaderFile {
2424
write(data: Entry.Options[]) {
2525
this.data = data
2626
}
27-
28-
async import(name: string) {
29-
return (this.ctx.loader as MockLoader).modules[name]
30-
}
3127
}
3228

3329
export default class MockLoader extends Loader {
34-
declare file: MockLoaderFile
30+
public file: MockLoaderFile
3531
public modules: Dict<Plugin.Object> = Object.create(null)
3632

3733
constructor(ctx: Context) {
3834
super(ctx, { name: 'cordis' })
35+
this.file = new MockLoaderFile(this, 'cordis.yml')
3936
this.mock('cordis/group', group)
4037
}
4138

42-
async refresh() {
43-
await super.refresh()
44-
while (this.tasks.size) {
45-
await Promise.all(this.tasks)
46-
}
47-
}
48-
4939
async start() {
50-
this.file = new MockLoaderFile(this.ctx, 'cordis.yml')
5140
await this.refresh()
41+
await new Promise((resolve) => setTimeout(resolve, 0))
42+
}
43+
44+
async import(name: string) {
45+
return this.modules[name]
5246
}
5347

5448
mock<F extends Function>(name: string, plugin: F) {

0 commit comments

Comments
 (0)