dsh-deferred-boot 0.3.0 → 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/market/index.json CHANGED
@@ -35,8 +35,8 @@
35
35
  },
36
36
  "versions": [
37
37
  {
38
- "version": "0.3.0",
39
- "publishTimestamp": 1794044331,
38
+ "version": "0.3.2",
39
+ "publishTimestamp": 1794054744,
40
40
  "source": {
41
41
  "type": "npm"
42
42
  },
package/package.json CHANGED
@@ -1,71 +1,71 @@
1
- {
2
- "name": "dsh-deferred-boot",
3
- "version": "0.3.0",
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 { createDeferredBoot } from './scheduler.js'
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(() => () => scheduler.stop())
108
+ ctx.effect(() => () => {
109
+ stopTrace()
110
+ scheduler.stop()
111
+ })
53
112
  } else if (typeof ctx.on === 'function') {
54
- ctx.on('dispose', () => scheduler.stop())
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. 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`
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 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
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.create({ id: target.id, name: target.name, config: target.config })
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})`)