dsh-deferred-boot 0.0.0-stage → 0.3.0
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 +56 -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 +228 -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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
|
+
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.3.0] - 2026-11-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Per-field hints in the configuration form (zh/en), explaining what each
|
|
11
|
+
value changes.
|
|
12
|
+
- Plugin-market publishing: `scripts/gen-market-index.mjs` generates a
|
|
13
|
+
format-1 market index (`market/index.json`), and `docs/PUBLISHING.md`
|
|
14
|
+
documents the npm + index-source flow.
|
|
15
|
+
- npm metadata: keywords, author, engines, files whitelist, and
|
|
16
|
+
`@deepseek-ai/schemastery` as a runtime dependency.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- The form no longer depends on `props.ctx`; it reads the ctx captured in
|
|
20
|
+
`apply()`, matching the reference web-search card.
|
|
21
|
+
- README rewritten for the plugin page and npm: badges, install steps,
|
|
22
|
+
configuration table, development layout.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- Configuration form showing "配置服务暂不可用" on newer DSH clients.
|
|
26
|
+
|
|
27
|
+
## [0.2.0] - 2026-11-07
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- Graphical configuration form on the plugin page (`plugins.bundle.config`),
|
|
31
|
+
backed by the `deferred-boot` settings namespace; values are saved to the
|
|
32
|
+
current profile's Cordis patch, so `cordis.patch.yml` no longer has to be
|
|
33
|
+
edited by hand.
|
|
34
|
+
- `Config` schema with four volatile fields: `idleDelay`, `disposeTimeout`,
|
|
35
|
+
`readyFallbackMs`, `deferredPlugins`.
|
|
36
|
+
- Locale dictionaries (zh/en) for the plugin page and the form.
|
|
37
|
+
- Icon, LICENSE (MIT), CI workflow, and packaging scripts.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- `cannot set property "deferredBoot" without provide`: the service is now
|
|
41
|
+
exposed with `ctx.provide('deferredBoot', api)`, which the strict Cordis 4
|
|
42
|
+
context requires.
|
|
43
|
+
|
|
44
|
+
## [0.1.0] - 2026-11-07
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
- Initial scheduler: mounts deferred plugins after the main UI is ready, one
|
|
48
|
+
target per idle slice.
|
|
49
|
+
- Existence check before `loader.create`, per-target failure isolation,
|
|
50
|
+
`dispose` raced against `disposeTimeout`, three failed attempts freeze a
|
|
51
|
+
target as `failed`, and manual `ctx.deferredBoot.ensure(id)` mounting.
|
|
52
|
+
- Strategies: `idle`, `manual`, `inline-lazy`, `disabled`.
|
|
53
|
+
- Tests for the scheduler, config normalization, and the client module.
|
|
54
|
+
|
|
55
|
+
[0.2.0]: https://github.com/your-name/dsh-deferred-boot/releases/tag/v0.2.0
|
|
56
|
+
[0.1.0]: https://github.com/your-name/dsh-deferred-boot/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BuSheng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,96 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-deferred-boot
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](#)
|
|
5
|
+
[](#)
|
|
6
|
+
|
|
7
|
+
把指定插件推迟到**主界面就绪之后**再挂载,一次空闲切片只挂一个,避免插件成批初始化拖住首屏。只搬运挂载时机,不修改目标插件。
|
|
8
|
+
|
|
9
|
+
## 特性
|
|
10
|
+
|
|
11
|
+
- 就绪后再调度:优先 `connection.hostDescription`,退化为 `connection.host`、`app.ready` 轮询与兜底超时
|
|
12
|
+
- 分段空闲调度:`scheduler.postTask(background)` → `requestIdleCallback` → `setTimeout`,一次回调只挂一个目标
|
|
13
|
+
- 安全挂载:`loader.create` 前检查同名/同 id 条目,已存在则跳过并标记 `mounted`
|
|
14
|
+
- 故障隔离:每个目标独立 `try/catch`,连续 3 次失败冻结为 `failed`,不影响其余目标
|
|
15
|
+
- 卸载不挂死:`dispose` 与 `disposeTimeout` 竞速,超时后移出活跃表,迟到的 rejection 挂空处理
|
|
16
|
+
- 手动模式:`ctx.deferredBoot.ensure(id)` 随时补挂 `manual` 目标
|
|
17
|
+
- 图形配置:插件详情页直接改参数,保存写入当前 profile 的 Cordis patch
|
|
18
|
+
- 中英双语:插件页标题/描述与表单文案随界面语言切换
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
**本地包**(自带 `node_modules`,离线可用):
|
|
23
|
+
|
|
24
|
+
1. 解压到任意目录
|
|
25
|
+
2. 运行 `install.ps1`(自动定位 profile、建 `node_modules` 链接、写入 profile 的 `package.json`)
|
|
26
|
+
3. 重启 DSH
|
|
27
|
+
|
|
28
|
+
**npm**(发布后):
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
dsh plugin add dsh-deferred-boot
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
发布到插件市场(npm + 索引源)见 [docs/PUBLISHING.md](docs/PUBLISHING.md)。
|
|
35
|
+
|
|
36
|
+
## 配置
|
|
37
|
+
|
|
38
|
+
侧栏「插件」→ **dsh-deferred-boot** 详情页 → 页面描述下方的配置表单,保存即生效,不用手改配置文件。表单里每个字段下方都有同样的用途说明。
|
|
39
|
+
|
|
40
|
+
| 字段 | 默认 | 说明 |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| `idleDelay` | 1200 | 就绪后到首次调度的延迟(毫秒)。调大更晚开始、首屏更稳;调小更早挂载目标。 |
|
|
43
|
+
| `disposeTimeout` | 10000 | 单个目标 dispose 的硬上界(毫秒)。超时视为已卸载并继续,防止热重载卡住调度。 |
|
|
44
|
+
| `readyFallbackMs` | 5000 | 就绪信号兜底超时(毫秒)。没有就绪信号时,最多等这么久就按已就绪处理。 |
|
|
45
|
+
| `deferredPlugins` | `[]` | JSON 数组,每项 `{ id, name, strategy, config }`。 |
|
|
46
|
+
|
|
47
|
+
`strategy`:
|
|
48
|
+
|
|
49
|
+
- `idle`:就绪后由分段空闲调度自动挂载
|
|
50
|
+
- `manual`:不自动挂载,调用 `ctx.deferredBoot.ensure(id)` 才挂载
|
|
51
|
+
- `inline-lazy`:不搬,只在日志里提示「目标应在自身 apply 内做 idle 初始化」
|
|
52
|
+
- `disabled`:跳过
|
|
53
|
+
|
|
54
|
+
保存后切换插件开关重载(或重启 DSH)使调度参数生效。等价 YAML:
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
- id: deferred-boot
|
|
58
|
+
name: dsh-deferred-boot
|
|
59
|
+
config:
|
|
60
|
+
idleDelay: 1200
|
|
61
|
+
disposeTimeout: 10000
|
|
62
|
+
readyFallbackMs: 5000
|
|
63
|
+
deferredPlugins:
|
|
64
|
+
- id: heavy-plugin
|
|
65
|
+
name: heavy-plugin
|
|
66
|
+
strategy: idle
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## 边界
|
|
70
|
+
|
|
71
|
+
本插件只搬挂载时机。目标插件 `apply` **内部**无法拆分的同步 CPU 长任务不会被拆分——那需要目标插件自己改成 idle 初始化。
|
|
72
|
+
|
|
73
|
+
## 开发
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
├── src/ host 半侧:index.js(apply / Config)、scheduler.js(调度器)
|
|
77
|
+
├── lib/client.js 浏览器半侧:插件详情页配置表单
|
|
78
|
+
├── locale/ zh/en 插件页标题与描述
|
|
79
|
+
├── test/ node:test 单元测试(调度器 / 配置 / 客户端模块)
|
|
80
|
+
├── docs/design.md 实现取舍、存在性检查、dispose 竞速、验收自检
|
|
81
|
+
├── cordis.patch.yml bundle 补丁
|
|
82
|
+
├── install.ps1 本地安装
|
|
83
|
+
├── pack.ps1 打包 zip
|
|
84
|
+
└── icon.svg 插件图标
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
node --test test/*.test.mjs # 运行测试
|
|
89
|
+
./pack.cmd # 重新打 zip 包
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
实现细节与已知限制见 [docs/design.md](docs/design.md)。
|
|
93
|
+
|
|
94
|
+
## 许可证
|
|
95
|
+
|
|
96
|
+
[MIT](LICENSE)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# dsh-deferred-boot bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# One insert layer over the profile root, following the bundle patch dialect:
|
|
4
|
+
# a patch is a mapping with `insert: [rows]`. The row mounts the scheduler
|
|
5
|
+
# itself; it is deliberately NOT marked `immediately: true`, so mounting this
|
|
6
|
+
# plugin never pulls heavy work into the startup window.
|
|
7
|
+
#
|
|
8
|
+
# Target plugins are listed under `deferredPlugins` and are mounted by the
|
|
9
|
+
# scheduler after the main UI is ready. Replace the empty list with real rows:
|
|
10
|
+
#
|
|
11
|
+
# - id: stats-panel
|
|
12
|
+
# name: "@scope/plugin-stats"
|
|
13
|
+
# config: {}
|
|
14
|
+
# strategy: idle
|
|
15
|
+
- insert:
|
|
16
|
+
- id: deferred-boot
|
|
17
|
+
name: dsh-deferred-boot
|
|
18
|
+
config:
|
|
19
|
+
idleDelay: 1200
|
|
20
|
+
disposeTimeout: 10000
|
|
21
|
+
readyFallbackMs: 5000
|
|
22
|
+
deferredPlugins: []
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# 发布到插件市场
|
|
2
|
+
|
|
3
|
+
DSH 的「插件 → 发现」不是中心化官方市场:每个用户自己添加**插件索引源**(一个 HTTPS URL),市场读取其中的 JSON(格式 1)并列表、安装其中的组合包。所以「发布出去让别人能下载」= **发布包** + **托管一份索引 JSON** + **把索引 URL 给用户**。
|
|
4
|
+
|
|
5
|
+
目前没有官方插件索引;索引由发布者自己托管(GitHub Pages、gist raw、对象存储、任意 HTTPS 静态服务)。
|
|
6
|
+
|
|
7
|
+
## 1. 发布到 npm
|
|
8
|
+
|
|
9
|
+
包名 `dsh-deferred-boot` 已确认在 npm 上空闲。
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm login # 或配置 npm token / .npmrc
|
|
13
|
+
npm publish --access public # 首次发布
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- `package.json` 已声明 `dependencies: { "@deepseek-ai/schemastery": "^3.18.5" }`,npm/pnpm 安装时会自动拉取;本地 `node_modules` 不进 npm 包(`files` 白名单里没有)。
|
|
17
|
+
- 以后升级:`npm version patch`(或 minor/major)→ `npm publish` → 重新生成索引(见第 2 步)。
|
|
18
|
+
- 若临时不发 npm,也可以把索引的 `source` 改成 `{ "type": "git", "url": "https://github.com/<you>/dsh-deferred-boot", "commit": "<40 位 sha>" }`,但 git 方式要求仓库里带 `node_modules`(否则 `@deepseek-ai/schemastery` 缺失),体积较大,不推荐。
|
|
19
|
+
|
|
20
|
+
## 2. 生成市场索引
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
node scripts/gen-market-index.mjs
|
|
24
|
+
# 可选:node scripts/gen-market-index.mjs --index-url https://<you>.github.io/dsh-deferred-boot/index.json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
输出 `market/index.json`,格式 1,包含插件名、中英文标题/描述、内联图标(data: URL)、作者、关键词、版本列表(`source.type = "npm"`)与 `engines.dsh`。
|
|
28
|
+
|
|
29
|
+
## 3. 托管索引
|
|
30
|
+
|
|
31
|
+
任选一种:
|
|
32
|
+
|
|
33
|
+
- **GitHub Pages**:新建仓库 → 把 `market/index.json` 放到根目录 → Settings → Pages → 选分支 → 得到 `https://<you>.github.io/<repo>/index.json`。
|
|
34
|
+
- **Gist**:新建 gist 放入 `index.json` → 用 `https://gist.githubusercontent.com/.../raw/index.json`。
|
|
35
|
+
- **任意静态托管**:对象存储 / nginx / Cloudflare Pages 等,要求 HTTPS、可被匿名 GET。
|
|
36
|
+
|
|
37
|
+
## 4. 别人怎么安装
|
|
38
|
+
|
|
39
|
+
1. DSH 侧栏 **插件** → **发现** 标签页;
|
|
40
|
+
2. 点 **添加索引源**,粘贴第 3 步的索引 URL;
|
|
41
|
+
3. 在列表里找到 **延迟启动 / Deferred Boot**,点 **安装**,确认后安装并重启 DSH。
|
|
42
|
+
|
|
43
|
+
## 5. 版本更新
|
|
44
|
+
|
|
45
|
+
1. 改版本号并 `npm publish`;
|
|
46
|
+
2. `node scripts/gen-market-index.mjs`(会写入新的 `publishTimestamp` 与版本);
|
|
47
|
+
3. 推送新的 `market/index.json` 到托管位置;
|
|
48
|
+
4. 用户刷新「发现」即可看到新版本,点「有新版本」升级。
|
|
49
|
+
|
|
50
|
+
## 校验索引
|
|
51
|
+
|
|
52
|
+
DSH 用 `@deepseek-ai/dsh-plugin-market-index` 的 `parseMarketIndex` 校验索引(格式 1)。发布前可用任意安装该包的 Node 环境自检:
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { parseMarketIndex } from '@deepseek-ai/dsh-plugin-market-index'
|
|
56
|
+
const index = parseMarketIndex(JSON.parse(await readFile('market/index.json', 'utf8')), 'https://example.com/index.json')
|
|
57
|
+
console.log(index.plugins.map((p) => p.name))
|
|
58
|
+
```
|
package/docs/design.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# dsh-deferred-boot 实现细节
|
|
2
|
+
|
|
3
|
+
## 工程约束与实现取舍
|
|
4
|
+
|
|
5
|
+
### 挂载前存在性检查
|
|
6
|
+
|
|
7
|
+
`duplicate loader entry id` 在 DSH 中是硬错误:Loader 应用配置后一旦出现重复 id 会直接拒绝启动,没有可捕获的恢复路径。因此 `create()` 之前必须检查:
|
|
8
|
+
|
|
9
|
+
1. `loader.store[target.id]`
|
|
10
|
+
2. `loader.entries()` 中 `options.id === target.id` 或 `options.name === target.name`
|
|
11
|
+
3. `loader.resolve(target.id)`(缺失会抛错,属预期)
|
|
12
|
+
|
|
13
|
+
命中任一即标记 `mounted` 并跳过,绝不再挂第二份。
|
|
14
|
+
|
|
15
|
+
### 每个 create 独立 try/catch
|
|
16
|
+
|
|
17
|
+
单个目标失败只记录 `attempts`/`lastError` 并继续下一个;连续 3 次失败标记 `failed` 后停止重试,调度循环不中断、异常不冒泡到启动路径。
|
|
18
|
+
|
|
19
|
+
### dispose 与超时竞速
|
|
20
|
+
|
|
21
|
+
`fiber.dispose()` 会等到所有异步清理兑现才 resolve;目标卡在等一个永不返回的服务时,`dispose()` 会无限挂起。卡死的 dispose 持有 mutation lock 时,整个界面会「点了没反应」。
|
|
22
|
+
|
|
23
|
+
本插件的做法:先把目标从活跃表移除(`target.handle = null`),再让 `dispose()` 与 `disposeTimeout` 竞速;超时只记日志并继续。原始 Promise 始终挂一个 `catch`,晚到的 rejection 不会变成 unhandled rejection。
|
|
24
|
+
|
|
25
|
+
### 分段调度
|
|
26
|
+
|
|
27
|
+
一次空闲回调**只处理一个目标**,剩余目标留给下一次调度。调度原语优先级:
|
|
28
|
+
|
|
29
|
+
1. `scheduler.postTask({ priority: "background" })`
|
|
30
|
+
2. `requestIdleCallback(fn, { timeout: 1000 })`
|
|
31
|
+
3. `setTimeout(fn, 0)`(host 端 Node 没有前两者时使用)
|
|
32
|
+
|
|
33
|
+
### 就绪等待的多重兜底
|
|
34
|
+
|
|
35
|
+
1. `ctx.connection.hostDescription`(字符串或函数)
|
|
36
|
+
2. `ctx.connection.host`(ready 握手发布的 `{ home }` 对象,流一断即清空)
|
|
37
|
+
3. 任一暴露 `ready === true` 的服务(`app` / `webApp`)
|
|
38
|
+
4. `readyFallbackMs` 计时兜底
|
|
39
|
+
|
|
40
|
+
不硬编码任何宿主事件名。
|
|
41
|
+
|
|
42
|
+
### HMR 重载后的状态重建
|
|
43
|
+
|
|
44
|
+
插件重载后从当前 loader 的存在性检查重建状态(`status()` 内部调用 `rebuildFromLoader()`),不会把已挂载目标再挂一遍。
|
|
45
|
+
|
|
46
|
+
### 目标依赖在延迟窗口内消失
|
|
47
|
+
|
|
48
|
+
调度器不负责替目标等待依赖;目标自身必须正确声明 `inject`。延迟挂载会暴露「假设服务启动时一定在」的脆弱性,这是目标插件的问题。
|
|
49
|
+
|
|
50
|
+
### 卸载清理
|
|
51
|
+
|
|
52
|
+
通过 `ctx.effect(() => () => scheduler.stop())` 注册清理,插件卸载后无残留定时器/观察者。
|
|
53
|
+
|
|
54
|
+
## 图形界面实现
|
|
55
|
+
|
|
56
|
+
- Host 侧 `Config` 的四个字段声明为 `.volatile()`;`src/scheduler.js` 在 `normalizeConfig` 里解包 volatile 引用(`get()`)后再使用。
|
|
57
|
+
- 客户端 `lib/client.js` 通过 `ctx.configForms.whileServed(['deferred-boot'], ...)` 等 settings 命名空间就绪,再向 `plugins.bundle.config` 槽位注册表单(key 为包名 `dsh-deferred-boot`);保存走 `ctx.configForms.mutate`,一次提交四个字段。
|
|
58
|
+
- `package.json` 的 `dsh.client.inject` 列出客户端依赖包,`exports["./client"]` 指向 `lib/client.js`。
|
|
59
|
+
|
|
60
|
+
## 验收自检
|
|
61
|
+
|
|
62
|
+
`test/scheduler.test.mjs` 覆盖:
|
|
63
|
+
|
|
64
|
+
- [x] 本插件不设 `immediately: true`;`apply` 内无 I/O、计算、`await`,同步返回
|
|
65
|
+
- [x] 目标已存在于 loader:跳过并标记 `mounted`,不产生重复 entry
|
|
66
|
+
- [x] 单个目标 `loader.create` 抛错:其余目标正常挂载,该目标为 `failed`
|
|
67
|
+
- [x] 目标 `dispose()` 永不结算:`disposeTimeout` 后调度器继续,晚到 reject 不变成 unhandled rejection
|
|
68
|
+
- [x] 就绪信号缺失时,`readyFallbackMs` 后仍能继续调度
|
|
69
|
+
- [x] `manual` 目标在 `ensure(id)` 前不挂载
|
|
70
|
+
- [x] 调度器自身被重载后,不会把已挂载目标再挂一遍
|
|
71
|
+
|
|
72
|
+
`test/config.test.mjs` 覆盖 Config 的 volatile 解析与调度器归一化;`test/client.test.mjs` 覆盖客户端模块加载、slot 注册与表单首次渲染。
|
|
73
|
+
|
|
74
|
+
## 已知限制
|
|
75
|
+
|
|
76
|
+
- `hostDescription` 在当前 checkout 的 connection 服务上未以该名字导出;实际可用信号是 `host`(ready 握手对象)。探测顺序已按仓库现状适配。
|
|
77
|
+
- 调度器只负责挂载时机;目标插件在 `apply` 内的同步重活不会被本插件拆分。
|
|
78
|
+
- 图形界面依赖 settings 服务;settings 不可用时表单显示「配置服务尚未就绪」,插件本身仍正常工作。
|
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none">
|
|
2
|
+
<rect x="2" y="2" width="60" height="60" rx="14" fill="#4D6BFE"/>
|
|
3
|
+
<circle cx="32" cy="34" r="13" stroke="#FFFFFF" stroke-width="3.5"/>
|
|
4
|
+
<path d="M32 26.5V34L37 37" stroke="#FFFFFF" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
5
|
+
<path d="M13 18H23M13 24H20" stroke="#FFFFFF" stroke-opacity="0.55" stroke-width="3" stroke-linecap="round"/>
|
|
6
|
+
<path d="M43 22L47 18M47 22L43 18" stroke="#FFFFFF" stroke-width="3" stroke-linecap="round"/>
|
|
7
|
+
</svg>
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Client half of dsh-deferred-boot.
|
|
2
|
+
//
|
|
3
|
+
// It renders the plugin's form on the plugin page (the bundle-config slot of
|
|
4
|
+
// dsh-client-ui-plugin-manager) and reads/writes the `deferred-boot` settings
|
|
5
|
+
// namespace through ctx.configForms. The host half (src/index.js) declares the
|
|
6
|
+
// four fields as volatile so this form is the only place they are edited.
|
|
7
|
+
window.__ModuleLoader__.load({
|
|
8
|
+
id: 'dsh-deferred-boot',
|
|
9
|
+
factory: (require) => {
|
|
10
|
+
var module = { exports: {} }
|
|
11
|
+
var exports = module.exports
|
|
12
|
+
const React = require('react')
|
|
13
|
+
const h = React.createElement
|
|
14
|
+
|
|
15
|
+
const styles = {
|
|
16
|
+
root: { display: 'flex', flexDirection: 'column', gap: '6px', fontSize: 13, lineHeight: 1.5 },
|
|
17
|
+
intro: { margin: '0 0 10px', color: 'var(--dsh-text-2, #c8c8c8)' },
|
|
18
|
+
row: { display: 'flex', flexDirection: 'column', gap: '3px', padding: '6px 0' },
|
|
19
|
+
rowMain: { display: 'flex', alignItems: 'center', gap: 12 },
|
|
20
|
+
label: { width: 156, flex: '0 0 156px', color: 'var(--dsh-text-2, #c8c8c8)' },
|
|
21
|
+
input: {
|
|
22
|
+
flex: '0 1 240px',
|
|
23
|
+
minWidth: 120,
|
|
24
|
+
padding: '4px 8px',
|
|
25
|
+
borderRadius: 6,
|
|
26
|
+
border: '1px solid var(--dsh-border, #3a3a3a)',
|
|
27
|
+
background: 'var(--dsh-surface, #1f1f1f)',
|
|
28
|
+
color: 'inherit',
|
|
29
|
+
font: 'inherit',
|
|
30
|
+
},
|
|
31
|
+
hint: { margin: '0 0 0 168px', fontSize: 12, lineHeight: 1.5, color: 'var(--dsh-text-3, #8b8b8b)' },
|
|
32
|
+
note: { margin: '0 0 0 168px', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' },
|
|
33
|
+
textarea: {
|
|
34
|
+
width: '100%',
|
|
35
|
+
minHeight: 96,
|
|
36
|
+
padding: '6px 8px',
|
|
37
|
+
borderRadius: 6,
|
|
38
|
+
border: '1px solid var(--dsh-border, #3a3a3a)',
|
|
39
|
+
background: 'var(--dsh-surface, #1f1f1f)',
|
|
40
|
+
color: 'inherit',
|
|
41
|
+
font: 'inherit',
|
|
42
|
+
resize: 'vertical',
|
|
43
|
+
},
|
|
44
|
+
actions: { display: 'flex', alignItems: 'center', gap: 10, marginTop: 8 },
|
|
45
|
+
button: {
|
|
46
|
+
padding: '4px 14px',
|
|
47
|
+
borderRadius: 6,
|
|
48
|
+
border: '1px solid var(--dsh-accent, #4d6bfe)',
|
|
49
|
+
background: 'var(--dsh-accent, #4d6bfe)',
|
|
50
|
+
color: '#fff',
|
|
51
|
+
cursor: 'pointer',
|
|
52
|
+
font: 'inherit',
|
|
53
|
+
},
|
|
54
|
+
status: { fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' },
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ZH = {
|
|
58
|
+
intro: '把指定的插件推迟到主界面就绪之后再挂载,一次空闲切片只挂一个,避免插件成批初始化把首屏拖住。',
|
|
59
|
+
'field.idleDelay': '就绪后延迟 (ms)',
|
|
60
|
+
'field.disposeTimeout': 'dispose 超时 (ms)',
|
|
61
|
+
'field.readyFallbackMs': '就绪兜底超时 (ms)',
|
|
62
|
+
'field.deferredPlugins': '延迟挂载的插件',
|
|
63
|
+
'plugins.label': '延迟挂载的插件(JSON 数组,每项 { id, name, strategy, config })',
|
|
64
|
+
'plugins.hint': 'strategy: idle(就绪后自动挂载)/ manual(调用 deferredBoot.ensure(id) 才挂载)/ inline-lazy / disabled。',
|
|
65
|
+
'hint.idleDelay': '主界面就绪后等待多久才开始第一次空闲调度(毫秒)。调大更晚开始、首屏更稳;调小更早挂载目标插件。',
|
|
66
|
+
'hint.disposeTimeout': '卸载或热重载目标插件时最多等待多久(毫秒)。超时视为已卸载并继续,防止卸载卡住调度。',
|
|
67
|
+
'hint.readyFallbackMs': '等不到“主界面已就绪”信号时的兜底超时(毫秒)。超时后按已就绪处理,继续延迟挂载。',
|
|
68
|
+
'hint.plugins': '每项一个目标插件:id 为 loader 条目 id,name 为模块名,strategy 决定挂载方式,config 传给目标插件。',
|
|
69
|
+
save: '保存',
|
|
70
|
+
saving: '保存中…',
|
|
71
|
+
saved: '已保存,下一次操作起生效。',
|
|
72
|
+
conflict: '配置已被其他窗口修改,已重新载入,请再保存一次。',
|
|
73
|
+
failed: '保存失败,请查看 Host 日志。',
|
|
74
|
+
badJson: '延迟插件不是合法 JSON,请修正后再保存。',
|
|
75
|
+
notArray: '延迟插件必须是 JSON 数组。',
|
|
76
|
+
unavailable: '配置服务暂不可用(需要新版 DSH 客户端)。',
|
|
77
|
+
note: '配置写入当前 profile 的 Cordis patch,无需手动编辑 cordis.patch.yml。',
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const EN = {
|
|
81
|
+
intro: 'Mount the listed plugins after the main UI is ready, one idling slice at a time, so a batch of heavy plugins cannot hold up the first screen.',
|
|
82
|
+
'field.idleDelay': 'Delay after ready (ms)',
|
|
83
|
+
'field.disposeTimeout': 'Dispose timeout (ms)',
|
|
84
|
+
'field.readyFallbackMs': 'Ready fallback (ms)',
|
|
85
|
+
'field.deferredPlugins': 'Deferred plugins',
|
|
86
|
+
'plugins.label': 'Deferred plugins (JSON array, each { id, name, strategy, config })',
|
|
87
|
+
'plugins.hint': 'strategy: idle (mount after ready) / manual (wait for deferredBoot.ensure(id)) / inline-lazy / disabled.',
|
|
88
|
+
'hint.idleDelay': 'How long to wait after the main UI is ready before the first idle slice (ms). Larger waits longer for a steadier first paint; smaller mounts targets sooner.',
|
|
89
|
+
'hint.disposeTimeout': 'Maximum time to wait while unloading or hot-reloading a target (ms). On timeout the target is treated as unmounted and scheduling continues.',
|
|
90
|
+
'hint.readyFallbackMs': 'Fallback timeout for “main UI ready” (ms). When no ready signal arrives, mounting starts after this delay.',
|
|
91
|
+
'hint.plugins': 'One entry per deferred plugin: id is the loader entry id, name is the module name, strategy controls how it mounts, config is passed to the target.',
|
|
92
|
+
save: 'Save',
|
|
93
|
+
saving: 'Saving…',
|
|
94
|
+
saved: 'Saved; takes effect from the next operation.',
|
|
95
|
+
conflict: 'The settings changed in another window. Reloaded — please save again.',
|
|
96
|
+
failed: 'Save failed; check the host log.',
|
|
97
|
+
badJson: 'Deferred plugins is not valid JSON.',
|
|
98
|
+
notArray: 'Deferred plugins must be a JSON array.',
|
|
99
|
+
unavailable: 'The config form service is unavailable (a newer DSH client is required).',
|
|
100
|
+
note: 'The change is written to the current profile Cordis patch; cordis.patch.yml stays untouched.',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function Row(props) {
|
|
104
|
+
return h(
|
|
105
|
+
'div',
|
|
106
|
+
{ style: styles.row },
|
|
107
|
+
h('div', { style: styles.rowMain }, h('span', { style: styles.label }, props.label), props.children),
|
|
108
|
+
props.hint ? h('p', { style: styles.hint }, props.hint) : null,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function useSnapshot(form) {
|
|
113
|
+
const [snapshot, setSnapshot] = React.useState(() => form.getSnapshot())
|
|
114
|
+
React.useEffect(() => {
|
|
115
|
+
setSnapshot(form.getSnapshot())
|
|
116
|
+
return form.subscribe(setSnapshot)
|
|
117
|
+
}, [form])
|
|
118
|
+
return snapshot
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toDraft(snapshot) {
|
|
122
|
+
const value = (snapshot && snapshot.value) || {}
|
|
123
|
+
const plugins = Array.isArray(value.deferredPlugins) ? value.deferredPlugins : []
|
|
124
|
+
return {
|
|
125
|
+
idleDelay: String(value.idleDelay ?? 1200),
|
|
126
|
+
disposeTimeout: String(value.disposeTimeout ?? 10000),
|
|
127
|
+
readyFallbackMs: String(value.readyFallbackMs ?? 5000),
|
|
128
|
+
plugins: JSON.stringify(plugins, null, 2),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The slot system does not guarantee that a form component receives the
|
|
133
|
+
// plugin ctx, so keep the injected instance reachable from apply().
|
|
134
|
+
let pluginCtx = null
|
|
135
|
+
|
|
136
|
+
function DeferredBootForm(props) {
|
|
137
|
+
const t = typeof props?.t === 'function' ? props.t : (key) => key
|
|
138
|
+
const forms = (props && props.ctx && props.ctx.configForms) || (pluginCtx && pluginCtx.configForms)
|
|
139
|
+
if (!forms || typeof forms.get !== 'function') return h('p', { style: styles.intro }, t('unavailable'))
|
|
140
|
+
const form = forms.get('deferred-boot')
|
|
141
|
+
const snapshot = useSnapshot(form)
|
|
142
|
+
const [draft, setDraft] = React.useState(() => toDraft(snapshot))
|
|
143
|
+
const [status, setStatus] = React.useState('')
|
|
144
|
+
|
|
145
|
+
React.useEffect(() => {
|
|
146
|
+
setDraft(toDraft(snapshot))
|
|
147
|
+
}, [snapshot])
|
|
148
|
+
|
|
149
|
+
const set = (key) => (event) => setDraft((prev) => ({ ...prev, [key]: event.target.value }))
|
|
150
|
+
|
|
151
|
+
const save = async () => {
|
|
152
|
+
let plugins
|
|
153
|
+
try {
|
|
154
|
+
plugins = JSON.parse(draft.plugins)
|
|
155
|
+
} catch {
|
|
156
|
+
setStatus(t('badJson'))
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(plugins)) {
|
|
160
|
+
setStatus(t('notArray'))
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
setStatus(t('saving'))
|
|
164
|
+
try {
|
|
165
|
+
const base = snapshot && snapshot.revision
|
|
166
|
+
const result = await form.mutate(
|
|
167
|
+
[
|
|
168
|
+
{ op: 'set', path: ['idleDelay'], value: Number(draft.idleDelay) || 0 },
|
|
169
|
+
{ op: 'set', path: ['disposeTimeout'], value: Number(draft.disposeTimeout) || 0 },
|
|
170
|
+
{ op: 'set', path: ['readyFallbackMs'], value: Number(draft.readyFallbackMs) || 0 },
|
|
171
|
+
{ op: 'set', path: ['deferredPlugins'], value: plugins },
|
|
172
|
+
],
|
|
173
|
+
base,
|
|
174
|
+
)
|
|
175
|
+
if (result === false) setStatus(t('conflict'))
|
|
176
|
+
else setStatus(t('saved'))
|
|
177
|
+
} catch {
|
|
178
|
+
setStatus(t('failed'))
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return h(
|
|
183
|
+
'div',
|
|
184
|
+
{ style: styles.root },
|
|
185
|
+
h('p', { style: styles.intro }, t('intro')),
|
|
186
|
+
h('div', { style: styles.row, padding: 0 }, h('span', { style: styles.label }, t('plugins.label'))),
|
|
187
|
+
h(Row, {
|
|
188
|
+
label: t('field.idleDelay'),
|
|
189
|
+
hint: t('hint.idleDelay'),
|
|
190
|
+
}, h('input', { style: styles.input, type: 'number', value: draft.idleDelay, onChange: set('idleDelay') })),
|
|
191
|
+
h(Row, {
|
|
192
|
+
label: t('field.disposeTimeout'),
|
|
193
|
+
hint: t('hint.disposeTimeout'),
|
|
194
|
+
}, h('input', { style: styles.input, type: 'number', value: draft.disposeTimeout, onChange: set('disposeTimeout') })),
|
|
195
|
+
h(Row, {
|
|
196
|
+
label: t('field.readyFallbackMs'),
|
|
197
|
+
hint: t('hint.readyFallbackMs'),
|
|
198
|
+
}, h('input', { style: styles.input, type: 'number', value: draft.readyFallbackMs, onChange: set('readyFallbackMs') })),
|
|
199
|
+
h('p', { style: { margin: '10px 0 0', color: 'var(--dsh-text-2, #c8c8c8)' } }, t('plugins.label')),
|
|
200
|
+
h('p', { style: styles.note, margin: '2px 0 6px' }, t('plugins.hint')),
|
|
201
|
+
h('textarea', { style: styles.textarea, value: draft.plugins, onChange: set('plugins') }),
|
|
202
|
+
h('p', { style: styles.hint, margin: '2px 0 0' }, t('hint.plugins')),
|
|
203
|
+
h('div', { style: styles.actions }, h('button', { style: styles.button, type: 'button', onClick: save }, t('save')), h('span', { style: styles.status }, status || t('note'))),
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function apply(ctx) {
|
|
208
|
+
pluginCtx = ctx
|
|
209
|
+
if (ctx.locale && typeof ctx.locale.register === 'function') {
|
|
210
|
+
ctx.effect(() => ctx.locale.register('deferredBoot', 'zh', ZH), 'dsh-deferred-boot: zh dictionary')
|
|
211
|
+
ctx.effect(() => ctx.locale.register('deferredBoot', 'en', EN), 'dsh-deferred-boot: en dictionary')
|
|
212
|
+
}
|
|
213
|
+
ctx.effect(
|
|
214
|
+
() =>
|
|
215
|
+
ctx.configForms.whileServed(['deferred-boot'], () =>
|
|
216
|
+
ctx.slots.inject('plugins.bundle.config', () =>
|
|
217
|
+
ctx.slots.register({ name: 'plugins.bundle.config', key: 'dsh-deferred-boot', locale: 'deferredBoot' }, DeferredBootForm),
|
|
218
|
+
),
|
|
219
|
+
),
|
|
220
|
+
'dsh-deferred-boot: bundle config form',
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
exports.apply = apply
|
|
225
|
+
exports.inject = ['slots', 'configForms', 'remote', 'locale']
|
|
226
|
+
return module.exports
|
|
227
|
+
},
|
|
228
|
+
})
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"format": 1,
|
|
3
|
+
"title": {
|
|
4
|
+
"en": "Deferred Boot",
|
|
5
|
+
"zh": "延迟启动"
|
|
6
|
+
},
|
|
7
|
+
"description": {
|
|
8
|
+
"en": "Plugins that defer heavy plugins until the main UI is ready",
|
|
9
|
+
"zh": "把指定插件推迟到主界面就绪之后再挂载的启动调度插件"
|
|
10
|
+
},
|
|
11
|
+
"plugins": [
|
|
12
|
+
{
|
|
13
|
+
"name": "dsh-deferred-boot",
|
|
14
|
+
"title": {
|
|
15
|
+
"en": "Deferred Boot",
|
|
16
|
+
"zh": "延迟启动"
|
|
17
|
+
},
|
|
18
|
+
"description": {
|
|
19
|
+
"en": "Mount selected plugins after the main UI is ready, one idle slice at a time, so a batch of plugin initializations does not hold the first screen.",
|
|
20
|
+
"zh": "主界面就绪之后再挂载指定插件,一次空闲切片只挂一个,避免插件成批初始化把首屏拖住。"
|
|
21
|
+
},
|
|
22
|
+
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDY0IDY0IiBmaWxsPSJub25lIj4KICA8cmVjdCB4PSIyIiB5PSIyIiB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHJ4PSIxNCIgZmlsbD0iIzRENkJGRSIvPgogIDxjaXJjbGUgY3g9IjMyIiBjeT0iMzQiIHI9IjEzIiBzdHJva2U9IiNGRkZGRkYiIHN0cm9rZS13aWR0aD0iMy41Ii8+CiAgPHBhdGggZD0iTTMyIDI2LjVWMzRMMzcgMzciIHN0cm9rZT0iI0ZGRkZGRiIgc3Ryb2tlLXdpZHRoPSIzLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgogIDxwYXRoIGQ9Ik0xMyAxOEgyM00xMyAyNEgyMCIgc3Ryb2tlPSIjRkZGRkZGIiBzdHJva2Utb3BhY2l0eT0iMC41NSIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KICA8cGF0aCBkPSJNNDMgMjJMNDcgMThNNDcgMjJMNDMgMTgiIHN0cm9rZT0iI0ZGRkZGRiIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KPC9zdmc+Cg==",
|
|
23
|
+
"author": "BuSheng",
|
|
24
|
+
"metadata": {
|
|
25
|
+
"keywords": [
|
|
26
|
+
"dsh",
|
|
27
|
+
"dsh-plugin",
|
|
28
|
+
"deepseek-harness",
|
|
29
|
+
"cordis",
|
|
30
|
+
"startup",
|
|
31
|
+
"lazy-loading",
|
|
32
|
+
"deferred",
|
|
33
|
+
"plugin-manager"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"versions": [
|
|
37
|
+
{
|
|
38
|
+
"version": "0.3.0",
|
|
39
|
+
"publishTimestamp": 1794044331,
|
|
40
|
+
"source": {
|
|
41
|
+
"type": "npm"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"dsh": ">=0.2.0-rc.1"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
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.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
|
+
}
|
|
@@ -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
|
+
}
|