dsh-unlag 1.7.7 → 1.7.9

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.
Files changed (4) hide show
  1. package/README.md +13 -9
  2. package/client.js +50 -10
  3. package/index.js +173 -75
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-unlag(解除卡顿)
2
2
 
3
- **改完插件点一下就生效。界面卡了点一下就顺。正在跑的任务不受影响。**
3
+ **改完插件点一下就生效。界面卡了点一下就顺。正在跑的任务照常跑完。**
4
4
 
5
5
  窗口右上角一个按钮,不重启进程、不刷新掉会话:插件代码原地换掉,老旧卡顿的界面重建一次。
6
6
 
@@ -8,10 +8,10 @@
8
8
 
9
9
  | 情况 | 按一下 |
10
10
  | --- | --- |
11
- | 改了插件,手里还有任务在跑 | 大部分插件当场更新,任务继续跑完 |
11
+ | 改了界面类插件,手里还有任务在跑 | 插件当场换掉,任务继续跑完 |
12
12
  | 界面越用越卡 | 直接恢复流畅,会话、队列、草稿都还在 |
13
13
 
14
- 以前两种都得重启 App。现在不用,也不用挑空闲时间——马上换,不等。
14
+ 以前两种都得关掉 App 再来。现在不用,也不用挑空闲时间——马上换,不等。
15
15
 
16
16
  ## 装
17
17
 
@@ -19,7 +19,7 @@
19
19
  dsh plugin install dsh-unlag
