dsh-deferred-boot 0.3.1 → 0.3.2
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 +103 -80
- package/README.md +1 -1
- package/docs/design.md +12 -5
- package/lib/client.js +627 -593
- package/market/index.json +2 -2
- package/package.json +71 -71
- package/src/index.js +65 -3
- package/src/scheduler.js +89 -25
package/market/index.json
CHANGED
package/package.json
CHANGED
|
@@ -1,71 +1,71 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-deferred-boot",
|
|
3
|
-
"version": "0.3.
|
|
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
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-deferred-boot",
|
|
3
|
+
"version": "0.3.2",
|
|
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
|
+
}
|
package/src/index.js
CHANGED
|
@@ -8,10 +8,39 @@
|
|
|
8
8
|
// `plugins.bundle.config` (see lib/client.js), and saving writes the values into
|
|
9
9
|
// the profile patch — the user never edits cordis.patch.yml by hand.
|
|
10
10
|
import Schema from '@deepseek-ai/schemastery'
|
|
11
|
-
import {
|
|
11
|
+
import { appendFileSync } from 'node:fs'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { createDeferredBoot, probeReadySource } from './scheduler.js'
|
|
12
15
|
|
|
13
16
|
export const name = 'dsh-deferred-boot'
|
|
14
17
|
|
|
18
|
+
// Startup trace.
|
|
19
|
+
//
|
|
20
|
+
// `dsh-deferred-boot` is the only plugin that is guaranteed to apply early and
|
|
21
|
+
// that already has a ready probe, which makes it a convenient clock for the
|
|
22
|
+
// host: one line when this plugin applies, one when the host reports ready.
|
|
23
|
+
// The numbers separate "the host took N ms to boot" from "the window took the
|
|
24
|
+
// rest", which is otherwise unmeasurable from inside the process. Written to
|
|
25
|
+
// `~/.dsh/startup-trace.log`; failures are ignored, a trace must never break a
|
|
26
|
+
// boot.
|
|
27
|
+
function traceStartup(ctx, stage, extra) {
|
|
28
|
+
try {
|
|
29
|
+
const now = Date.now()
|
|
30
|
+
const parts = [
|
|
31
|
+
new Date(now).toISOString(),
|
|
32
|
+
stage,
|
|
33
|
+
`process-uptime=${Math.round(process.uptime() * 1000)}ms`,
|
|
34
|
+
]
|
|
35
|
+
const loaderStart = ctx?.loader?.envData?.startTime
|
|
36
|
+
if (typeof loaderStart === 'number') parts.push(`loader-age=${now - loaderStart}ms`)
|
|
37
|
+
if (extra) parts.push(extra)
|
|
38
|
+
appendFileSync(join(homedir(), '.dsh', 'startup-trace.log'), parts.join(' ') + '\n')
|
|
39
|
+
} catch {
|
|
40
|
+
// tracing is best-effort
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
15
44
|
export const Config = Schema.object({
|
|
16
45
|
idleDelay: Schema.number().default(1200).volatile(),
|
|
17
46
|
disposeTimeout: Schema.number().default(10000).volatile(),
|
|
@@ -29,7 +58,34 @@ export const Config = Schema.object({
|
|
|
29
58
|
* @param {{ idleDelay?: number, disposeTimeout?: number, readyFallbackMs?: number, deferredPlugins?: unknown[] }} [config]
|
|
30
59
|
*/
|
|
31
60
|
export function apply(ctx, config = {}) {
|
|
61
|
+
const applyAt = Date.now()
|
|
62
|
+
traceStartup(ctx, 'deferred-boot.apply')
|
|
32
63
|
const scheduler = createDeferredBoot(ctx, config, { log: ctx.logger })
|
|
64
|
+
|
|
65
|
+
// One poller until the host reports ready, then it stops itself. `unref` so a
|
|
66
|
+
// lingering timer can never hold the process open.
|
|
67
|
+
let traceTimer = null
|
|
68
|
+
const stopTrace = () => {
|
|
69
|
+
if (traceTimer) clearInterval(traceTimer)
|
|
70
|
+
traceTimer = null
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
traceTimer = setInterval(() => {
|
|
74
|
+
const source = probeReadySource(ctx)
|
|
75
|
+
if (source !== null) {
|
|
76
|
+
traceStartup(ctx, 'host-ready', `after-apply=${Date.now() - applyAt}ms source=${source}`)
|
|
77
|
+
stopTrace()
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (Date.now() - applyAt > 60000) {
|
|
81
|
+
traceStartup(ctx, 'host-ready-timeout', `after-apply=${Date.now() - applyAt}ms`)
|
|
82
|
+
stopTrace()
|
|
83
|
+
}
|
|
84
|
+
}, 120)
|
|
85
|
+
if (typeof traceTimer.unref === 'function') traceTimer.unref()
|
|
86
|
+
} catch {
|
|
87
|
+
// a timer is optional
|
|
88
|
+
}
|
|
33
89
|
const api = {
|
|
34
90
|
/** Mount a `manual` target on demand. Resolves when the attempt settled. */
|
|
35
91
|
ensure: (id) => scheduler.ensure(id),
|
|
@@ -49,9 +105,15 @@ export function apply(ctx, config = {}) {
|
|
|
49
105
|
}
|
|
50
106
|
|
|
51
107
|
if (typeof ctx.effect === 'function') {
|
|
52
|
-
ctx.effect(() => () =>
|
|
108
|
+
ctx.effect(() => () => {
|
|
109
|
+
stopTrace()
|
|
110
|
+
scheduler.stop()
|
|
111
|
+
})
|
|
53
112
|
} else if (typeof ctx.on === 'function') {
|
|
54
|
-
ctx.on('dispose', () =>
|
|
113
|
+
ctx.on('dispose', () => {
|
|
114
|
+
stopTrace()
|
|
115
|
+
scheduler.stop()
|
|
116
|
+
})
|
|
55
117
|
}
|
|
56
118
|
|
|
57
119
|
// start() only schedules a microtask; apply() itself stays synchronous.
|
package/src/scheduler.js
CHANGED
|
@@ -92,10 +92,13 @@ export function scheduleSlice(run) {
|
|
|
92
92
|
/**
|
|
93
93
|
* Wait for the main UI to be ready.
|
|
94
94
|
*
|
|
95
|
-
* Probe order:
|
|
96
|
-
* 1.
|
|
97
|
-
*
|
|
98
|
-
*
|
|
95
|
+
* Probe order (first hit wins):
|
|
96
|
+
* 1. `typertGateway.hasLiveClient()` — a browser page has attached to the RPC
|
|
97
|
+
* gateway, which is the real `first screen is live` signal on a Web GUI.
|
|
98
|
+
* 2. `webApp.getAdvertisedUrl()` — the Web GUI is being served, even when no
|
|
99
|
+
* page has attached yet (headless).
|
|
100
|
+
* 3. `app.ready === true` / `connection.hostDescription` / `connection.host` —
|
|
101
|
+
* kept for non-Web hosts; none of them exists on the desktop Web GUI.
|
|
99
102
|
* 4. readyFallbackMs timer, so a missing signal can never stall the queue
|
|
100
103
|
*
|
|
101
104
|
* Never hard-codes a host event name.
|
|
@@ -114,26 +117,10 @@ export function waitForReady(ctx, fallbackMs, { pollMs = READY_POLL_MS } = {}) {
|
|
|
114
117
|
}
|
|
115
118
|
|
|
116
119
|
const probe = () => {
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
120
|
+
const source = probeReadySource(ctx)
|
|
121
|
+
if (source === null) return false
|
|
122
|
+
finish(source)
|
|
123
|
+
return true
|
|
137
124
|
}
|
|
138
125
|
|
|
139
126
|
if (probe()) return
|
|
@@ -147,6 +134,46 @@ export function waitForReady(ctx, fallbackMs, { pollMs = READY_POLL_MS } = {}) {
|
|
|
147
134
|
})
|
|
148
135
|
}
|
|
149
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Return the name of the first readiness signal that is currently satisfied,
|
|
139
|
+
* or `null`. Exported so the startup trace can reuse the exact same probe
|
|
140
|
+
* instead of keeping a second, drifting copy.
|
|
141
|
+
*/
|
|
142
|
+
export function probeReadySource(ctx) {
|
|
143
|
+
const gateway = readService(ctx, 'typertGateway')
|
|
144
|
+
if (gateway && typeof gateway.hasLiveClient === 'function') {
|
|
145
|
+
try {
|
|
146
|
+
if (gateway.hasLiveClient() === true) return 'typertGateway.hasLiveClient'
|
|
147
|
+
} catch {
|
|
148
|
+
// gateway not ready: keep probing
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const webApp = readService(ctx, 'webApp')
|
|
153
|
+
if (webApp && typeof webApp.getAdvertisedUrl === 'function') {
|
|
154
|
+
try {
|
|
155
|
+
const url = webApp.getAdvertisedUrl()
|
|
156
|
+
if (typeof url === 'string' && url.length > 0) return 'webApp.getAdvertisedUrl'
|
|
157
|
+
} catch {
|
|
158
|
+
// server not bound yet: keep probing
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const app = readService(ctx, 'app')
|
|
163
|
+
if (app && app.ready === true) return 'app.ready'
|
|
164
|
+
|
|
165
|
+
const connection = readService(ctx, 'connection')
|
|
166
|
+
if (connection) {
|
|
167
|
+
const description = connection.hostDescription
|
|
168
|
+
const value = typeof description === 'function' ? description() : description
|
|
169
|
+
if (typeof value === 'string' && value.length > 0) return 'connection.hostDescription'
|
|
170
|
+
const host = connection.host
|
|
171
|
+
if (host && (host.home || host.name || host.id)) return 'connection.host'
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
|
|
150
177
|
function readService(ctx, name) {
|
|
151
178
|
try {
|
|
152
179
|
if (typeof ctx.get === 'function') {
|
|
@@ -193,6 +220,17 @@ export function findExistingEntry(loader, target) {
|
|
|
193
220
|
} catch {
|
|
194
221
|
// resolve() throws for missing ids: expected
|
|
195
222
|
}
|
|
223
|
+
// A bundle patch may already have inserted this module into the `include`
|
|
224
|
+
// group, where it lives under the full id `include:<id>`. Creating a root
|
|
225
|
+
// entry with the short id next to it would be a second copy of the same
|
|
226
|
+
// module — and a duplicate id as soon as the group prefix is in play.
|
|
227
|
+
try {
|
|
228
|
+
if (typeof loader.resolve === 'function') {
|
|
229
|
+
return loader.resolve(`include:${target.id}`)
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
// not nested: expected when the bundle was removed from the profile
|
|
233
|
+
}
|
|
196
234
|
return null
|
|
197
235
|
}
|
|
198
236
|
|
|
@@ -323,6 +361,32 @@ export function createDeferredBoot(ctx, rawConfig, { log } = {}) {
|
|
|
323
361
|
return changed
|
|
324
362
|
}
|
|
325
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Mount one entry the way a bundle patch would.
|
|
366
|
+
*
|
|
367
|
+
* Bundle patches register their plugins inside the `include` group, and
|
|
368
|
+
* `loader.create(options, 'include')` is exactly the call that group performs
|
|
369
|
+
* on its own config, so the deferred entry lands under the familiar
|
|
370
|
+
* `include:<id>` name and a later bundle re-add hot-updates it in place
|
|
371
|
+
* instead of leaving a second copy of the same module in the tree.
|
|
372
|
+
*
|
|
373
|
+
* The group only exists while a bundle provides `cordis:include`; a
|
|
374
|
+
* hand-written profile may not have one, so fall back to the root group.
|
|
375
|
+
* A create that fails *after* inserting still leaves the entry behind, hence
|
|
376
|
+
* the re-check before the fallback.
|
|
377
|
+
*/
|
|
378
|
+
async function createMounted(loader, target) {
|
|
379
|
+
const options = { id: target.id, name: target.name, config: target.config }
|
|
380
|
+
try {
|
|
381
|
+
return await loader.create(options, 'include')
|
|
382
|
+
} catch (error) {
|
|
383
|
+
const nested = findExistingEntry(loader, target)
|
|
384
|
+
if (nested) return nested.id
|
|
385
|
+
logger?.info?.(`dsh-deferred-boot: include group unavailable for ${target.id}: ${describe(error)}`)
|
|
386
|
+
return await loader.create(options)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
326
390
|
async function mountOne(target, reason) {
|
|
327
391
|
if (stopped) return
|
|
328
392
|
if (target.state === 'mounted' || target.state === 'disabled' || target.state === 'failed') return
|
|
@@ -340,7 +404,7 @@ export function createDeferredBoot(ctx, rawConfig, { log } = {}) {
|
|
|
340
404
|
return
|
|
341
405
|
}
|
|
342
406
|
try {
|
|
343
|
-
const id = await loader
|
|
407
|
+
const id = await createMounted(loader, target)
|
|
344
408
|
target.handle = loader.store?.[id] ?? findExistingEntry(loader, target)
|
|
345
409
|
target.state = 'mounted'
|
|
346
410
|
logger?.info?.(`dsh-deferred-boot: mounted ${target.id} (${reason})`)
|