dsh-deferred-boot 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "dsh-deferred-boot",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.1",
4
+ "description": "Mount deferred plugins after the main UI is ready, one idle slice at a time",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./scheduler": "./src/scheduler.js",
10
+ "./client": "./lib/client.js",
11
+ "./icon": "./icon.svg",
12
+ "./locale/*.json": "./locale/*.json",
13
+ "./cordis.patch.yml": "./cordis.patch.yml",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "icon": "./icon.svg",
17
+ "dsh": {
18
+ "bundle": {
19
+ "patch": "./cordis.patch.yml"
20
+ },
21
+ "client": {
22
+ "inject": [
23
+ "@deepseek-ai/dsh-client-locale",
24
+ "@deepseek-ai/dsh-client-ui-settings",
25
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
26
+ "@deepseek-ai/dsh-api-remotes"
27
+ ],
28
+ "platform": "web"
29
+ }
30
+ },
31
+ "files": [
32
+ "src",
33
+ "lib",
34
+ "locale",
35
+ "docs",
36
+ "market",
37
+ "scripts",
38
+ "cordis.patch.yml",
39
+ "icon.svg",
40
+ "README.md",
41
+ "LICENSE",
42
+ "CHANGELOG.md"
43
+ ],
44
+ "keywords": [
45
+ "dsh",
46
+ "dsh-plugin",
47
+ "deepseek-harness",
48
+ "cordis",
49
+ "startup",
50
+ "lazy-loading",
51
+ "deferred",
52
+ "plugin-manager"
53
+ ],
54
+ "author": "BuSheng",
55
+ "engines": {
56
+ "dsh": ">=0.2.0-rc.1",
57
+ "node": ">=20"
58
+ },
59
+ "peerDependencies": {
60
+ "@deepseek-ai/cordis": "~4.1.0"
61
+ },
62
+ "dependencies": {
63
+ "@deepseek-ai/schemastery": "^3.18.5"
64
+ },
65
+ "scripts": {
66
+ "test": "node --test test/*.test.mjs",
67
+ "pack": "powershell -NoProfile -ExecutionPolicy Bypass -File pack.ps1",
68
+ "index": "node scripts/gen-market-index.mjs"
69
+ },
70
+ "license": "MIT"
71
+ }
@@ -0,0 +1,55 @@
1
+ // Generate the plugin market index (format 1) for this plugin.
2
+ //
3
+ // node scripts/gen-market-index.mjs [--index-url https://example.com/market/index.json]
4
+ //
5
+ // The output is `market/index.json`. Host it anywhere that serves HTTPS; users
6
+ // add that URL as an index source in 插件 → 发现 → 添加索引源.
7
+ // The icon is inlined as a data: URL so the JSON is self-contained.
8
+ import { readFile, mkdir, writeFile } from 'node:fs/promises'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { dirname, join } from 'node:path'
11
+
12
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
13
+ const read = async (rel) => readFile(join(root, rel), 'utf8')
14
+
15
+ const pkg = JSON.parse(await read('package.json'))
16
+ const zh = JSON.parse(await read('locale/zh.json'))
17
+ const en = JSON.parse(await read('locale/en.json'))
18
+ const icon = await read('icon.svg')
19
+
20
+ const iconDataUrl = `data:image/svg+xml;base64,${Buffer.from(icon, 'utf8').toString('base64')}`
21
+ const args = process.argv.slice(2)
22
+ const indexUrlFlag = args.indexOf('--index-url')
23
+ const indexUrl = indexUrlFlag >= 0 ? args[indexUrlFlag + 1] : 'https://example.com/dsh-deferred-boot/index.json'
24
+
25
+ const title = { en: en.meta?.title ?? 'Deferred Boot', zh: zh.meta?.title ?? '延迟启动' }
26
+ const description = { en: en.meta?.description ?? '', zh: zh.meta?.description ?? '' }
27
+
28
+ const index = {
29
+ format: 1,
30
+ title,
31
+ description: { en: 'Plugins that defer heavy plugins until the main UI is ready', zh: '把指定插件推迟到主界面就绪之后再挂载的启动调度插件' },
32
+ plugins: [
33
+ {
34
+ name: pkg.name,
35
+ title,
36
+ description,
37
+ icon: iconDataUrl,
38
+ author: pkg.author,
39
+ metadata: { keywords: pkg.keywords ?? [] },
40
+ versions: [
41
+ {
42
+ version: pkg.version,
43
+ publishTimestamp: Math.floor(Date.now() / 1000),
44
+ source: { type: 'npm' },
45
+ engines: { dsh: pkg.engines?.dsh ?? '>=0.2.0-rc.1' },
46
+ },
47
+ ],
48
+ },
49
+ ],
50
+ }
51
+
52
+ await mkdir(join(root, 'market'), { recursive: true })
53
+ await writeFile(join(root, 'market', 'index.json'), `${JSON.stringify(index, null, 2)}\n`)
54
+ console.log(`wrote market/index.json for ${pkg.name}@${pkg.version}`)
55
+ console.log(`host it as a static file, then use its URL as an index source: ${indexUrl}`)
package/src/index.js ADDED
@@ -0,0 +1,60 @@
1
+ // dsh-deferred-boot — host-side entry.
2
+ //
3
+ // apply() must stay synchronous and cheap: register the scheduler, hand it to
4
+ // the context, and return. No I/O, no imports of target plugins, no awaits.
5
+ //
6
+ // The Config fields are declared `.volatile()` so the settings service exposes
7
+ // an editable form for them. The profile's plugin page renders that form under
8
+ // `plugins.bundle.config` (see lib/client.js), and saving writes the values into
9
+ // the profile patch — the user never edits cordis.patch.yml by hand.
10
+ import Schema from '@deepseek-ai/schemastery'
11
+ import { createDeferredBoot } from './scheduler.js'
12
+
13
+ export const name = 'dsh-deferred-boot'
14
+
15
+ export const Config = Schema.object({
16
+ idleDelay: Schema.number().default(1200).volatile(),
17
+ disposeTimeout: Schema.number().default(10000).volatile(),
18
+ readyFallbackMs: Schema.number().default(5000).volatile(),
19
+ deferredPlugins: Schema.array(Schema.object({
20
+ id: Schema.string().required(),
21
+ name: Schema.string().required(),
22
+ strategy: Schema.string().default('manual'),
23
+ config: Schema.any().default({}),
24
+ })).default([]).volatile(),
25
+ })
26
+
27
+ /**
28
+ * @param {import('@deepseek-ai/cordis').Context} ctx
29
+ * @param {{ idleDelay?: number, disposeTimeout?: number, readyFallbackMs?: number, deferredPlugins?: unknown[] }} [config]
30
+ */
31
+ export function apply(ctx, config = {}) {
32
+ const scheduler = createDeferredBoot(ctx, config, { log: ctx.logger })
33
+ const api = {
34
+ /** Mount a `manual` target on demand. Resolves when the attempt settled. */
35
+ ensure: (id) => scheduler.ensure(id),
36
+ /** Snapshot of every target: { id, strategy, state, attempts, lastError }. */
37
+ status: () => scheduler.status(),
38
+ /** Dispose one mounted target with the configured timeout. */
39
+ unmount: (id) => scheduler.unmount(id),
40
+ }
41
+ // Cordis 4 contexts are strict proxies: assigning an unknown property throws
42
+ // `cannot set property "..." without provide`. Register the service through
43
+ // ctx.provide() so the fiber owns it and disposes it on unload; older Cordis
44
+ // builds without provide() accept the plain assignment.
45
+ if (typeof ctx.provide === 'function') {
46
+ ctx.provide('deferredBoot', api)
47
+ } else {
48
+ ctx.deferredBoot = api
49
+ }
50
+
51
+ if (typeof ctx.effect === 'function') {
52
+ ctx.effect(() => () => scheduler.stop())
53
+ } else if (typeof ctx.on === 'function') {
54
+ ctx.on('dispose', () => scheduler.stop())
55
+ }
56
+
57
+ // start() only schedules a microtask; apply() itself stays synchronous.
58
+ scheduler.start()
59
+ return api
60
+ }
@@ -0,0 +1,419 @@
1
+ // dsh-deferred-boot scheduler core.
2
+ //
3
+ // Responsibilities:
4
+ // - wait for a "main UI ready" signal with layered fallbacks
5
+ // - mount deferred targets one at a time, on idle slices (never a whole batch
6
+ // inside one idle callback)
7
+ // - check loader existence before every create() (duplicate ids are fatal)
8
+ // - isolate per-target failures; freeze after repeated failures
9
+ // - race dispose() against a hard timeout, never leave an unhandled rejection
10
+ //
11
+ // The module is runtime-neutral: it never imports Node or browser globals at
12
+ // load time, only probes them when a schedule is requested.
13
+
14
+ export const DEFAULT_CONFIG = {
15
+ idleDelay: 1200,
16
+ disposeTimeout: 10000,
17
+ readyFallbackMs: 5000,
18
+ deferredPlugins: [],
19
+ }
20
+
21
+ const STRATEGIES = new Set(['idle', 'manual', 'inline-lazy', 'disabled'])
22
+ const MAX_ATTEMPTS = 3
23
+ const READY_POLL_MS = 150
24
+
25
+ /**
26
+ * Schemastery's `.volatile()` fields arrive as references with get()/set()
27
+ * instead of plain values. Unwrap them so the scheduler always sees data.
28
+ */
29
+ function unwrap(value) {
30
+ if (value && typeof value === 'object' && typeof value.get === 'function') {
31
+ try {
32
+ return value.get()
33
+ } catch {
34
+ return value
35
+ }
36
+ }
37
+ return value
38
+ }
39
+
40
+ function numberOr(value, fallback) {
41
+ const number = Number(unwrap(value))
42
+ return Number.isFinite(number) && number >= 0 ? number : fallback
43
+ }
44
+
45
+ /**
46
+ * Normalize the user config. Unknown strategies fall back to `manual` so a typo
47
+ * can never make a heavy plugin mount automatically.
48
+ */
49
+ export function normalizeConfig(raw) {
50
+ const source = raw ?? {}
51
+ const plugins = unwrap(source.deferredPlugins)
52
+ const config = {
53
+ idleDelay: numberOr(source.idleDelay, DEFAULT_CONFIG.idleDelay),
54
+ disposeTimeout: numberOr(source.disposeTimeout, DEFAULT_CONFIG.disposeTimeout),
55
+ readyFallbackMs: numberOr(source.readyFallbackMs, DEFAULT_CONFIG.readyFallbackMs),
56
+ deferredPlugins: Array.isArray(plugins) ? plugins : DEFAULT_CONFIG.deferredPlugins,
57
+ }
58
+ config.deferredPlugins = config.deferredPlugins.map((item, index) => {
59
+ const strategy = STRATEGIES.has(item.strategy) ? item.strategy : 'manual'
60
+ const id = String(item.id ?? item.name ?? `deferred-${index}`)
61
+ return {
62
+ id,
63
+ name: String(item.name ?? id),
64
+ config: item.config ?? {},
65
+ strategy,
66
+ attempts: 0,
67
+ state: 'pending',
68
+ lastError: null,
69
+ handle: null,
70
+ }
71
+ })
72
+ return config
73
+ }
74
+
75
+ /** Turn one idle task trigger into a callback. Never runs a batch. */
76
+ export function scheduleSlice(run) {
77
+ const g = globalThis
78
+ if (g.scheduler && typeof g.scheduler.postTask === 'function') {
79
+ // background priority: the browser may run it after user-visible work.
80
+ g.scheduler.postTask(() => run(), { priority: 'background' }).catch(() => {})
81
+ return
82
+ }
83
+ if (typeof g.requestIdleCallback === 'function') {
84
+ g.requestIdleCallback(() => run(), { timeout: 1000 })
85
+ return
86
+ }
87
+ // Node (host runtime) has neither API. A zero-delay timer keeps the slice
88
+ // asynchronous and lets the event loop breathe between targets.
89
+ setTimeout(() => run(), 0)
90
+ }
91
+
92
+ /**
93
+ * Wait for the main UI to be ready.
94
+ *
95
+ * Probe order:
96
+ * 1. ctx.connection.hostDescription (string or function)
97
+ * 2. ctx.connection.host (object with home/name), i.e. the ready handshake
98
+ * 3. any service exposing a boolean `ready`
99
+ * 4. readyFallbackMs timer, so a missing signal can never stall the queue
100
+ *
101
+ * Never hard-codes a host event name.
102
+ */
103
+ export function waitForReady(ctx, fallbackMs, { pollMs = READY_POLL_MS } = {}) {
104
+ return new Promise((resolve) => {
105
+ let settled = false
106
+ let timer = null
107
+ let poll = null
108
+ const finish = (source) => {
109
+ if (settled) return
110
+ settled = true
111
+ if (timer) clearTimeout(timer)
112
+ if (poll) clearInterval(poll)
113
+ resolve(source)
114
+ }
115
+
116
+ const probe = () => {
117
+ const connection = readService(ctx, 'connection')
118
+ if (connection) {
119
+ const description = connection.hostDescription
120
+ const value = typeof description === 'function' ? description() : description
121
+ if (typeof value === 'string' && value.length > 0) {
122
+ finish('connection.hostDescription')
123
+ return true
124
+ }
125
+ const host = connection.host
126
+ if (host && (host.home || host.name || host.id)) {
127
+ finish('connection.host')
128
+ return true
129
+ }
130
+ }
131
+ const app = readService(ctx, 'app') ?? readService(ctx, 'webApp')
132
+ if (app && app.ready === true) {
133
+ finish('app.ready')
134
+ return true
135
+ }
136
+ return false
137
+ }
138
+
139
+ if (probe()) return
140
+
141
+ timer = setTimeout(() => finish('fallback-timeout'), fallbackMs)
142
+ poll = setInterval(() => {
143
+ if (probe()) {
144
+ // finish() clears the interval itself
145
+ }
146
+ }, pollMs)
147
+ })
148
+ }
149
+
150
+ function readService(ctx, name) {
151
+ try {
152
+ if (typeof ctx.get === 'function') {
153
+ const service = ctx.get(name)
154
+ if (service) return service
155
+ }
156
+ } catch {
157
+ // service missing: fall through
158
+ }
159
+ try {
160
+ return ctx[name] ?? null
161
+ } catch {
162
+ return null
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Look for an entry that already represents `target` in the current loader.
168
+ * `loader.create` with an existing id replaces/hot-updates rather than adding a
169
+ * duplicate row, but DSH treats a duplicate entry id in the tree as a hard
170
+ * startup error, so the check must happen before create().
171
+ */
172
+ export function findExistingEntry(loader, target) {
173
+ try {
174
+ const direct = loader.store?.[target.id]
175
+ if (direct) return direct
176
+ } catch {
177
+ // store is not enumerable in some hosts
178
+ }
179
+ try {
180
+ if (typeof loader.entries === 'function') {
181
+ for (const entry of loader.entries()) {
182
+ if (entry?.options?.id === target.id) return entry
183
+ if (entry?.options?.name === target.name) return entry
184
+ }
185
+ }
186
+ } catch {
187
+ // entries() may throw while the tree is mutating; treat as absent
188
+ }
189
+ try {
190
+ if (typeof loader.resolve === 'function') {
191
+ return loader.resolve(target.id)
192
+ }
193
+ } catch {
194
+ // resolve() throws for missing ids: expected
195
+ }
196
+ return null
197
+ }
198
+
199
+ function readFiber(entry) {
200
+ if (!entry) return null
201
+ if (entry.fiber && typeof entry.fiber.dispose === 'function') return entry.fiber
202
+ if (entry.ctx?.fiber && typeof entry.ctx.fiber.dispose === 'function') return entry.ctx.fiber
203
+ return null
204
+ }
205
+
206
+ /**
207
+ * Dispose one mounted target with a hard upper bound.
208
+ *
209
+ * Cordis fiber.dispose() resolves only after every async cleanup settles; a
210
+ * target waiting on a service that never comes back can hang it forever. A
211
+ * hung dispose holding the mutation lock is what freezes the whole UI, so the
212
+ * target is removed from the active table *before* the race and a late
213
+ * rejection gets a sink.
214
+ */
215
+ export async function disposeWithTimeout(target, loader, timeoutMs, log) {
216
+ const entry = target.handle ?? findExistingEntry(loader, target)
217
+ target.handle = null // remove from the active table before awaiting anything
218
+ const fiber = readFiber(entry)
219
+ if (!fiber) {
220
+ try {
221
+ loader.remove?.(target.id)
222
+ } catch (error) {
223
+ log?.warn?.(`dsh-deferred-boot: remove(${target.id}) failed: ${describe(error)}`)
224
+ }
225
+ return 'no-fiber'
226
+ }
227
+
228
+ let disposed = false
229
+ const settle = () => {
230
+ disposed = true
231
+ }
232
+ const disposal = Promise.resolve()
233
+ .then(() => fiber.dispose())
234
+ .then(settle, (error) => {
235
+ settle()
236
+ log?.warn?.(`dsh-deferred-boot: dispose(${target.id}) rejected: ${describe(error)}`)
237
+ })
238
+ // keep a sink on the original promise so a late rejection is never unhandled
239
+ disposal.catch(() => {})
240
+
241
+ const timeout = new Promise((resolve) => setTimeout(resolve, Math.max(1, timeoutMs)))
242
+ const winner = await Promise.race([
243
+ disposal.then(() => 'disposed'),
244
+ timeout.then(() => 'timeout'),
245
+ ])
246
+ if (winner === 'timeout' && !disposed) {
247
+ log?.warn?.(
248
+ `dsh-deferred-boot: dispose(${target.id}) exceeded ${timeoutMs}ms; continuing without waiting`,
249
+ )
250
+ }
251
+ return winner
252
+ }
253
+
254
+ export function describe(error) {
255
+ if (error instanceof Error) return error.message || error.name
256
+ if (typeof error === 'string') return error
257
+ try {
258
+ return JSON.stringify(error)
259
+ } catch {
260
+ return String(error)
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Build the scheduler. `apply()` calls this and returns immediately: no I/O,
266
+ * no awaits, no target imports here.
267
+ */
268
+ export function createDeferredBoot(ctx, rawConfig, { log } = {}) {
269
+ const config = normalizeConfig(rawConfig)
270
+ // Resolve through the service accessor first: a direct ctx.loader read throws
271
+ // on a context whose inject declaration does not cover the loader service.
272
+ const loader = readService(ctx, 'loader')
273
+ const logger = log ?? ctx?.logger
274
+ const active = new Map()
275
+ let started = false
276
+ let readySource = null
277
+ let stopped = false
278
+
279
+ const queue = config.deferredPlugins.filter((t) => t.strategy === 'idle')
280
+ const manual = new Map(
281
+ config.deferredPlugins.filter((t) => t.strategy === 'manual').map((t) => [t.id, t]),
282
+ )
283
+
284
+ for (const target of config.deferredPlugins) {
285
+ if (target.strategy === 'inline-lazy') {
286
+ logger?.info?.(
287
+ `dsh-deferred-boot: ${target.id} is inline-lazy; move its heavy work into an idle init inside its own apply() instead of deferring the mount`,
288
+ )
289
+ }
290
+ if (target.strategy === 'disabled') target.state = 'disabled'
291
+ }
292
+
293
+ function status() {
294
+ if (rebuildFromLoader()) {
295
+ // state was rebuilt from the live loader after an HMR reload
296
+ }
297
+ return config.deferredPlugins.map((t) => ({
298
+ id: t.id,
299
+ strategy: t.strategy,
300
+ state: t.state,
301
+ attempts: t.attempts,
302
+ lastError: t.lastError,
303
+ }))
304
+ }
305
+
306
+ /**
307
+ * Rebuild mount state from the loader itself, so a scheduler HMR reload does
308
+ * not mount an already-mounted target twice. Returns true when it changed
309
+ * anything (used only to keep the call explicit in status()).
310
+ */
311
+ function rebuildFromLoader() {
312
+ if (!loader) return false
313
+ let changed = false
314
+ for (const target of config.deferredPlugins) {
315
+ if (target.state === 'mounted' || target.state === 'disabled') continue
316
+ const entry = findExistingEntry(loader, target)
317
+ if (entry) {
318
+ target.handle = entry
319
+ target.state = 'mounted'
320
+ changed = true
321
+ }
322
+ }
323
+ return changed
324
+ }
325
+
326
+ async function mountOne(target, reason) {
327
+ if (stopped) return
328
+ if (target.state === 'mounted' || target.state === 'disabled' || target.state === 'failed') return
329
+ if (target.strategy === 'manual' && reason !== 'ensure') return
330
+ if (!loader) {
331
+ target.lastError = 'loader service is unavailable'
332
+ target.state = 'failed'
333
+ return
334
+ }
335
+ const existing = findExistingEntry(loader, target)
336
+ if (existing) {
337
+ target.handle = existing
338
+ target.state = 'mounted'
339
+ logger?.info?.(`dsh-deferred-boot: ${target.id} already present; skipped`)
340
+ return
341
+ }
342
+ try {
343
+ const id = await loader.create({ id: target.id, name: target.name, config: target.config })
344
+ target.handle = loader.store?.[id] ?? findExistingEntry(loader, target)
345
+ target.state = 'mounted'
346
+ logger?.info?.(`dsh-deferred-boot: mounted ${target.id} (${reason})`)
347
+ } catch (error) {
348
+ target.attempts += 1
349
+ target.lastError = describe(error)
350
+ const fatal = target.attempts >= MAX_ATTEMPTS
351
+ target.state = fatal ? 'failed' : 'pending'
352
+ logger?.warn?.(
353
+ `dsh-deferred-boot: mount(${target.id}) failed (attempt ${target.attempts}${fatal ? ', frozen' : ', will retry'}): ${target.lastError}`,
354
+ )
355
+ }
356
+ }
357
+
358
+ function start() {
359
+ if (started) return
360
+ started = true
361
+ if (!queue.length && !manual.size) return
362
+ // Defer the first probe so apply() stays synchronous and cheap.
363
+ queueMicrotask(async () => {
364
+ readySource = await waitForReady(ctx, config.readyFallbackMs)
365
+ logger?.info?.(`dsh-deferred-boot: ready via ${readySource}`)
366
+ scheduleSlice(drain)
367
+ })
368
+ }
369
+
370
+ function drain() {
371
+ if (stopped) return
372
+ if (!queue.length) return
373
+ const target = queue[0]
374
+ const pending = target.state === 'pending'
375
+ if (pending && target.attempts < MAX_ATTEMPTS) {
376
+ mountOne(target, 'idle')
377
+ .catch((error) => {
378
+ logger?.warn?.(`dsh-deferred-boot: unexpected mount error: ${describe(error)}`)
379
+ })
380
+ .finally(() => {
381
+ // retry pending target after a failure, but only after idleDelay slices
382
+ if (target.state === 'pending') {
383
+ scheduleSlice(drain)
384
+ } else {
385
+ queue.shift()
386
+ scheduleSlice(drain)
387
+ }
388
+ })
389
+ return
390
+ }
391
+ // terminal or non-idle: drop and continue the queue
392
+ queue.shift()
393
+ scheduleSlice(drain)
394
+ }
395
+
396
+ async function ensure(id) {
397
+ if (stopped) return
398
+ const target = manual.get(id) ?? config.deferredPlugins.find((t) => t.id === id)
399
+ if (!target) {
400
+ logger?.warn?.(`dsh-deferred-boot: ensure(${id}) unknown target`)
401
+ return
402
+ }
403
+ if (target.state === 'mounted') return
404
+ await mountOne(target, 'ensure')
405
+ }
406
+
407
+ async function unmount(id) {
408
+ const target = config.deferredPlugins.find((t) => t.id === id)
409
+ if (!target || !target.handle) return
410
+ await disposeWithTimeout(target, loader, config.disposeTimeout, logger)
411
+ target.state = 'pending'
412
+ }
413
+
414
+ function stop() {
415
+ stopped = true
416
+ }
417
+
418
+ return { start, status, ensure, unmount, stop, rebuildFromLoader, get config() { return config } }
419
+ }