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/CHANGELOG.md +80 -0
- package/LICENSE +21 -0
- package/README.md +95 -2
- package/cordis.patch.yml +22 -0
- package/docs/PUBLISHING.md +58 -0
- package/docs/design.md +78 -0
- package/icon.svg +7 -0
- package/lib/client.js +593 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/market/index.json +50 -0
- package/package.json +69 -4
- package/scripts/gen-market-index.mjs +55 -0
- package/src/index.js +60 -0
- package/src/scheduler.js +419 -0
package/package.json
CHANGED
|
@@ -1,6 +1,71 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-deferred-boot",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/scheduler.js
ADDED
|
@@ -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
|
+
}
|