20
20
  ```
21
21
 
22
- 重载一次 App 让它进来,之后所有更新都走按钮。
22
+ 安装后由 DSH 完成首次加载,之后的更新都走按钮。
23
23
 
24
24
  ## 用
25
25
 
@@ -33,16 +33,20 @@ dsh plugin install dsh-unlag
33
33
  ## 不会丢什么
34
34
 
35
35
  - 轮次、子代理、终端进程:在宿主侧,一路继续。
36
- - 排队消息、输入草稿、附件:重建前先提交焦点里的内容。
37
- - 有未保存改动或开着弹窗:重建被拦下并提示,不硬刷。
38
- - 从不自己重启或退出 App;启动模块缺失时宁可放弃重建并提示,也不冒把 App 带下去的风险。
36
+ - 排队消息、输入草稿、附件:重建前先提交焦点里的内容;有人没存完就拦下,不硬刷。
37
+ - 有未保存改动、开着弹窗、或下个页面要用的模块读不全:拦下并提示,宁可不刷。
38
+ - 从不自己重启或退出 App。
39
39
 
40
- 管不到:独立进程和进程级的东西(宠物窗口自己的配置、DSH 自身升级、原生依赖变化)。
40
+ 管不到(它会拦下并说明,不会硬来):
41
+
42
+ - 改动落在宿主侧的插件:新增原生能力、改服务声明、改权限。
43
+ - 更新时正被某个任务使用的插件:那个任务可能随之结束。
44
+ - 独立进程和进程级的东西:宠物窗口自己的配置、DSH 自身升级、原生依赖变化。
41
45
 
42
46
  ## 排错
43
47
 
44
48
  - 日志:`~/.dsh/unlag-reload.log`,换代码/跳过/被拦各一行,含原因。
45
- - 状态:`GET /dsh-unlag/state` → `{"plugins":true,"page":true}`。
49
+ - 状态:`GET /dsh-unlag/state` → `{"plugins":true,"page":true,"shim":{...}}`。
46
50
 
47
51
  ## 测试
48
52
 
package/client.js CHANGED
@@ -6,8 +6,8 @@ window.__ModuleLoader__.load({
6
6
 
7
7
  const zh = /^zh/i.test(navigator.language || '');
8
8
  const COPY = zh
9
- ? { label: '解除卡顿', hint: '卡顿时用:换掉插件代码并重建界面;运行中的任务、排队的消息保留', blocked: '暂时无法重建界面,稍后再试', unsaved: '有未保存的改动,先保存再重试', dialog: '先关闭打开的窗口再重载', queued: '已排队:任务跑完自动重载' } // ui-copy-ignore
10
- : { label: 'Unlag', hint: 'For a sluggish interface: replaces plugin code and rebuilds the page; running turns and queued messages are kept', blocked: 'Cannot rebuild the page right now; try again', unsaved: 'Unsaved changes; save them, then retry', dialog: 'Close the open window first', queued: 'Queued; it reloads once the turn ends' };
9
+ ? { label: '解除卡顿', hint: '卡顿时用:换掉插件代码并重建界面;运行中的任务、排队的消息保留', blocked: '暂时无法重建界面,稍后再试', unsaved: '有未保存的改动,先保存再重试', dialog: '先关闭打开的窗口再重载', queued: '已排队:任务跑完自动重载', failed: '插件更新没完成,稍后再按一次' } // ui-copy-ignore
10
+ : { label: 'Unlag', hint: 'For a sluggish interface: replaces plugin code and rebuilds the page; running turns and queued messages are kept', blocked: 'Cannot rebuild the page right now; try again', unsaved: 'Unsaved changes; save them, then retry', dialog: 'Close the open window first', queued: 'Queued; it reloads once the turn ends', failed: 'The plugin update did not finish; press again later' };
11
11
 
12
12
  // Desktop shell only: the macOS and Windows desktop build owns the page and the running
13
13
  // app, so this is where the button belongs. A browser tab can reload freely on its own.
@@ -71,12 +71,27 @@ window.__ModuleLoader__.load({
71
71
  });
72
72
  if (!response.ok) return 'failed';
73
73
  const data = await response.json();
74
- return typeof data?.status === 'string' ? data.status : 'failed';
74
+ return reloadVerdict(data);
75
75
  } catch {
76
76
  return 'failed';
77
77
  }
78
78
  }
79
79
 
80
+ /**
81
+ * What the app's answer to a plugin replacement really means for the rebuild that follows.
82
+ * Only a set the app has finished starting can be rebuilt against: one still starting would
83
+ * boot a document over plugins that are not ready, and one the app says cannot be applied to a
84
+ * running page would boot the old code behind a fresh interface.
85
+ * @returns 'done' | 'off' | 'queued' | 'unavailable' | 'failed'
86
+ */
87
+ function reloadVerdict(data) {
88
+ if (data === null || typeof data !== 'object') return 'failed';
89
+ const status = typeof data.status === 'string' ? data.status : 'failed';
90
+ if (Array.isArray(data.pending) && data.pending.length > 0) return 'queued';
91
+ if (typeof data.restartReason === 'string' && data.restartReason !== '') return 'failed';
92
+ return status;
93
+ }
94
+
80
95
  /** The plugin ids one startup bundle covers, read from its `??<id>/client.js,…` list. */
81
96
  function bundledIds(url) {
82
97
  const match = /\?\?([^&]+)/.exec(url);
@@ -151,6 +166,12 @@ window.__ModuleLoader__.load({
151
166
  * after that, so this is the only way to see what a rebuild would really boot from.
152
167
  * @returns the list rows, or null when this page is not running inside the desktop shell.
153
168
  */
169
+ /** Whether this document is owned by the desktop shell, whose saved list decides a boot. */
170
+ function desktopShellPresent() {
171
+ const bridge = window.dshDesktopBoot;
172
+ return bridge !== undefined && bridge !== null && typeof bridge.ready === 'function';
173
+ }
174
+
154
175
  async function desktopStartupPayload() {
155
176
  const bridge = window.dshDesktopBoot;
156
177
  if (bridge === undefined || bridge === null || typeof bridge.ready !== 'function') return null;
@@ -301,6 +322,9 @@ window.__ModuleLoader__.load({
301
322
  async function startupLooksServable() {
302
323
  for (let attempt = 0; attempt < 2; attempt += 1) {
303
324
  const saved = await desktopStartupPayload();
325
+ // The desktop shell replays one startup list for every document. If it is running but its
326
+ // copy cannot be read, nothing here proves a new document would boot, so the page stays.
327
+ if (saved === null && desktopShellPresent()) return false;
304
328
  const servable =
305
329
  saved === null
306
330
  ? await bundlesCoverStartup((await servedBootGraph()) ?? window.__DSH_BOOT__)
@@ -322,10 +346,15 @@ window.__ModuleLoader__.load({
322
346
  if (event.defaultPrevented) return false;
323
347
  const pending = Array.isArray(detail.pending) ? detail.pending.filter((item) => item !== null && typeof item?.then === 'function') : [];
324
348
  if (pending.length === 0) return true;
325
- await Promise.race([
349
+ const slow = Symbol('slow'); // ui-copy-ignore
350
+ const settled = await Promise.race([
326
351
  Promise.allSettled(pending),
327
- new Promise((resolve) => setTimeout(resolve, 1200)), // ui-copy-ignore
352
+ new Promise((resolve) => setTimeout(() => resolve(slow), 1200)), // ui-copy-ignore
328
353
  ]);
354
+ // A save that never settled in time is as uncommitted as one that rejected: the rebuild
355
+ // would drop the work it still holds, so the page stays.
356
+ if (settled === slow) return false;
357
+ if (settled.some((entry) => entry.status === 'rejected')) return false;
329
358
  return event.defaultPrevented === false;
330
359
  }
331
360
 
@@ -446,15 +475,18 @@ window.__ModuleLoader__.load({
446
475
  return;
447
476
  }
448
477
  // Replace first: a plugin that only changed the interface is then covered by the rebuild.
449
- let pluginNote = null;
478
+ // A replacement that did not finish stops the press here: rebuilding the page anyway would
479
+ // start a document against a plugin set the app has not finished applying.
450
480
  if (replace) {
451
481
  const status = await replacePlugins();
452
- if (status === 'queued') pluginNote = 'queued';
453
- else if (status !== 'done' && status !== 'off') pluginNote = 'blocked';
482
+ if (status !== 'done' && status !== 'off') {
483
+ const key = status === 'queued' ? 'queued' : status === 'unavailable' ? 'blocked' : 'failed';
484
+ report('plugin-held', key);
485
+ return stop(key);
486
+ }
454
487
  }
455
488
  if (!rebuild) {
456
489
  setPending(false);
457
- if (pluginNote !== null) setNotice(COPY[pluginNote]);
458
490
  return;
459
491
  }
460
492
  window.sessionStorage.setItem(STAMP_KEY, String(Date.now()));
@@ -498,7 +530,15 @@ window.__ModuleLoader__.load({
498
530
  * must never cause is a navigation the saved startup list cannot survive, and that question is
499
531
  * worth asking without starting a desktop app.
500
532
  */
501
- const PROBE = { prepareInterface, startupLooksServable, frozenStartupLooksServable, desktopStartupPayload, reloadFreshPage };
533
+ const PROBE = {
534
+ prepareInterface,
535
+ startupLooksServable,
536
+ frozenStartupLooksServable,
537
+ desktopStartupPayload,
538
+ desktopShellPresent,
539
+ reloadVerdict,
540
+ reloadFreshPage,
541
+ };
502
542
 
503
543
  return {
504
544
  inject: ['slots'], // ui-copy-ignore
package/index.js CHANGED
@@ -162,19 +162,6 @@ function pluginReloader(ctx, config) {
162
162
  let deferrals = 0
163
163
  let lastRun = 0
164
164
 
165
- /** Resolve true when the HMR queue is empty, i.e. no transaction is really running right now. */
166
- const queueIsIdle = async (hmr) => {
167
- const queue = hmr?.operations
168
- if (typeof queue?.then !== 'function') return true
169
- let settled = false
170
- Promise.resolve(queue).then(
171
- () => (settled = true),
172
- () => (settled = true),
173
- )
174
- await new Promise((resolve) => setTimeout(resolve, 40))
175
- return settled
176
- }
177
-
178
165
  const turnsRunning = () => {
179
166
  try {
180
167
  const agents = ctx.get('agents')
@@ -187,9 +174,12 @@ function pluginReloader(ctx, config) {
187
174
  const busy = () => idleOnly && turnsRunning()
188
175
 
189
176
  /**
190
- * Ask the HMR service to recompose; it throws rather than half-applying, so a catch is enough.
191
- * Another reload may already hold the transaction (a save landing next to a session's own swap),
192
- * so a nested refusal is retried instead of being reported as a reload that failed.
177
+ * Ask the HMR service to recompose through its own queue. The service throws instead of
178
+ * half-applying, so a catch is enough. Another reload may already hold the transaction (a save
179
+ * landing next to a session's own swap), so a nested refusal waits and asks again through the
180
+ * same queue. It never recomposes outside the queue: two recompositions at once can leave half
181
+ * the plugin set stopped, and a document started in that state does not boot.
182
+ * @returns what the service reported, so the caller can tell done from partly done.
193
183
  */
194
184
  const reload = async (hmr) => {
195
185
  let result = null
@@ -200,22 +190,17 @@ function pluginReloader(ctx, config) {
200
190
  } catch (error) {
201
191
  const message = String(error?.message ?? error)
202
192
  if (message.includes('nested') === false) throw error
203
- if (await queueIsIdle(hmr)) {
204
- // The refusal came from a finished transaction whose context this callback inherited
205
- // (a watcher created while a reload was restarting this plugin). With the queue empty
206
- // there is nothing to serialize against, so recompose directly.
207
- log('reload ran outside the queue: the caller inherited a finished transaction')
208
- result = await hmr.reconcileProfile([])
209
- break
210
- }
211
193
  if (attempt >= 9) throw error
212
194
  await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)))
213
195
  }
214
196
  }
215
- const warnings = Array.isArray(result?.warnings) ? result.warnings.length : 0
216
- const pending = Array.isArray(result?.pending) ? result.pending.length : 0
217
- const suffix = typeof result?.restartReason === 'string' ? ` restart=${result.restartReason}` : ''
218
- log(`plugins reloaded (warnings=${warnings} pending=${pending}${suffix})`)
197
+ const warnings = Array.isArray(result?.warnings) ? result.warnings : []
198
+ const pending = Array.isArray(result?.pending) ? result.pending : []
199
+ const restartReason = typeof result?.restartReason === 'string' ? result.restartReason : null
200
+ log(
201
+ `plugins reloaded (warnings=${warnings.length} pending=${pending.length}${restartReason === null ? '' : ` restart=${restartReason}`})`,
202
+ )
203
+ return { status: 'done', warnings, pending, restartReason }
219
204
  }
220
205
 
221
206
  const flush = async () => {
@@ -247,7 +232,7 @@ function pluginReloader(ctx, config) {
247
232
  running = true
248
233
  await reload(hmr)
249
234
  } catch (error) {
250
- log(`reload failed, the previous code keeps running: ${error?.message ?? error}`)
235
+ log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
251
236
  } finally {
252
237
  running = false
253
238
  if (again) {
@@ -293,19 +278,27 @@ function pluginReloader(ctx, config) {
293
278
  return 'queued'
294
279
  }
295
280
 
281
+ /** The answer shape a press always gets, whether or not a reload was started. */
282
+ const queuedAnswer = (answer) => ({
283
+ status: answer === 'unavailable' ? 'unavailable' : 'queued',
284
+ warnings: [],
285
+ pending: [],
286
+ restartReason: null,
287
+ })
288
+
296
289
  /**
297
290
  * The button path: run now and report what happened, so the page can tell the user why the
298
291
  * press did not replace anything instead of looking broken.
299
- * @returns 'done' | 'queued' | 'unavailable' | 'failed'
292
+ * @returns {{status: string, warnings: string[], pending: string[], restartReason: string|null}}
300
293
  */
301
294
  const reloadNow = async (force = false) => {
302
295
  const hmr = hmrService()
303
- if (hmr === null) return 'unavailable'
296
+ if (hmr === null) return { status: 'unavailable', warnings: [], pending: [], restartReason: null }
304
297
  if (turnsRunning()) log('reloading while turns are still running; they keep running')
305
- if (running || (force === false && busy())) return queueReload()
298
+ if (running || (force === false && busy())) return queuedAnswer(queueReload())
306
299
  const wait = lastRun + MIN_INTERVAL_MS - Date.now()
307
300
  if (wait > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(wait, 2000)))
308
- if (running || (force === false && busy())) return queueReload()
301
+ if (running || (force === false && busy())) return queuedAnswer(queueReload())
309
302
  if (timer !== null) {
310
303
  clearTimeout(timer)
311
304
  timer = null
@@ -314,11 +307,16 @@ function pluginReloader(ctx, config) {
314
307
  deferrals = 0
315
308
  lastRun = Date.now()
316
309
  try {
317
- await reload(hmr)
318
- return 'done'
310
+ const outcome = await reload(hmr)
311
+ if (outcome.pending.length > 0 || outcome.restartReason !== null) {
312
+ // The app accepted the update but has not finished starting the new plugin set. Rebuilding
313
+ // the page now would start a document against a set that is still settling.
314
+ return { status: 'failed', warnings: outcome.warnings, pending: outcome.pending, restartReason: outcome.restartReason }
315
+ }
316
+ return outcome
319
317
  } catch (error) {
320
- log(`reload failed, the previous code keeps running: ${error?.message ?? error}`)
321
- return 'failed'
318
+ log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
319
+ return { status: 'failed', warnings: [], pending: [], restartReason: null }
322
320
  } finally {
323
321
  running = false
324
322
  again = false
@@ -459,48 +457,98 @@ function currentBundleUrls(graph, request) {
459
457
  }
460
458
 
461
459
  /**
462
- * Answer one request the running app cannot serve because the revision it names is gone. The
463
- * bundle module table is asked for the same set at its current revision, and only a body that
464
- * really contains every plugin it claims is handed back; anything else falls through to the
465
- * route as before, so an unknown id still ends as a plain 404.
460
+ * Ask the module table for one bundle at one URL. The newest app takes a standard request object;
461
+ * older ones take the method and address on their own, so both shapes are offered instead of one
462
+ * silently failing on the other.
463
+ */
464
+ async function fetchBundleAt(clientModules, url, method) {
465
+ const address = `http://unlag.invalid${url}`
466
+ if (typeof Request === 'function') {
467
+ try {
468
+ return await clientModules.fetchBundle(new Request(address, { method, headers: {} }))
469
+ } catch (error) {
470
+ if (error?.name !== 'TypeError') throw error
471
+ }
472
+ }
473
+ return clientModules.fetchBundle({ method, url: address })
474
+ }
475
+
476
+ /**
477
+ * The plugin set one stale request names, answered at the revision the app serves now. Only a body
478
+ * that really contains every plugin it claims is handed back; anything unsure answers null, so the
479
+ * route then behaves exactly as before and an unknown id still ends as a plain 404.
466
480
  */
