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.
- package/README.md +13 -9
- package/client.js +50 -10
- package/index.js +173 -75
- 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
|
-
|
|
14
|
+
以前两种都得关掉 App 再来。现在不用,也不用挑空闲时间——马上换,不等。
|
|
15
15
|
|
|
16
16
|
## 装
|
|
17
17
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
dsh plugin install dsh-unlag
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
|
|
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
|
|
36
|
+
- 排队消息、输入草稿、附件:重建前先提交焦点里的内容;有人没存完就拦下,不硬刷。
|
|
37
|
+
- 有未保存改动、开着弹窗、或下个页面要用的模块读不全:拦下并提示,宁可不刷。
|
|
38
|
+
- 从不自己重启或退出 App。
|
|
39
39
|
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
453
|
-
|
|
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 = {
|
|
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
|
|
191
|
-
* Another reload may already hold the transaction (a save
|
|
192
|
-
*
|
|
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
|
|
216
|
-
const pending = Array.isArray(result?.pending) ? result.pending
|
|
217
|
-
const
|
|
218
|
-
log(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
|
|
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
|
|
472
|
-
if (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
502
|
-
*
|
|
503
|
-
*
|
|
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 ||
|
|
512
|
-
if (route
|
|
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.
|
|
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
|
|
591
|
-
sendJson(res, 200,
|
|
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
|
-
|
|
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.
|
|
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",
|