dsh-mobile-plugin 0.1.58 → 0.1.60

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.
@@ -156,6 +156,10 @@ function registerControlPlaneRoutes(deps) {
156
156
  // 插件 ctx。只给「桌面端说它读过了」那条路由用:它要清掉宿主侧的完成提醒。
157
157
  // 缺省 null 时那条路由降级为「只回 200、什么都不清」。
158
158
  ctx = null,
159
+ // **外壳**暴露的换代服务(`dshMobilePluginRuntime`)。更新的机制住在外壳里,而这几条
160
+ // 路由住在代里 —— 代被换掉之后服务还在,接口也就跟着活下来。
161
+ // 缺省返回 null:`lib/impl.js` 单独挂载时(测试 / 老宿主)没有外壳,如实报不可用。
162
+ getPluginRuntime = () => null,
159
163
  } = deps;
160
164
 
161
165
  // Pairing approval. The desktop shell used to answer this with an interactive
@@ -356,12 +360,68 @@ function registerControlPlaneRoutes(deps) {
356
360
  await syncSidecar()
357
361
  sendJson(res, 200, status())
358
362
  }) })
359
- // ── 插件更新 ──
363
+ // ── 插件更新(2026-10 搬回自更新之后重新有这四条)──
360
364
  //
361
- // 这里**没有**自更新路由了(2026-10 拆掉):插件从 npm 分发,更新由 DSH 的插件市场做
362
- // (网页版那张卡片直接调市场的公共更新接口 `/dsh-market/api/v1/*`,见 React/)。
363
- // 原来那四条(`/update/{status,check,apply,reload}`)走的是中转的插件 OTA 与 `.live`
364
- // 代机制,整套已经删掉 —— 插件只剩一条更新路径:市场装新版本 + 重启 DSH。
365
+ // 分工:**下载交给市场**(它在 profile 里跑 pnpm,还负责版本核验、release-age 策略、
366
+ // 回滚点、重启守卫),**换代码留给外壳**(`lib/index.js` 的 runtime)。所以这里只是把
367
+ // 外壳那几个动作原样开成本机 HTTP —— 面板与脚本都打这几条,不再各自去拼市场的接口,
368
+ // 免得「宿主能装、面板说看不见」这种两边写法不一致的偏差。
369
+ //
370
+ // 四条都走 localOnly:它们能触发一次安装并把运行中的实现换掉,是本机运维动作。
371
+ const pluginRuntime = () => {
372
+ try {
373
+ return getPluginRuntime() || null
374
+ } catch {
375
+ return null
376
+ }
377
+ }
378
+ const runtimeUnavailable = { ok: false, error: { code: 'update_unavailable', message: '这一版没有可换代的外壳(插件是被直接挂载的),更新请走 DSH 的插件市场' } }
379
+ registerRoute({ kind: 'exact', path: '/api/relay/update/status', handler: localOnly('GET', async (_req, res) => {
380
+ const runtime = pluginRuntime()
381
+ if (!runtime) return sendJson(res, 200, { ok: true, data: { available: false, runningVersion: null, installedVersion: null } })
382
+ return sendJson(res, 200, { ok: true, data: { available: true, ...runtime.status() } })
383
+ }) })
384
+ registerRoute({ kind: 'exact', path: '/api/relay/update/check', handler: localOnly('POST', async (_req, res) => {
385
+ const runtime = pluginRuntime()
386
+ if (!runtime) return sendJson(res, 409, runtimeUnavailable)
387
+ try {
388
+ const result = await runtime.check({ force: true })
389
+ return sendJson(res, 200, { ok: true, data: { update: result, status: runtime.status() } })
390
+ } catch (error) {
391
+ return sendJson(res, 500, { ok: false, error: { code: 'update_check_failed', message: String(error?.message || error) } })
392
+ }
393
+ }) })
394
+ // 「更新并立即生效」:请市场装,装完就地换代。手动与自动走的是同一条路。
395
+ registerRoute({ kind: 'exact', path: '/api/relay/update/apply', handler: localOnly('POST', async (req, res) => {
396
+ const runtime = pluginRuntime()
397
+ if (!runtime) return sendJson(res, 409, runtimeUnavailable)
398
+ let body = {}
399
+ try {
400
+ body = (await readJson(req)) || {}
401
+ } catch {
402
+ body = {}
403
+ }
404
+ try {
405
+ const outcome = await runtime.apply({ force: body.force === true })
406
+ return sendJson(res, 200, { ok: outcome.installed !== false || outcome.skipped === true, data: outcome })
407
+ } catch (error) {
408
+ return sendJson(res, 500, { ok: false, error: { code: 'update_apply_failed', message: String(error?.message || error) }, data: { status: runtime.status() } })
409
+ }
410
+ }) })
411
+ // 自动更新开关(默认关)。开=每 10 分钟问一次市场,有新版就装并就地切换。
412
+ registerRoute({ kind: 'exact', path: '/api/relay/update/auto', handler: localOnly('POST', async (req, res) => {
413
+ const runtime = pluginRuntime()
414
+ if (!runtime) return sendJson(res, 409, runtimeUnavailable)
415
+ let body = {}
416
+ try {
417
+ body = (await readJson(req)) || {}
418
+ } catch {
419
+ body = {}
420
+ }
421
+ if (typeof body.enabled !== 'boolean') return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'enabled 必须是布尔值' } })
422
+ const enabled = runtime.setAutoUpdate(body.enabled)
423
+ return sendJson(res, 200, { ok: true, data: { enabled, status: runtime.status() } })
424
+ }) })
365
425
 