481
+ async function currentBundleAnswer(clientModules, method, url) {
482
+ const request = bundleRequest(url)
483
+ if (request === null) return null
484
+ const urls = currentBundleUrls(clientModules.graph(), request)
485
+ if (urls.length === 0) return null
486
+ if (request.rev !== '' && urls.some((target) => target.endsWith(`&rev=${request.rev}`))) return null
487
+ for (const target of urls) {
488
+ const response = await fetchBundleAt(clientModules, target, 'GET')
489
+ if (response.status !== 200) continue
490
+ const body = Buffer.from(await response.arrayBuffer())
491
+ if (body.length === 0) continue
492
+ if (!request.sourceMap && !request.ids.every((id) => body.includes(id))) continue
493
+ return {
494
+ body,
495
+ contentType: String(response.headers.get('content-type') ?? 'text/javascript; charset=utf-8'),
496
+ ids: request.ids,
497
+ }
498
+ }
499
+ return null
500
+ }
501
+
502
+ /** Answer one stale request over the plain request/response route an app registers. */
467
503
  async function serveCurrentBundle(clientModules, req, res) {
468
504
  try {
469
505
  const method = String(req?.method ?? 'GET').toUpperCase()
470
506
  if (method !== 'GET' && method !== 'HEAD') return false
471
- const request = bundleRequest(req?.url)
472
- if (request === null) return false
473
- const urls = currentBundleUrls(clientModules.graph(), request)
474
- if (urls.length === 0) return false
475
- if (request.rev !== '' && urls.some((url) => url.endsWith(`&rev=${request.rev}`))) return false
476
- for (const url of urls) {
477
- const response = await clientModules.fetchBundle({ method: 'GET', url: `http://unlag.invalid${url}` })
478
- if (response.status !== 200) continue
479
- const body = Buffer.from(await response.arrayBuffer())
480
- if (body.length === 0) continue
481
- if (!request.sourceMap && !request.ids.every((id) => body.includes(id))) continue
482
- if (res.writableEnded === true || res.destroyed === true) return true
483
- res.writeHead(200, {
484
- 'content-type': String(response.headers.get('content-type') ?? 'text/javascript; charset=utf-8'),
485
- 'cache-control': 'no-store',
486
- 'content-length': String(body.length),
487
- })
488
- res.end(method === 'HEAD' ? undefined : body)
489
- log(`served ${request.ids.join(', ')} at the current revision: a page asked for an older one`)
490
- return true
491
- }
492
- return false
507
+ const answer = await currentBundleAnswer(clientModules, method, req?.url)
508
+ if (answer === null) return false
509
+ if (res.writableEnded === true || res.destroyed === true) return true
510
+ res.writeHead(200, {
511
+ 'content-type': answer.contentType,
512
+ 'cache-control': 'no-store',
513
+ 'content-length': String(answer.body.length),
514
+ })
515
+ res.end(method === 'HEAD' ? undefined : answer.body)
516
+ log(`served ${answer.ids.join(', ')} at the current revision: a page asked for an older one`)
517
+ return true
493
518
  } catch (error) {
494
519
  log(`cannot serve a bundle from an older revision: ${error?.message ?? error}`)
495
520
  return false
496
521
  }
497
522
  }
