dsh-unlag 1.7.8 → 1.7.10
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 +164 -76
- 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
|
@@ -26,6 +26,9 @@ export const name = 'dsh-unlag'
|
|
|
26
26
|
*/
|
|
27
27
|
export const inject = ['webServer']
|
|
28
28
|
|
|
29
|
+
/** A transaction that has not finished in this long is stuck; the press reports it instead of hanging. */
|
|
30
|
+
const RELOAD_TIMEOUT_MS = 20_000
|
|
31
|
+
|
|
29
32
|
const LOG_PATH = join(homedir(), '.dsh', 'unlag-reload.log')
|
|
30
33
|
/** Tests point this at a scratch file, so a test run leaves the live log readable. */
|
|
31
34
|
const logTarget = () => process.env.DSH_UNLAG_LOG ?? LOG_PATH
|
|
@@ -162,19 +165,6 @@ function pluginReloader(ctx, config) {
|
|
|
162
165
|
let deferrals = 0
|
|
163
166
|
let lastRun = 0
|
|
164
167
|
|
|
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
168
|
const turnsRunning = () => {
|
|
179
169
|
try {
|
|
180
170
|
const agents = ctx.get('agents')
|
|
@@ -187,35 +177,50 @@ function pluginReloader(ctx, config) {
|
|
|
187
177
|
const busy = () => idleOnly && turnsRunning()
|
|
188
178
|
|
|
189
179
|
/**
|
|
190
|
-
* Ask the HMR service to recompose
|
|
191
|
-
* Another reload may already hold the transaction (a save
|
|
192
|
-
*
|
|
180
|
+
* Ask the HMR service to recompose through its own queue. The service throws instead of
|
|
181
|
+
* half-applying, so a catch is enough. Another reload may already hold the transaction (a save
|
|
182
|
+
* landing next to a session's own swap), so a nested refusal waits and asks again through the
|
|
183
|
+
* same queue. It never recomposes outside the queue: two recompositions at once can leave half
|
|
184
|
+
* the plugin set stopped, and a document started in that state does not boot.
|
|
185
|
+
* @returns what the service reported, so the caller can tell done from partly done.
|
|
193
186
|
*/
|
|
194
187
|
const reload = async (hmr) => {
|
|
188
|
+
/**
|
|
189
|
+
* Queue the recomposition through the service's own queue. A watcher callback created while a
|
|
190
|
+
* reload was restarting this plugin inherits that reload's context, and the service reads any
|
|
191
|
+
* request made from that context as a nested one even though the transaction is over. Leaving
|
|
192
|
+
* that context first is what the service's own watcher does, and it keeps the request queued
|
|
193
|
+
* instead of running a second recomposition beside the first.
|
|
194
|
+
*/
|
|
195
|
+
const ask = (service) =>
|
|
196
|
+
typeof service.executing?.exit === 'function'
|
|
197
|
+
? service.executing.exit(() => service.runExclusive(() => service.reconcileProfile([])))
|
|
198
|
+
: service.runExclusive(() => service.reconcileProfile([]))
|
|
199
|
+
const abandoned = () =>
|
|
200
|
+
new Promise((_resolve, reject) => {
|
|
201
|
+
const limit = Number(process.env.DSH_UNLAG_TIMEOUT_MS ?? '') || RELOAD_TIMEOUT_MS
|
|
202
|
+
const timer = setTimeout(() => reject(new Error('the plugin update did not finish in time')), limit)
|
|
203
|
+
timer.unref?.()
|
|
204
|
+
})
|
|
195
205
|
let result = null
|
|
196
206
|
for (let attempt = 0; ; attempt += 1) {
|
|
197
207
|
try {
|
|
198
|
-
result = await
|
|
208
|
+
result = await Promise.race([ask(hmr), abandoned()])
|
|
199
209
|
break
|
|
200
210
|
} catch (error) {
|
|
201
211
|
const message = String(error?.message ?? error)
|
|
202
212
|
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
213
|
if (attempt >= 9) throw error
|
|
212
214
|
await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)))
|
|
213
215
|
}
|
|
214
216
|
}
|
|
215
|
-
const warnings = Array.isArray(result?.warnings) ? result.warnings
|
|
216
|
-
const pending = Array.isArray(result?.pending) ? result.pending
|
|
217
|
-
const
|
|
218
|
-
log(
|
|
217
|
+
const warnings = Array.isArray(result?.warnings) ? result.warnings : []
|
|
218
|
+
const pending = Array.isArray(result?.pending) ? result.pending : []
|
|
219
|
+
const restartReason = typeof result?.restartReason === 'string' ? result.restartReason : null
|
|
220
|
+
log(
|
|
221
|
+
`plugins reloaded (warnings=${warnings.length} pending=${pending.length}${restartReason === null ? '' : ` restart=${restartReason}`})`,
|
|
222
|
+
)
|
|
223
|
+
return { status: 'done', warnings, pending, restartReason }
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
const flush = async () => {
|
|
@@ -247,7 +252,7 @@ function pluginReloader(ctx, config) {
|
|
|
247
252
|
running = true
|
|
248
253
|
await reload(hmr)
|
|
249
254
|
} catch (error) {
|
|
250
|
-
log(`reload
|
|
255
|
+
log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
|
|
251
256
|
} finally {
|
|
252
257
|
running = false
|
|
253
258
|
if (again) {
|
|
@@ -293,19 +298,27 @@ function pluginReloader(ctx, config) {
|
|
|
293
298
|
return 'queued'
|
|
294
299
|
}
|
|
295
300
|
|
|
301
|
+
/** The answer shape a press always gets, whether or not a reload was started. */
|
|
302
|
+
const queuedAnswer = (answer) => ({
|
|
303
|
+
status: answer === 'unavailable' ? 'unavailable' : 'queued',
|
|
304
|
+
warnings: [],
|
|
305
|
+
pending: [],
|
|
306
|
+
restartReason: null,
|
|
307
|
+
})
|
|
308
|
+
|
|
296
309
|
/**
|
|
297
310
|
* The button path: run now and report what happened, so the page can tell the user why the
|
|
298
311
|
* press did not replace anything instead of looking broken.
|
|
299
|
-
* @returns
|
|
312
|
+
* @returns {{status: string, warnings: string[], pending: string[], restartReason: string|null}}
|
|
300
313
|
*/
|
|
301
314
|
const reloadNow = async (force = false) => {
|
|
302
315
|
const hmr = hmrService()
|
|
303
|
-
if (hmr === null) return 'unavailable'
|
|
316
|
+
if (hmr === null) return { status: 'unavailable', warnings: [], pending: [], restartReason: null }
|
|
304
317
|
if (turnsRunning()) log('reloading while turns are still running; they keep running')
|
|
305
|
-
if (running || (force === false && busy())) return queueReload()
|
|
318
|
+
if (running || (force === false && busy())) return queuedAnswer(queueReload())
|
|
306
319
|
const wait = lastRun + MIN_INTERVAL_MS - Date.now()
|
|
307
320
|
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(wait, 2000)))
|
|
308
|
-
if (running || (force === false && busy())) return queueReload()
|
|
321
|
+
if (running || (force === false && busy())) return queuedAnswer(queueReload())
|
|
309
322
|
if (timer !== null) {
|
|
310
323
|
clearTimeout(timer)
|
|
311
324
|
timer = null
|
|
@@ -314,11 +327,16 @@ function pluginReloader(ctx, config) {
|
|
|
314
327
|
deferrals = 0
|
|
315
328
|
lastRun = Date.now()
|
|
316
329
|
try {
|
|
317
|
-
await reload(hmr)
|
|
318
|
-
|
|
330
|
+
const outcome = await reload(hmr)
|
|
331
|
+
if (outcome.pending.length > 0 || outcome.restartReason !== null) {
|
|
332
|
+
// The app accepted the update but has not finished starting the new plugin set. Rebuilding
|
|
333
|
+
// the page now would start a document against a set that is still settling.
|
|
334
|
+
return { status: 'failed', warnings: outcome.warnings, pending: outcome.pending, restartReason: outcome.restartReason }
|
|
335
|
+
}
|
|
336
|
+
return outcome
|
|
319
337
|
} catch (error) {
|
|
320
|
-
log(`reload
|
|
321
|
-
return 'failed'
|
|
338
|
+
log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
|
|
339
|
+
return { status: 'failed', warnings: [], pending: [], restartReason: null }
|
|
322
340
|
} finally {
|
|
323
341
|
running = false
|
|
324
342
|
again = false
|
|
@@ -459,48 +477,98 @@ function currentBundleUrls(graph, request) {
|
|
|
459
477
|
}
|
|
460
478
|
|
|
461
479
|
/**
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
* route as before, so an unknown id still ends as a plain 404.
|
|
480
|
+
* Ask the module table for one bundle at one URL. The newest app takes a standard request object;
|
|
481
|
+
* older ones take the method and address on their own, so both shapes are offered instead of one
|
|
482
|
+
* silently failing on the other.
|
|
466
483
|
*/
|
|
484
|
+
async function fetchBundleAt(clientModules, url, method) {
|
|
485
|
+
const address = `http://unlag.invalid${url}`
|
|
486
|
+
if (typeof Request === 'function') {
|
|
487
|
+
try {
|
|
488
|
+
return await clientModules.fetchBundle(new Request(address, { method, headers: {} }))
|
|
489
|
+
} catch (error) {
|
|
490
|
+
if (error?.name !== 'TypeError') throw error
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return clientModules.fetchBundle({ method, url: address })
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The plugin set one stale request names, answered at the revision the app serves now. Only a body
|
|
498
|
+
* that really contains every plugin it claims is handed back; anything unsure answers null, so the
|
|
499
|
+
* route then behaves exactly as before and an unknown id still ends as a plain 404.
|
|
500
|
+
*/
|
|
501
|
+
async function currentBundleAnswer(clientModules, method, url) {
|
|
502
|
+
const request = bundleRequest(url)
|
|
503
|
+
if (request === null) return null
|
|
504
|
+
const urls = currentBundleUrls(clientModules.graph(), request)
|
|
505
|
+
if (urls.length === 0) return null
|
|
506
|
+
if (request.rev !== '' && urls.some((target) => target.endsWith(`&rev=${request.rev}`))) return null
|
|
507
|
+
for (const target of urls) {
|
|
508
|
+
const response = await fetchBundleAt(clientModules, target, 'GET')
|
|
509
|
+
if (response.status !== 200) continue
|
|
510
|
+
const body = Buffer.from(await response.arrayBuffer())
|
|
511
|
+
if (body.length === 0) continue
|
|
512
|
+
if (!request.sourceMap && !request.ids.every((id) => body.includes(id))) continue
|
|
513
|
+
return {
|
|
514
|
+
body,
|
|
515
|
+
contentType: String(response.headers.get('content-type') ?? 'text/javascript; charset=utf-8'),
|
|
516
|
+
ids: request.ids,
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return null
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Answer one stale request over the plain request/response route an app registers. */
|
|
467
523
|
async function serveCurrentBundle(clientModules, req, res) {
|
|
468
524
|
try {
|
|
469
525
|
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
470
526
|
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
|
|
527
|
+
const answer = await currentBundleAnswer(clientModules, method, req?.url)
|
|
528
|
+
if (answer === null) return false
|
|
529
|
+
if (res.writableEnded === true || res.destroyed === true) return true
|
|
530
|
+
res.writeHead(200, {
|
|
531
|
+
'content-type': answer.contentType,
|
|
532
|
+
'cache-control': 'no-store',
|
|
533
|
+
'content-length': String(answer.body.length),
|
|
534
|
+
})
|
|
535
|
+
res.end(method === 'HEAD' ? undefined : answer.body)
|
|
536
|
+
log(`served ${answer.ids.join(', ')} at the current revision: a page asked for an older one`)
|
|
537
|
+
return true
|
|
493
538
|
} catch (error) {
|
|
494
539
|
log(`cannot serve a bundle from an older revision: ${error?.message ?? error}`)
|
|
495
540
|
return false
|
|
496
541
|
}
|
|
497
542
|
}
|
|
498
543
|
|
|
544
|
+
/** The same answer over the request-in/response-out route a newer app registers. */
|
|
545
|
+
async function serveCurrentBundleResource(clientModules, request) {
|
|
546
|
+
try {
|
|
547
|
+
const method = String(request?.method ?? 'GET').toUpperCase()
|
|
548
|
+
if (method !== 'GET' && method !== 'HEAD') return null
|
|
549
|
+
const answer = await currentBundleAnswer(clientModules, method, request?.url)
|
|
550
|
+
if (answer === null) return null
|
|
551
|
+
log(`served ${answer.ids.join(', ')} at the current revision: a page asked for an older one`)
|
|
552
|
+
return new Response(method === 'HEAD' ? null : answer.body, {
|
|
553
|
+
status: 200,
|
|
554
|
+
headers: {
|
|
555
|
+
'content-type': answer.contentType,
|
|
556
|
+
'cache-control': 'no-store',
|
|
557
|
+
'content-length': String(answer.body.length),
|
|
558
|
+
},
|
|
559
|
+
})
|
|
560
|
+
} catch (error) {
|
|
561
|
+
log(`cannot serve a bundle from an older revision: ${error?.message ?? error}`)
|
|
562
|
+
return null
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
499
566
|
/**
|
|
500
567
|
* 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
|
-
*
|
|
568
|
+
* belongs to the module table, and a second registration of the same prefix is refused, so what is
|
|
569
|
+
* already there is wrapped in place and put back on dispose. A newer app registers the route as a
|
|
570
|
+
* request-in/response-out resource instead of a handler, and the dispatch prefers that one, so the
|
|
571
|
+
* shape the app actually registers is the shape wrapped — otherwise the guard would be a no-op.
|
|
504
572
|
*/
|
|
505
573
|
function serveBundlesAtCurrentRevision(ctx) {
|
|
506
574
|
const server = ctx.get('webServer')
|
|
@@ -508,8 +576,24 @@ function serveBundlesAtCurrentRevision(ctx) {
|
|
|
508
576
|
if (server?.prefixes?.get === undefined) return null
|
|
509
577
|
if (typeof clientModules?.fetchBundle !== 'function' || typeof clientModules?.graph !== 'function') return null
|
|
510
578
|
const route = server.prefixes.get(BUNDLE_ROUTE)
|
|
511
|
-
if (route === null || route === undefined ||
|
|
512
|
-
if (route
|
|
579
|
+
if (route === null || route === undefined || route[SHIM_MARK] === true) return null
|
|
580
|
+
if (typeof route.resource === 'function') {
|
|
581
|
+
const original = route.resource
|
|
582
|
+
const wrapped = async (request) => {
|
|
583
|
+
try {
|
|
584
|
+
const served = await serveCurrentBundleResource(clientModules, request)
|
|
585
|
+
if (served !== null) return served
|
|
586
|
+
} catch (error) {
|
|
587
|
+
log(`cannot answer a bundle request: ${error?.message ?? error}`)
|
|
588
|
+
}
|
|
589
|
+
return original(request)
|
|
590
|
+
}
|
|
591
|
+
route[SHIM_MARK] = true
|
|
592
|
+
route.resource = wrapped
|
|
593
|
+
log('bundles asked for under an older revision are answered at the current one')
|
|
594
|
+
return { route, slot: 'resource', original, wrapped }
|
|
595
|
+
}
|
|
596
|
+
if (typeof route.handler !== 'function') return null
|
|
513
597
|
const original = route.handler
|
|
514
598
|
const wrapped = (req, res) => {
|
|
515
599
|
const fallback = () => original(req, res)
|
|
@@ -523,14 +607,16 @@ function serveBundlesAtCurrentRevision(ctx) {
|
|
|
523
607
|
route[SHIM_MARK] = true
|
|
524
608
|
route.handler = wrapped
|
|
525
609
|
log('bundles asked for under an older revision are answered at the current one')
|
|
526
|
-
return { route, original, wrapped }
|
|
610
|
+
return { route, slot: 'handler', original, wrapped }
|
|
527
611
|
}
|
|
528
612
|
|
|
529
613
|
/** Put the bundle route back the way it was found; a route replaced since then is left alone. */
|
|
530
614
|
function releaseBundleShim(shim) {
|
|
531
615
|
try {
|
|
532
616
|
if (shim === null || shim === undefined) return
|
|
533
|
-
if (shim.
|
|
617
|
+
if (shim.slot === 'resource') {
|
|
618
|
+
if (shim.route.resource === shim.wrapped) shim.route.resource = shim.original
|
|
619
|
+
} else if (shim.route.handler === shim.wrapped) shim.route.handler = shim.original
|
|
534
620
|
if (shim.route[SHIM_MARK] === true) delete shim.route[SHIM_MARK]
|
|
535
621
|
} catch {}
|
|
536
622
|
}
|
|
@@ -544,7 +630,7 @@ function ensureBundleShim(ctx) {
|
|
|
544
630
|
const shim = globalThis[SHIM_KEY]
|
|
545
631
|
if (shim !== null && shim !== undefined) {
|
|
546
632
|
const route = ctx.get('webServer')?.prefixes?.get?.(BUNDLE_ROUTE)
|
|
547
|
-
if (route === shim.route && route.
|
|
633
|
+
if (route === shim.route && route[shim.slot] === shim.wrapped && route[SHIM_MARK] === true) return shim
|
|
548
634
|
releaseBundleShim(shim)
|
|
549
635
|
globalThis[SHIM_KEY] = null
|
|
550
636
|
}
|
|
@@ -556,10 +642,12 @@ function ensureBundleShim(ctx) {
|
|
|
556
642
|
function bundleShimStatus(ctx) {
|
|
557
643
|
const route = ctx.get('webServer')?.prefixes?.get?.(BUNDLE_ROUTE)
|
|
558
644
|
const clientModules = ctx.get('clientModules')
|
|
645
|
+
const shape =
|
|
646
|
+
typeof route?.resource === 'function' ? 'resource' : typeof route?.handler === 'function' ? 'handler' : 'none'
|
|
559
647
|
return {
|
|
560
648
|
route: route !== null && route !== undefined,
|
|
561
|
-
|
|
562
|
-
wrapped: route?.[SHIM_MARK] === true,
|
|
649
|
+
kind: shape,
|
|
650
|
+
wrapped: route?.[SHIM_MARK] === true && typeof route?.[shape === 'none' ? 'handler' : shape] === 'function',
|
|
563
651
|
fetch: typeof clientModules?.fetchBundle === 'function',
|
|
564
652
|
graph: typeof clientModules?.graph === 'function',
|
|
565
653
|
}
|
|
@@ -618,8 +706,8 @@ function serveButton(ctx, controller, config) {
|
|
|
618
706
|
const header = req?.headers?.[BUTTON_HEADER]
|
|
619
707
|
if (typeof header !== 'string' || header === '') return sendJson(res, 403, { status: 'forbidden' })
|
|
620
708
|
if (!settings.reloadPlugins) return sendJson(res, 200, { status: 'off' })
|
|
621
|
-
const
|
|
622
|
-
sendJson(res, 200,
|
|
709
|
+
const outcome = await controller.reloadNow()
|
|
710
|
+
sendJson(res, 200, outcome)
|
|
623
711
|
},
|
|
624
712
|
],
|
|
625
713
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-unlag",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.10",
|
|
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",
|