366
426
 
367
427
  // sidecar 自重启:换掉 worker 子进程 + facade 模块,宿主本身不重启。
@@ -0,0 +1,284 @@
1
+ /**
2
+ * 代际装载器:把「插件实现」当成可以整代替换的东西。
3
+ *
4
+ * 起因:宿主进程启动时就把 profile 的插件树定死了,改一次插件代码就得**完整重启
5
+ * DSH**(Node 缓存 realpath,换 profile 链接必须重启进程)。手机那边的主机是 systemd
6
+ * 拉起来的,重启一条命令的事;桌面这边用户得自己退出再打开,而他不想每次都做这件事。
7
+ *
8
+ * ## 2026-10 改:代从哪儿来(从「OTA 写进 .live」改成「市场装进 profile」)
9
+ *
10
+ * 从前一代是 OTA 下载、解包、写进 `<pkg>/lib/.live/<releaseId>/`;分发交给 DSH 插件市场
11
+ * 之后那套没了,但**换代的判据反而更简单**:市场在 profile 里跑 pnpm,装新版会把
12
+ * `<profile>/node_modules/dsh-mobile-plugin` 这个链接指向**一个全新的 store 目录**:
13
+ *
14
+ * <profile>/node_modules/dsh-mobile-plugin -> .pnpm/dsh-mobile-plugin@0.1.58/node_modules/dsh-mobile-plugin
15
+ *
16
+ * 「新目录 = 新 realpath = 新 URL」,正是当年必须靠 `.live/<releaseId>` 才能拿到的东西
17
+ * (见 `importGeneration` 里 `?v=` 那一段:同一路径换代码是骗不过 ESM 缓存的)。所以这里
18
+ * 只要**认出 profile 里当前装的是哪一份**,把它当成一代接上去就够了 —— 不需要拷贝、
19
+ * 不需要下载,也不需要 sha256 校验(那是市场的活儿)。
20
+ *
21
+ * 一代 = 一个完整包目录,`package.json` 与 `lib/impl.js` 缺一不可:`lib/*.js` 内部有
22
+ * 80 多处相对引用(`../shared/http-util` 之类),必须保持 `lib/` 这一层的相对结构;
23
+ * 实现里还有 `require('../dsh-mobile-credentials/lib/credentials.js')` 这种跨包相对引用,
24
+ * 而凭据包就嵌在插件包目录内部,所以一代是**自包含**的:回滚到旧代时连凭据版本一起
25
+ * 回滚,不会出现「旧插件配新凭据」。
26
+ *
27
+ * 裸包名(`ws`、`@deepseek-ai/...`)依旧沿祖先目录解析:从 `<代>/lib` 往上会经过
28
+ * `<代>/node_modules`、profile 的 `node_modules`,最终落在同一份插件自身依赖与 peer
29
+ * 依赖上,与基础代解析到同一份 real path,不会出现两份实例。
30
+ */
31
+
32
+ import fs from 'node:fs'
33
+ import { createRequire } from 'node:module'
34
+ import path from 'node:path'
35
+ import { fileURLToPath, pathToFileURL } from 'node:url'
36
+
37
+ /** CJS 模块缓存。实现树里 `lib/relay`、`lib/routes`、`lib/mobile` 都是 CJS。 */
38
+ const require_ = createRequire(import.meta.url)
39
+
40
+ /** 外壳自己所在目录(`<pkg>/lib`);随包发布的那一代(基础代)相对它定位。 */
41
+ const LIB_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
42
+ /** 外壳所在包根(进程启动时的那一代)。它可能在运行期间被 pnpm 换掉,所以只当兜底。 */
43
+ const BOOT_PKG_DIR = path.join(LIB_DIR, '..')
44
+ /** 随包发布的实现(进程启动时必然存在;被换掉之后就不一定了)。 */
45
+ const BASE_ENTRY = path.join(LIB_DIR, 'impl.js')
46
+ /** 本包名:profile 里找的就是它。 */
47
+ const PLUGIN_PKG = 'dsh-mobile-plugin'
48
+ /**
49
+ * profile 的 `node_modules`。测试用 `DSH_MOBILE_PROFILE_NODE_MODULES` 指到临时目录
50
+ * (与从前 `DSH_MOBILE_LIVE_DIR` 同一个用途)。
51
+ */
52
+ const PROFILE_NODE_MODULES = process.env.DSH_MOBILE_PROFILE_NODE_MODULES || findProfileNodeModules()
53
+
54
+ /**
55
+ * 从外壳自己的路径往上找 profile 的 `node_modules`。
56
+ *
57
+ * 两种装法都要认:
58
+ * · pnpm/市场装(`.pnpm/<ver>/node_modules/dsh-mobile-plugin`)—— 往上会**先后**经过
59
+ * 内层 `<ver>/node_modules` 与外层 `<profile>/node_modules`,两处都有 `dsh-mobile-plugin`
60
+ * 这个名字,取**最外层**那个才是「市场换版本时会动的那个链接」;
61
+ * · 老装法(`<profile>/node_modules/dsh-mobile-plugin` 是实体目录)—— 只有一层。
62
+ *
63
+ * 找不到就退回外壳所在包的上一层:那一刻等于「没有可换的代」,`listGenerations()` 只会
64
+ * 列出基础代,换代接口如实报「盘上没有别的代」,不会假装成功。
65
+ */
66
+ function findProfileNodeModules() {
67
+ let dir = BOOT_PKG_DIR
68
+ let found = null
69
+ for (let i = 0; i < 8; i += 1) {
70
+ const parent = path.dirname(dir)
71
+ if (parent === dir) break
72
+ dir = parent
73
+ if (path.basename(dir) !== 'node_modules') continue
74
+ if (fs.existsSync(path.join(dir, PLUGIN_PKG))) found = dir
75
+ }
76
+ return found || path.join(BOOT_PKG_DIR, '..')
77
+ }
78
+
79
+ /** 版本号比较:只认 `x.y.z` 形式的段,够用且不引依赖。 */
80
+ export function compareVersion(a, b) {
81
+ const left = String(a || '0').split('-')[0].split('.').map(Number)
82
+ const right = String(b || '0').split('-')[0].split('.').map(Number)
83
+ for (let i = 0; i < 3; i += 1) {
84
+ const l = Number.isFinite(left[i]) ? left[i] : 0
85
+ const r = Number.isFinite(right[i]) ? right[i] : 0
86
+ if (l !== r) return l - r
87
+ }
88
+ return 0
89
+ }
90
+
91
+ function readPackageVersion(file) {
92
+ try {
93
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
94
+ return typeof raw.version === 'string' ? raw.version : null
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
100
+ /**
101
+ * 列出所有可用的代。
102
+ *
103
+ * 两个来源:
104
+ * · `bundled` —— 外壳自己所在的包(进程启动时那一代)。它可能在运行期间被市场的 pnpm
105
+ * 整个换掉甚至删掉,所以**盘上读不到就列不出来**;那种时候只剩内存里那份
106
+ * (`imported` 里缓存着),回滚也只能靠它。
107
+ * · `installed` —— profile 里当前装着的那一份(`node_modules/dsh-mobile-plugin` 的
108
+ * realpath)。市场装新版之后这里立刻就是新版本,而且它的 realpath 每次都可能不同,
109
+ * 天然绕开 ESM 缓存(这正是当年要靠 `.live/<releaseId>` 才能拿到的东西)。
110
+ *
111
+ * 只做「结构齐不齐」的粗筛:`package.json` 与 `lib/impl.js` 缺一不可。半份拷贝(安装被
112
+ * 打断)如果被当成可用代,下次换代就会用一份残缺的代码上线。
113
+ *
114
+ * @returns {Array<{version: string, entry: string, dir: string|null, pkgDir: string, source: 'bundled'|'installed'}>}
115
+ */
116
+ export function listGenerations() {
117
+ const generations = []
118
+ const baseVersion = readPackageVersion(path.join(BOOT_PKG_DIR, 'package.json'))
119
+ if (baseVersion && fs.existsSync(BASE_ENTRY)) {
120
+ generations.push({ version: baseVersion, entry: BASE_ENTRY, dir: BOOT_PKG_DIR, pkgDir: BOOT_PKG_DIR, source: 'bundled' })
121
+ }
122
+ const installed = installedPackageDir()
123
+ if (installed) {
124
+ const entry = path.join(installed, 'lib', 'impl.js')
125
+ const version = readPackageVersion(path.join(installed, 'package.json'))
126
+ if (version && fs.existsSync(entry)) {
127
+ generations.push({ version, entry, dir: installed, pkgDir: installed, source: 'installed' })
128
+ }
129
+ }
130
+ return generations
131
+ }
132
+
133
+ /**
134
+ * profile 里当前装着的那份插件包的**真实路径**。
135
+ *
136
+ * 必须用 realpath:pnpm 把包放在 store 里、`node_modules/dsh-mobile-plugin` 只是个链接,
137
+ * 而 ESM/CJS 的缓存键与相对引用都按真实路径算。拿不到(没装、链接坏)返回 null。
138
+ */
139
+ export function installedPackageDir() {
140
+ try {
141
+ return fs.realpathSync(path.join(PROFILE_NODE_MODULES, PLUGIN_PKG))
142
+ } catch {
143
+ return null
144
+ }
145
+ }
146
+
147
+ /** profile 的 `node_modules`(诊断用)。 */
148
+ export function profileNodeModules() {
149
+ return PROFILE_NODE_MODULES
150
+ }
151
+
152
+ /** 盘上装着的版本号;读不到返回 null(没装 / 装了一半 / 链接坏)。 */
153
+ export function installedVersion() {
154
+ const dir = installedPackageDir()
155
+ return dir ? readPackageVersion(path.join(dir, 'package.json')) : null
156
+ }
157
+
158
+ /**
159
+ * 取版本号最高的代。
160
+ *
161
+ * `preferVersion` 命中时优先(用于回滚到指定的旧代)。**版本号相同时取装进来的那一代**
162
+ * (`source: 'live'`),理由见下面 `delta === 0` 那一段 —— 这不是审美问题,是「同样报
163
+ * 0.1.16,一个跑新代码一个跑旧代码」的分叉点。
164
+ */
165
+ export function resolveGeneration(preferVersion) {
166
+ const generations = listGenerations()
167
+ if (!generations.length) return null
168
+ if (preferVersion) {
169
+ const preferred = generations.find(item => item.version === preferVersion)
170
+ if (preferred) return preferred
171
+ }
172
+ return generations.reduce((best, item) => {
173
+ const delta = compareVersion(item.version, best.version)
174
+ if (delta > 0) return item
175
+ // 同版本时必须取 `installed`。基础代(包内 `lib/impl.js`)的路径是**固定**的,而
176
+ // `importGeneration` 的 `?v=` 只骗得过**入口** —— 入口里 `require('./relay/…')` 那些
177
+ // CJS 子模块按解析后的真实路径命中缓存,query 传不下去;于是「profile 里的插件包被
178
+ // pnpm 换过版本」之后再激活固定路径那一代,拿到的仍是进程启动时那份**旧**实现。
179
+ // 实测过:完整安装之后再自更新,接口回 `installed: true, version: <新版>`,而实际跑
180
+ // 的还是旧代码 —— 状态说成功、代码没换,是最坏的那种失败。
181
+ // `installed` 那一代的 realpath 是新目录 = 新 URL,整棵树必然读到新代码。
182
+ if (delta === 0 && item.source === 'installed' && best.source !== 'installed') return item
183
+ return best
184
+ })
185
+ }
186
+
187
+ /**
188
+ * 一代要从 CJS 缓存里清掉的根目录。
189
+ *
190
+ * 一代是自包含的(插件包里嵌着 `dsh-mobile-credentials/`),所以清「代目录」+「插件包
191
+ * 目录」+「嵌着的凭据包」三处就够。凭据包还可能落在与插件包**同级**的旧布局位置上
192
+ * (以兄弟身份被 require),所以那条路也要清,否则换代后新实现会读到上一代的凭据模块。
193
+ */
194
+ function evictionRoots(generation) {
195
+ const pkgDir = generation.pkgDir || path.dirname(path.dirname(generation.entry))
196
+ const roots = [generation.dir, pkgDir, path.join(pkgDir, 'dsh-mobile-credentials')]
197
+ if (generation.dir) roots.push(path.join(generation.dir, 'dsh-mobile-credentials'))
198
+ return roots
199
+ }
200
+
201
+ /**
202
+ * 把一棵实现树从 CJS 缓存里清掉。
203
+ *
204
+ * **为什么必须有这一步**:代际入口靠 `?v=<时间戳>` 换 URL,但入口内部那些
205
+ * `require('./relay/…')` 走的是 CJS 缓存(按解析后的真实路径索引),query 传不下去。
206
+ * 于是「同一路径下的代码被换掉了」时,新入口会拿到旧子模块 —— 实测:改盘上的子模块、
207
+ * 再 `import(entry?v=…)`,拿到的仍是旧值;先清缓存才拿到新值。
208
+ *
209
+ * 这个坑只在**同一个路径被第二次加载**时出现。正常 OTA 往 `lib/.live/<新 id>/` 里装,
210
+ * 路径是新的,踩不到;但基础代(包内 `lib/impl.js`)路径固定 —— 只要 profile 里的插件包
211
+ * 在运行期间被 pnpm 换过版本,换代回基础代就会拿到上一版实现:新路由没注册、
212
+ * `appVersion` 报旧版本号,而状态里却写着新版本。
213
+ */
214
+ function evictCommonJsTree(roots) {
215
+ const prefixes = []
216
+ for (const root of roots) {
217
+ if (!root) continue
218
+ let real = root
219
+ try {
220
+ // 缓存键是 realpath(macOS 上 `/tmp` 与 `/private/tmp` 是两个字符串,直接比会漏)。
221
+ real = fs.realpathSync(root)
222
+ } catch {
223
+ // 目录已被删(半份拷贝被清理)时,按原样比一次。
224
+ }
225
+ prefixes.push(real.endsWith(path.sep) ? real : real + path.sep)
226
+ }
227
+ if (!prefixes.length) return
228
+ for (const key of Object.keys(require_.cache)) {
229
+ if (prefixes.some(prefix => key.startsWith(prefix))) delete require_.cache[key]
230
+ }
231
+ }
232
+
233
+ /**
234
+ * 动态 import 一个代。
235
+ *
236
+ * URL 上带 `?v=<时间戳>-<序号>`:同一路径重复 import 会命中 ESM 缓存,**加了 query 才会
237
+ * 拿到新实例** —— 这正是「同一份文件在不重启进程的前提下换掉实现」的开关。已经加载
238
+ * 过的代会被缓存住,回滚时不会再读第二遍盘。
239
+ *
240
+ * 但 query 只够换掉**入口**:入口内部 require 出来的 CJS 子模块仍按路径命中缓存,所以
241
+ * import 之前必须先把这棵树的 CJS 缓存清掉(见 `evictCommonJsTree`)。
242
+ */
243
+ const imported = new Map()
244
+
245
+ /** `?v=` 里的序号:同一毫秒内连换两代时,光有时间戳会撞进同一个 URL。 */
246
+ let importSeq = 0
247
+
248
+ export async function importGeneration(generation) {
249
+ const cached = imported.get(generation.version)
250
+ if (cached) return cached
251
+ // 先把 CJS 缓存清干净再 import,否则「入口是新的、子模块还是旧的」,等于换了个半成品上去。
252
+ evictCommonJsTree(evictionRoots(generation))
253
+ importSeq += 1
254
+ const url = `${pathToFileURL(generation.entry).href}?v=${Date.now()}-${importSeq}`
255
+ const mod = await import(url)
256
+ const apply = typeof mod?.apply === 'function' ? mod.apply : null
257
+ if (!apply) {
258
+ throw new Error(`${generation.entry} 没有导出 apply:这一代的结构不对(期望导出一个 apply(ctx, config) 函数)`)
259
+ }
260
+ const loaded = {
261
+ version: generation.version,
262
+ source: generation.source,
263
+ dir: generation.dir,
264
+ apply,
265
+ // 代自己声明的依赖服务,转交给 `ctx.plugin()`。**不能丢**:`lib/impl.js` 的 inject
266
+ // 是按运行时二选一的(旧运行时多一个 `apiProxy`),丢了会让它在新旧运行时上都取不到
267
+ // `ctx.apiProxy`。测试里的假代不声明时为 undefined,由外壳兜底。
268
+ inject: Array.isArray(mod?.inject) ? mod.inject : null,
269
+ hostPath: generation.dir,
270
+ }
271
+ imported.set(generation.version, loaded)
272
+ return loaded
273
+ }
274
+
275
+ /**
276
+ * 把一份新装进来的代登记为「可加载」。
277
+ *
278
+ * 只清缓存不够:调用方(OTA)写完新目录后立刻要切过去,而 `listGenerations()` 每次都
279
+ * 重读目录,所以这里只需要把该版本可能存在的旧缓存丢掉,强制下次 import 读新文件。
280
+ */
281
+ export function invalidateGeneration(version) {
282
+ if (version) imported.delete(version)
283
+ else imported.clear()
284
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * 代际运行时:把「外壳」和「某一代实现」缝在一起,并负责换代那一刻的秩序。
3
+ *
4
+ * 外壳(`lib/index.js`)只做两件事:启动时激活版本最高的一代,以及把换代请求转给这里。
5
+ * 实现侧完全不知道自己是被外壳装进来的 —— 它拿到的是一个普通的 cordis `Context`,
6
+ * 照常 `ctx.effect(...)` / `ctx.webServer.register(...)` / `ctx.reflect.provide(...)`。
7
+ *
8
+ * ## 换代为什么必须串行、而且先停后起
9
+ *
10
+ * 一个代在 `apply` 里会往宿主注册**同一条路径**的路由(`ctx.webServer.register` 撞路径会抛
11
+ * `webserver: duplicate exact route`),还会绑 sidecar、起镜像推送。所以不能「先装新的再
12
+ * 卸旧的」——只能先把旧代的 fiber dispose 掉(它的每个 `ctx.effect` 会反向执行清理:路由
13
+ * 摘掉、子进程收掉),再装新的。这个文件里的 `swapping` 锁就是保证同一时刻只有一次换代。
14
+ *
15
+ * ## 让路
16
+ *
17
+ * 手机那边随时可能有一个请求在读事件流。直接 dispose 会把 `ctx.effect` 的清理跑在别人的
18
+ * 请求中间。所以换代前先等 `inFlight` 归零,最多等 `DRAIN_TIMEOUT_MS`(等不到也照换——
19
+ * 卡住的请求不该拖住一次修复性更新)。
20
+ */
21
+
22
+ import { importGeneration, resolveGeneration } from './manager.js'
23
+
24
+ /** 换代前等在飞请求归零的上限。 */
25
+ const DRAIN_TIMEOUT_MS = 2000
26
+ /** 等一代装载完成的上限。装载卡住(依赖永远不来)时不能把整个外壳拖死。 */
27
+ const ACTIVATE_TIMEOUT_MS = 20000
28
+
29
+ /**
30
+ * 代没有声明 `inject` 时的兜底(只有测试里的假代会走这里)。
31
+ *
32
+ * 真实实现 `lib/impl.js` 一定声明 inject,而且是按运行时二选一的,所以这里**不能**
33
+ * 拿它去覆盖代自己的声明 —— 那会把旧运行时要用的 `apiProxy` 抹掉。
34
+ */
35
+ const DEFAULT_INJECT = ['webServer', 'sessions']
36
+
37
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
38
+
39
+ export function createRuntime() {
40
+ /** 外壳那份 `apply` 拿到的 context。装载完成前是 null。 */
41
+ let ctx = null
42
+ /** 当前活跃的 fiber(旧代)。 */
43
+ let fiber = null
44
+ /**
45
+ * 当前这一代**已经 import 进来的模块本体**。
46
+ *
47
+ * 回滚必须用它,不能靠「按版本号再挑一次」:市场装新版会把旧的 store 目录整个带走,
48
+ * 那一刻盘上已经找不到旧代了 —— 照版本号找会拿到 null,于是回滚失败、插件彻底没有
49
+ * 活跃实现(手机连不上,日志里只剩一行 pending)。模块本体在内存里,重新 `apply`
50
+ * 一次就是完整的旧代。
51
+ */
52
+ let loadedCurrent = null
53
+ /** 当前代的描述(`{version, source, dir, hostPath}` 的子集)。 */
54
+ let current = null
55
+ /** 上一次成功激活过的代,供回滚。 */
56
+ let previous = null
57
+ /** 串行化所有换代动作。 */
58
+ let swapping = null
59
+ /** 在飞请求计数(`beginRequest` / `endRequest` 维护)。 */
60
+ let inFlight = 0
61
+ let lastError = null
62
+ let activatedAt = null
63
+ let detached = false
64
+
65
+ /** 对外状态快照。控制面接口直接把它交给客户端。 */
66
+ function status() {
67
+ return {
68
+ version: current?.version ?? null,
69
+ source: current?.source ?? null,
70
+ previousVersion: previous?.version ?? null,
71
+ activatedAt,
72
+ lastError,
73
+ inFlight,
74
+ swapping: swapping !== null,
75
+ }
76
+ }
77
+
78
+ /** 装载一代并等它真正 ACTIVE。失败抛错,由调用方决定是否回滚。 */
79
+ async function activate(generation) {
80
+ return activateLoaded(await importGeneration(generation))
81
+ }
82
+
83
+ /**
84
+ * 把已经 import 进来的那代装上去(回滚走这条)。
85
+ *
86
+ * 回滚**不重新 import**:新代装失败时旧代的目录可能已经被市场删掉了,重新读盘会
87
+ * MODULE_NOT_FOUND;而旧代的模块本体一直在内存里,重新 apply 一次就够了。
88
+ */
89
+ async function activateLoaded(loaded) {
90
+ let timer = null
91
+ // 用 `{ inject, apply }` 而不是裸函数:cordis 只有在**声明过** inject 的 fiber 上
92
+ // 才允许 `ctx.webServer` 这种取值(裸函数会以 "cannot get property without inject"
93
+ // 失败)。代里的实现自己声明了 `inject`,但它作为插件对象被装进来时那份声明不会
94
+ // 自动生效,所以外壳在这里转交给 cordis。
95
+ //
96
+ // 用代自己声明的 inject,不写死:`lib/impl.js` 的 inject 是**按运行时二选一**的
97
+ // (旧运行时多一个 `apiProxy`,0.2.0 没有)。写死成 `['webServer','sessions']` 会让
98
+ // 旧运行时上读 `ctx.apiProxy` 抛 "cannot get property apiProxy without inject"。
99
+ // 测试里的假代没声明 inject 时退回外壳默认值。
100
+ const inject = Array.isArray(loaded.inject) && loaded.inject.length > 0 ? loaded.inject : DEFAULT_INJECT
101
+ const result = await Promise.race([
102
+ Promise.resolve().then(() => ctx.plugin({ inject, apply: loaded.apply })),
103
+ new Promise((_, reject) => {
104
+ timer = setTimeout(() => reject(new Error(`装载 ${loaded.version} 超时(${ACTIVATE_TIMEOUT_MS}ms):它可能卡在等一个永远不来的依赖服务上`)), ACTIVATE_TIMEOUT_MS)
105
+ }),
106
+ ]).finally(() => {
107
+ if (timer) clearTimeout(timer)
108
+ })
109
+ return { loaded, fiber: result }
110
+ }
111
+
112
+ function describe(loaded, dir) {
113
+ return { version: loaded.version, source: loaded.source, hostPath: loaded.hostPath ?? dir ?? null }
114
+ }
115
+
116
+ /**
117
+ * 换到指定版本(默认版本最高的那一代)。
118
+ *
119
+ * 旧代先停、新代后起;新代起不来就回滚把旧代装回去,并把错误留在 `lastError` 里——
120
+ * 「更新把自己弄挂了」必须能被看见,而不是静默少个插件。
121
+ */
122
+ async function reload(preferVersion) {
123
+ if (detached) throw new Error('插件已经卸载,不能再换代')
124
+ if (swapping) return swapping
125
+ swapping = (async () => {
126
+ const target = resolveGeneration(preferVersion)
127
+ if (!target) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
128
+ if (current && target.version === current.version) return status()
129
+
130
+ await drain()
131
+ const oldFiber = fiber
132
+ const oldCurrent = current
133
+ fiber = null
134
+ current = null
135
+ if (oldFiber) {
136
+ try {
137
+ await oldFiber.dispose()
138
+ } catch (error) {
139
+ // dispose 里的清理动作自己炸了:记下来,但不能因此不装新代。
140
+ lastError = `卸载 ${oldCurrent?.version ?? '旧代'} 时出错:${error?.message || error}`
141
+ }
142
+ }
143
+ try {
144
+ const { loaded, fiber: nextFiber } = await activate(target)
145
+ fiber = nextFiber
146
+ loadedCurrent = loaded
147
+ current = describe(loaded, target.dir)
148
+ if (oldCurrent) previous = oldCurrent
149
+ activatedAt = new Date().toISOString()
150
+ lastError = null
151
+ return status()
152
+ } catch (error) {
153
+ lastError = `装载 ${target.version} 失败:${error?.message || error}`
154
+ if (oldCurrent) {
155
+ // 回滚:把上一代装回去,尽量让插件保持可用。
156
+ //
157
+ // 顺序是刻意的:**先用内存里那份**(`loadedCurrent`),因为它一定还在;盘上那份
158
+ // 可能已经被市场这次安装带走了(pnpm 换 store 目录),照版本号找会找空。
159
+ try {
160
+ if (loadedCurrent) {
161
+ const { fiber: nextFiber } = await activateLoaded(loadedCurrent)
162
+ fiber = nextFiber
163
+ current = describe(loadedCurrent, oldCurrent.dir ?? null)
164
+ activatedAt = new Date().toISOString()
165
+ } else {
166
+ const fallback = resolveGeneration(oldCurrent.version)
167
+ if (fallback) {
168
+ const { loaded, fiber: nextFiber } = await activate(fallback)
169
+ fiber = nextFiber
170
+ loadedCurrent = loaded
171
+ current = describe(loaded, fallback.dir)
172
+ activatedAt = new Date().toISOString()
173
+ }
174
+ }
175
+ } catch (rollbackError) {
176
+ lastError = `${lastError};回滚到 ${oldCurrent.version} 也失败:${rollbackError?.message || rollbackError}`
177
+ }
178
+ }
179
+ throw error
180
+ }
181
+ })().finally(() => {
182
+ swapping = null
183
+ })
184
+ return swapping
185
+ }
186
+
187
+ /** 第一次激活。 */
188
+ async function start(context) {
189
+ ctx = context
190
+ const target = resolveGeneration()
191
+ if (!target) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
192
+ const { loaded, fiber: startedFiber } = await activate(target)
193
+ fiber = startedFiber
194
+ loadedCurrent = loaded
195
+ current = describe(loaded, target.dir)
196
+ activatedAt = new Date().toISOString()
197
+ return status()
198
+ }
199
+
200
+ /** 等在飞请求结束(最多 `DRAIN_TIMEOUT_MS`)。 */
201
+ async function drain() {
202
+ const deadline = Date.now() + DRAIN_TIMEOUT_MS
203
+ while (inFlight > 0 && Date.now() < deadline) {
204
+ await sleep(25)
205
+ }
206
+ }
207
+
208
+ /** 外壳被卸载时调用:停当前代,之后不再接受换代。 */
209
+ async function detach() {
210
+ detached = true
211
+ const oldFiber = fiber
212
+ fiber = null
213
+ current = null
214
+ if (oldFiber) await oldFiber.dispose()
215
+ }
216
+
217
+ return {
218
+ start,
219
+ reload,
220
+ detach,
221
+ status,
222
+ beginRequest: () => {
223
+ inFlight += 1
224
+ },
225
+ endRequest: () => {
226
+ inFlight = Math.max(0, inFlight - 1)
227
+ },
228
+ /**
229
+ * 把一个路由 handler 包成「进来 +1、出去 -1」。
230
+ *
231
+ * 宿主 webServer 没有 Koa 式中间件(只有 `register(route)` / `registerUpgrade` /
232
+ * `registerFallback` / `tapIndex`),所以「在飞请求」只能在 handler 外面数。包错
233
+ * 形状时原样返回,绝不因为统计把一条路由弄丢。
234
+ */
235
+ wrapHandler: (route) => {
236
+ if (!route || typeof route.handler !== 'function') return route
237
+ const handler = route.handler
238
+ route.handler = async (req, res) => {
239
+ inFlight += 1
240
+ try {
241
+ return await handler(req, res)
242
+ } finally {
243
+ inFlight = Math.max(0, inFlight - 1)
244
+ }
245
+ }
246
+ return route
247
+ },
248
+ }
249
+ }