498
523
 
524
+ /** The same answer over the request-in/response-out route a newer app registers. */
525
+ async function serveCurrentBundleResource(clientModules, request) {
526
+ try {
527
+ const method = String(request?.method ?? 'GET').toUpperCase()
528
+ if (method !== 'GET' && method !== 'HEAD') return null
529
+ const answer = await currentBundleAnswer(clientModules, method, request?.url)
530
+ if (answer === null) return null
531
+ log(`served ${answer.ids.join(', ')} at the current revision: a page asked for an older one`)
532
+ return new Response(method === 'HEAD' ? null : answer.body, {
533
+ status: 200,
534
+ headers: {
535
+ 'content-type': answer.contentType,
536
+ 'cache-control': 'no-store',
537
+ 'content-length': String(answer.body.length),
538
+ },
539
+ })
540
+ } catch (error) {
541
+ log(`cannot serve a bundle from an older revision: ${error?.message ?? error}`)
542
+ return null
543
+ }
544
+ }
545
+
499
546
  /**
500
547
  * Wrap the bundle route so a document booting from an older startup list still runs. The route
501
- * belongs to the module table, and a second registration of the same prefix is refused, so the
502
- * handler already there is wrapped in place and put back on dispose. Every path through the
503
- * wrapper falls back to that handler: a bundle it cannot place is answered exactly as before.
548
+ * belongs to the module table, and a second registration of the same prefix is refused, so what is
549
+ * already there is wrapped in place and put back on dispose. A newer app registers the route as a
550
+ * request-in/response-out resource instead of a handler, and the dispatch prefers that one, so the
551
+ * shape the app actually registers is the shape wrapped — otherwise the guard would be a no-op.
504
552
  */
505
553
  function serveBundlesAtCurrentRevision(ctx) {
506
554
  const server = ctx.get('webServer')
@@ -508,8 +556,24 @@ function serveBundlesAtCurrentRevision(ctx) {
508
556
  if (server?.prefixes?.get === undefined) return null
509
557
  if (typeof clientModules?.fetchBundle !== 'function' || typeof clientModules?.graph !== 'function') return null
510
558
  const route = server.prefixes.get(BUNDLE_ROUTE)
511
- if (route === null || route === undefined || typeof route.handler !== 'function') return null
512
- if (route[SHIM_MARK] === true) return null
559
+ if (route === null || route === undefined || route[SHIM_MARK] === true) return null
560
+ if (typeof route.resource === 'function') {
561
+ const original = route.resource
562
+ const wrapped = async (request) => {
563
+ try {
564
+ const served = await serveCurrentBundleResource(clientModules, request)
565
+ if (served !== null) return served
566
+ } catch (error) {
567
+ log(`cannot answer a bundle request: ${error?.message ?? error}`)
568
+ }
569
+ return original(request)
570
+ }
571
+ route[SHIM_MARK] = true
572
+ route.resource = wrapped
573
+ log('bundles asked for under an older revision are answered at the current one')
574
+ return { route, slot: 'resource', original, wrapped }
575
+ }
576
+ if (typeof route.handler !== 'function') return null
513
577
  const original = route.handler
514
578
  const wrapped = (req, res) => {
515
579
  const fallback = () => original(req, res)
@@ -523,18 +587,52 @@ function serveBundlesAtCurrentRevision(ctx) {
523
587
  route[SHIM_MARK] = true
524
588
  route.handler = wrapped
525
589
  log('bundles asked for under an older revision are answered at the current one')
526
- return { route, original, wrapped }
590
+ return { route, slot: 'handler', original, wrapped }
527
591
  }
528
592
 
529
593
  /** Put the bundle route back the way it was found; a route replaced since then is left alone. */
530
594
  function releaseBundleShim(shim) {
531
595
  try {
532
596
  if (shim === null || shim === undefined) return
533
- if (shim.route.handler === shim.wrapped) shim.route.handler = shim.original
597
+ if (shim.slot === 'resource') {
598
+ if (shim.route.resource === shim.wrapped) shim.route.resource = shim.original
599
+ } else if (shim.route.handler === shim.wrapped) shim.route.handler = shim.original
534
600
  if (shim.route[SHIM_MARK] === true) delete shim.route[SHIM_MARK]
535
601
  } catch {}
536
602
  }
537
603
 
604
+ /**
605
+ * Wrap the bundle route unless the wrap already there is the one still on the route. The route can
606
+ * appear after this plugin starts and can be replaced afterwards, so every caller that is about to
607
+ * matter (a reload, a page asking for the graph) asks again instead of trusting an earlier answer.
608
+ */
609
+ function ensureBundleShim(ctx) {
610
+ const shim = globalThis[SHIM_KEY]
611
+ if (shim !== null && shim !== undefined) {
612
+ const route = ctx.get('webServer')?.prefixes?.get?.(BUNDLE_ROUTE)
613
+ if (route === shim.route && route[shim.slot] === shim.wrapped && route[SHIM_MARK] === true) return shim
614
+ releaseBundleShim(shim)
615
+ globalThis[SHIM_KEY] = null
616
+ }
617
+ globalThis[SHIM_KEY] = serveBundlesAtCurrentRevision(ctx)
618
+ return globalThis[SHIM_KEY]
619
+ }
620
+
621
+ /** What the bundle route looks like from here, so a stale answer can be told from a missing wrap. */
622
+ function bundleShimStatus(ctx) {
623
+ const route = ctx.get('webServer')?.prefixes?.get?.(BUNDLE_ROUTE)
624
+ const clientModules = ctx.get('clientModules')
625
+ const shape =
626
+ typeof route?.resource === 'function' ? 'resource' : typeof route?.handler === 'function' ? 'handler' : 'none'
627
+ return {
628
+ route: route !== null && route !== undefined,
629
+ kind: shape,
630
+ wrapped: route?.[SHIM_MARK] === true && typeof route?.[shape === 'none' ? 'handler' : shape] === 'function',
631
+ fetch: typeof clientModules?.fetchBundle === 'function',
632
+ graph: typeof clientModules?.graph === 'function',
633
+ }
634
+ }
635
+
538
636
  /**
539
637
  * The entry points the header button calls. Each answers with the current switches so the page
540
638
  * never guesses: with both off it does nothing, and a turn still running is reported rather than
@@ -552,12 +650,13 @@ function serveButton(ctx, controller, config) {
552
650
  [
553
651
  `${BASE_PATH}/state`,
554
652
  (_req, res) => {
555
- sendJson(res, 200, { plugins: settings.reloadPlugins, page: settings.reloadInterface })
653
+ sendJson(res, 200, { plugins: settings.reloadPlugins, page: settings.reloadInterface, shim: bundleShimStatus(ctx) })
556
654
  },
557
655
  ],
558
656
  [
559
657
  `${BASE_PATH}/graph`,
560
658
  (_req, res) => {
659
+ ensureBundleShim(ctx)
561
660
  sendJson(res, 200, { graph: liveBootGraph(server) })
562
661
  },
563
662
  ],
@@ -587,8 +686,8 @@ function serveButton(ctx, controller, config) {
587
686
  const header = req?.headers?.[BUTTON_HEADER]
588
687
  if (typeof header !== 'string' || header === '') return sendJson(res, 403, { status: 'forbidden' })
589
688
  if (!settings.reloadPlugins) return sendJson(res, 200, { status: 'off' })
590
- const status = await controller.reloadNow()
591
- sendJson(res, 200, { status })
689
+ const outcome = await controller.reloadNow()
690
+ sendJson(res, 200, outcome)
592
691
  },
593
692
  ],
594
693
  ]
@@ -622,8 +721,7 @@ export function apply(ctx, config) {
622
721
  * half starts, and one that appears after it.
623
722
  */
624
723
  const syncBundles = () => {
625
- if (globalThis[SHIM_KEY] !== null) return
626
- globalThis[SHIM_KEY] = serveBundlesAtCurrentRevision(ctx)
724
+ ensureBundleShim(ctx)
627
725
  }
628
726
  /** Watching needs HMR; start as soon as it exists, drop the watcher when it goes away. */
629
727
  const syncWatching = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-unlag",
3
- "version": "1.7.7",
3
+ "version": "1.7.9",
4
4
  "type": "module",
5
5
  "description": "One press applies plugin edits right away and brings a laggy interface back; running turns, queued messages and unsent input are untouched, and nothing restarts.",
6
6
  "license": "MIT",