dsh-unlag 1.7.11 → 1.8.1
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 +5 -8
- package/client.js +139 -39
- package/index.js +272 -99
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,15 +32,15 @@ dsh plugin install dsh-unlag
|
|
|
32
32
|
|
|
33
33
|
## 不会丢什么
|
|
34
34
|
|
|
35
|
-
-
|
|
35
|
+
- 轮次、子代理、终端进程:照常继续。
|
|
36
36
|
- 排队消息、输入草稿、附件:重建前先提交焦点里的内容;有人没存完就拦下,不硬刷。
|
|
37
|
-
-
|
|
37
|
+
- 有未保存改动、开着弹窗、或下个页面要用的模块读不全:拦下并提示,提示里写明卡在哪一项,宁可不刷。
|
|
38
|
+
- 旧页面记着的启动清单分组变了(装过/卸过插件):按插件逐个补齐再交给新页面,不会因为清单 404 把 App 带走。
|
|
38
39
|
- 从不自己重启或退出 App。
|
|
39
40
|
|
|
40
41
|
管不到(它会拦下并说明,不会硬来):
|
|
41
42
|
|
|
42
|
-
-
|
|
43
|
-
- 更新时正被某个任务使用的插件:那个任务可能随之结束。
|
|
43
|
+
- 改动替插件加原生能力、改服务声明或权限:这类改动不在一次点击的范围里。
|
|
44
44
|
- 独立进程和进程级的东西:宠物窗口自己的配置、DSH 自身升级、原生依赖变化。
|
|
45
45
|
|
|
46
46
|
## 排错
|
|
@@ -51,8 +51,5 @@ dsh plugin install dsh-unlag
|
|
|
51
51
|
## 测试
|
|
52
52
|
|
|
53
53
|
```sh
|
|
54
|
-
node test
|
|
55
|
-
node test/bundle-shim.test.mjs
|
|
56
|
-
node test/client-guard.test.mjs
|
|
57
|
-
node test/safety.test.mjs
|
|
54
|
+
node --test test/*.test.mjs
|
|
58
55
|
```
|
package/client.js
CHANGED
|
@@ -6,8 +6,33 @@ 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: '
|
|
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: '
|
|
9
|
+
? { label: '解除卡顿', hint: '卡顿时用:换掉插件代码并重建界面;运行中的任务、排队的消息保留', blocked: '暂时无法重建界面,稍后再试', unsaved: '有未保存的改动,先保存再重试', dialog: '先关闭打开的窗口再重载', queued: '更新已排队,稍后再按一次', applying: '还在应用这次更新,稍后再按一次', restart: '这次改动要重新打开应用才会生效', 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: 'Update queued; press again shortly', applying: 'Still applying this update; press again shortly', restart: 'This change needs the app reopened before it can apply', failed: 'The plugin update did not finish; press again later' };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Why the interface was left as it is, in words a person can act on. The checks below record
|
|
14
|
+
* the first reason they refuse for, so a press that stops can say what it saw instead of
|
|
15
|
+
* repeating one generic sentence.
|
|
16
|
+
*/
|
|
17
|
+
const REASONS = zh
|
|
18
|
+
? { list: '读不到启动信息', row: '启动信息不完整', src: '启动脚本没有地址', graph: '已装插件清单读不到', plugin: '有插件已不在运行', script: '启动脚本读不到', empty: '启动脚本是空的', covers: '启动脚本缺插件', parse: '启动脚本内容不完整' }
|
|
19
|
+
: { list: 'startup info is unavailable', row: 'startup info is incomplete', src: 'a startup script has no address', graph: 'the plugin list cannot be read', plugin: 'a plugin is no longer running', script: 'a startup script cannot be read', empty: 'a startup script is empty', covers: 'a startup script misses a plugin', parse: 'a startup script is incomplete' };
|
|
20
|
+
|
|
21
|
+
/** The last reason a readiness check refused for, or null after a check that passed. */
|
|
22
|
+
let holdReason = null;
|
|
23
|
+
|
|
24
|
+
/** Record why a rebuild is held and answer false for the check that asked. */
|
|
25
|
+
function held(reason) {
|
|
26
|
+
holdReason = reason;
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The notice for one copy key, naming the reason when a readiness check stopped the press. */
|
|
31
|
+
function noticeFor(key) {
|
|
32
|
+
if (key !== 'blocked' || holdReason === null) return COPY[key];
|
|
33
|
+
const why = REASONS[holdReason] ?? REASONS.list;
|
|
34
|
+
return zh ? `暂时无法重建:${why}。稍后再点一次。` : `Cannot rebuild yet: ${why}. Press again shortly.`;
|
|
35
|
+
}
|
|
11
36
|
|
|
12
37
|
// Desktop shell only: the macOS and Windows desktop build owns the page and the running
|
|
13
38
|
// app, so this is where the button belongs. A browser tab can reload freely on its own.
|
|
@@ -87,7 +112,10 @@ window.__ModuleLoader__.load({
|
|
|
87
112
|
function reloadVerdict(data) {
|
|
88
113
|
if (data === null || typeof data !== 'object') return 'failed';
|
|
89
114
|
const status = typeof data.status === 'string' ? data.status : 'failed';
|
|
90
|
-
|
|
115
|
+
// A plugin that did not start makes the set unusable for a rebuild, whatever the status says.
|
|
116
|
+
if (Array.isArray(data.failedEntries) && data.failedEntries.length > 0) return 'failed';
|
|
117
|
+
// `pending` is not a queued update: the changed modules need a restart to load at all.
|
|
118
|
+
if (Array.isArray(data.pending) && data.pending.length > 0) return 'restart';
|
|
91
119
|
if (typeof data.restartReason === 'string' && data.restartReason !== '') return 'failed';
|
|
92
120
|
return status;
|
|
93
121
|
}
|
|
@@ -101,10 +129,10 @@ window.__ModuleLoader__.load({
|
|
|
101
129
|
|
|
102
130
|
/** Read every bundle the next boot would load and prove each listed plugin is inside. */
|
|
103
131
|
async function bundlesCoverStartup(graph) {
|
|
104
|
-
if (graph === null || typeof graph !== 'object') return
|
|
132
|
+
if (graph === null || typeof graph !== 'object') return held('graph');
|
|
105
133
|
const entries = Array.isArray(graph.entries) ? graph.entries : [];
|
|
106
134
|
const batches = Array.isArray(graph.batches) ? graph.batches : [];
|
|
107
|
-
if (entries.length === 0 || batches.length === 0) return
|
|
135
|
+
if (entries.length === 0 || batches.length === 0) return held('graph');
|
|
108
136
|
const covered = new Set();
|
|
109
137
|
const sources = [];
|
|
110
138
|
for (const batch of batches) {
|
|
@@ -112,29 +140,30 @@ window.__ModuleLoader__.load({
|
|
|
112
140
|
if (url === '') continue;
|
|
113
141
|
const listed = Array.isArray(batch.entries) && batch.entries.every((id) => typeof id === 'string') ? batch.entries : [];
|
|
114
142
|
const ids = listed.length > 0 ? listed : bundledIds(url);
|
|
115
|
-
if (ids.length === 0) return
|
|
143
|
+
if (ids.length === 0) return held('row');
|
|
116
144
|
for (const id of ids) covered.add(id);
|
|
117
145
|
sources.push({ url: new URL(url, location.href), ids });
|
|
118
146
|
}
|
|
119
|
-
if (sources.length === 0) return
|
|
147
|
+
if (sources.length === 0) return held('graph');
|
|
120
148
|
for (const entry of entries) {
|
|
121
149
|
const id = typeof entry === 'string' ? entry : entry?.id;
|
|
122
|
-
if (typeof id !== 'string' || !covered.has(id)) return
|
|
150
|
+
if (typeof id !== 'string' || !covered.has(id)) return held('covers');
|
|
123
151
|
}
|
|
124
152
|
for (const source of sources) {
|
|
125
153
|
try {
|
|
126
154
|
const response = await fetch(source.url.toString(), { cache: 'no-store', credentials: 'same-origin' });
|
|
127
|
-
if (!response.ok) return
|
|
155
|
+
if (!response.ok) return held('script');
|
|
128
156
|
const body = await response.text();
|
|
129
|
-
if (body.length === 0) return
|
|
157
|
+
if (body.length === 0) return held('empty');
|
|
130
158
|
for (const id of source.ids) {
|
|
131
|
-
if (!body.includes(id)) return
|
|
159
|
+
if (!body.includes(id)) return held(source.ids.length === 1 ? 'plugin' : 'covers');
|
|
132
160
|
}
|
|
133
|
-
if (!parsesAsClassicScript(body)) return
|
|
161
|
+
if (!parsesAsClassicScript(body)) return held('parse');
|
|
134
162
|
} catch {
|
|
135
|
-
return
|
|
163
|
+
return held('script');
|
|
136
164
|
}
|
|
137
165
|
}
|
|
166
|
+
holdReason = null;
|
|
138
167
|
return true;
|
|
139
168
|
}
|
|
140
169
|
|
|
@@ -149,8 +178,44 @@ window.__ModuleLoader__.load({
|
|
|
149
178
|
* @param body - the served script.
|
|
150
179
|
* @returns true when the body parses, or when this page cannot compile one at all.
|
|
151
180
|
*/
|
|
181
|
+
/** The source with comments and string contents blanked out, so a keyword scan sees code only. */
|
|
182
|
+
function codeOnly(source) {
|
|
183
|
+
let out = '';
|
|
184
|
+
let index = 0;
|
|
185
|
+
while (index < source.length) {
|
|
186
|
+
const here = source[index];
|
|
187
|
+
const next = source[index + 1];
|
|
188
|
+
if (here === '/' && next === '*') {
|
|
189
|
+
const end = source.indexOf('*/', index + 2);
|
|
190
|
+
index = end < 0 ? source.length : end + 2;
|
|
191
|
+
out += ' ';
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (here === '/' && next === '/') {
|
|
195
|
+
const end = source.indexOf('\n', index + 2);
|
|
196
|
+
index = end < 0 ? source.length : end;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (here === '"' || here === "'" || here === '`') {
|
|
200
|
+
index += 1;
|
|
201
|
+
while (index < source.length && source[index] !== here) {
|
|
202
|
+
if (source[index] === '\\') index += 1;
|
|
203
|
+
index += 1;
|
|
204
|
+
}
|
|
205
|
+
index += 1;
|
|
206
|
+
out += '""';
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
out += here;
|
|
210
|
+
index += 1;
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
152
215
|
function parsesAsClassicScript(body) {
|
|
153
216
|
try {
|
|
217
|
+
// A top-level return parses in a function body but not as a classic script.
|
|
218
|
+
if (/(?:^|[;{}])\s*return\b/u.test(codeOnly(body))) return false;
|
|
154
219
|
new Function(body); // eslint-disable-line no-new-func
|
|
155
220
|
return true;
|
|
156
221
|
} catch (error) {
|
|
@@ -197,9 +262,15 @@ window.__ModuleLoader__.load({
|
|
|
197
262
|
let frozen = null;
|
|
198
263
|
for (const row of rows) {
|
|
199
264
|
if (row === null || typeof row !== 'object') continue;
|
|
200
|
-
if (typeof row.kind !== 'string' || !STARTUP_ROW_KINDS.has(row.kind)) return
|
|
201
|
-
|
|
202
|
-
|
|
265
|
+
if (typeof row.kind !== 'string' || !STARTUP_ROW_KINDS.has(row.kind)) return held('row');
|
|
266
|
+
// Only the rows a document runs as scripts have to load as classic scripts. Style and
|
|
267
|
+
// markup rows carry their own text and their address is not a script at all, so reading
|
|
268
|
+
// them back would refuse a sound list over a false alarm.
|
|
269
|
+
const runs = row.kind === 'script-src' || row.kind === 'script-preload';
|
|
270
|
+
if (runs && (typeof row.src !== 'string' || row.src === '')) return held('src');
|
|
271
|
+
if (runs) scripts.push(row.src);
|
|
272
|
+
// Inline rows carry their own text and are not fetched; the queue script among them ends
|
|
273
|
+
// in a nested return that a flat scan would misread, so their text is left to the document.
|
|
203
274
|
if (row.kind !== 'global' || row.name !== '__DSH_BOOT__') continue; // ui-copy-ignore
|
|
204
275
|
let value = row.value;
|
|
205
276
|
if (typeof value === 'string') {
|
|
@@ -211,14 +282,23 @@ window.__ModuleLoader__.load({
|
|
|
211
282
|
}
|
|
212
283
|
if (value !== null && typeof value === 'object') frozen = value;
|
|
213
284
|
}
|
|
214
|
-
if (scripts.length === 0) return
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
285
|
+
if (scripts.length === 0) return held('list');
|
|
286
|
+
// The graph is read twice: one answer taken while the app re-composes says nothing about
|
|
287
|
+
// the next boot, and holding the page is the safe answer when it cannot be read at all.
|
|
288
|
+
let live = await hostBootGraph();
|
|
289
|
+
if (live === null && frozen !== null) {
|
|
290
|
+
await new Promise((resolve) => setTimeout(resolve, 300)); // ui-copy-ignore
|
|
291
|
+
live = await hostBootGraph();
|
|
292
|
+
}
|
|
293
|
+
if (frozen !== null) {
|
|
294
|
+
// The frozen list decides what this document boots. Without the current plugin list there
|
|
295
|
+
// is nothing to prove every plugin in it still exists, so the page is not rebuilt.
|
|
296
|
+
if (live === null) return held('graph');
|
|
218
297
|
const kept = Array.isArray(frozen.entries) ? frozen.entries : [];
|
|
298
|
+
const liveIds = new Set((Array.isArray(live.entries) ? live.entries : []).map((entry) => (typeof entry === 'string' ? entry : entry?.id)));
|
|
219
299
|
for (const entry of kept) {
|
|
220
300
|
const id = typeof entry === 'string' ? entry : entry?.id;
|
|
221
|
-
if (typeof id === 'string' && id !== '' && !liveIds.has(id)) return
|
|
301
|
+
if (typeof id === 'string' && id !== '' && !liveIds.has(id)) return held('plugin');
|
|
222
302
|
}
|
|
223
303
|
}
|
|
224
304
|
for (const src of scripts) {
|
|
@@ -226,21 +306,23 @@ window.__ModuleLoader__.load({
|
|
|
226
306
|
try {
|
|
227
307
|
target = new URL(src, location.href).toString();
|
|
228
308
|
} catch {
|
|
229
|
-
return
|
|
309
|
+
return held('src');
|
|
230
310
|
}
|
|
231
311
|
try {
|
|
232
312
|
const response = await fetch(target, { cache: 'no-store', credentials: 'same-origin' });
|
|
233
|
-
if (!response.ok) return
|
|
313
|
+
if (!response.ok) return held('script');
|
|
234
314
|
const body = await response.text();
|
|
235
|
-
if (body.length === 0) return
|
|
236
|
-
|
|
237
|
-
|
|
315
|
+
if (body.length === 0) return held('empty');
|
|
316
|
+
const ids = bundledIds(target);
|
|
317
|
+
for (const id of ids) {
|
|
318
|
+
if (!body.includes(id)) return held(ids.length === 1 ? 'plugin' : 'covers');
|
|
238
319
|
}
|
|
239
|
-
if (!parsesAsClassicScript(body)) return
|
|
320
|
+
if (!parsesAsClassicScript(body)) return held('parse');
|
|
240
321
|
} catch {
|
|
241
|
-
return
|
|
322
|
+
return held('script');
|
|
242
323
|
}
|
|
243
324
|
}
|
|
325
|
+
holdReason = null;
|
|
244
326
|
return true;
|
|
245
327
|
}
|
|
246
328
|
|
|
@@ -320,18 +402,25 @@ window.__ModuleLoader__.load({
|
|
|
320
402
|
* @returns true when every listed plugin's module is present in the served bundles.
|
|
321
403
|
*/
|
|
322
404
|
async function startupLooksServable() {
|
|
323
|
-
|
|
405
|
+
holdReason = null;
|
|
406
|
+
// Two repeats: the app re-composes its bundles while it starts and after a plugin swap, and
|
|
407
|
+
// a list read in the middle of that can answer for a generation that is already gone.
|
|
408
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
324
409
|
const saved = await desktopStartupPayload();
|
|
325
410
|
// The desktop shell replays one startup list for every document. If it is running but its
|
|
326
411
|
// copy cannot be read, nothing here proves a new document would boot, so the page stays.
|
|
327
|
-
if (saved === null && desktopShellPresent())
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
412
|
+
if (saved === null && desktopShellPresent()) {
|
|
413
|
+
held('list');
|
|
414
|
+
} else {
|
|
415
|
+
const servable =
|
|
416
|
+
saved === null
|
|
417
|
+
? await bundlesCoverStartup((await servedBootGraph()) ?? window.__DSH_BOOT__)
|
|
418
|
+
: await frozenStartupLooksServable(saved);
|
|
419
|
+
if (servable) return true;
|
|
420
|
+
}
|
|
421
|
+
if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 300 + attempt * 300)); // ui-copy-ignore
|
|
334
422
|
}
|
|
423
|
+
if (holdReason === null) held('list');
|
|
335
424
|
return false;
|
|
336
425
|
}
|
|
337
426
|
|
|
@@ -464,7 +553,7 @@ window.__ModuleLoader__.load({
|
|
|
464
553
|
setNotice(null);
|
|
465
554
|
const stop = (key) => {
|
|
466
555
|
setPending(false);
|
|
467
|
-
setNotice(
|
|
556
|
+
setNotice(noticeFor(key));
|
|
468
557
|
};
|
|
469
558
|
const settings = await readSettings();
|
|
470
559
|
const replace = settings === null ? false : settings.plugins;
|
|
@@ -480,7 +569,16 @@ window.__ModuleLoader__.load({
|
|
|
480
569
|
if (replace) {
|
|
481
570
|
const status = await replacePlugins();
|
|
482
571
|
if (status !== 'done' && status !== 'off') {
|
|
483
|
-
const key =
|
|
572
|
+
const key =
|
|
573
|
+
status === 'queued'
|
|
574
|
+
? 'queued'
|
|
575
|
+
: status === 'applying'
|
|
576
|
+
? 'applying'
|
|
577
|
+
: status === 'restart'
|
|
578
|
+
? 'restart'
|
|
579
|
+
: status === 'unavailable'
|
|
580
|
+
? 'blocked'
|
|
581
|
+
: 'failed';
|
|
484
582
|
report('plugin-held', key);
|
|
485
583
|
return stop(key);
|
|
486
584
|
}
|
|
@@ -492,7 +590,8 @@ window.__ModuleLoader__.load({
|
|
|
492
590
|
window.sessionStorage.setItem(STAMP_KEY, String(Date.now()));
|
|
493
591
|
const reason = await prepareInterface();
|
|
494
592
|
if (reason !== null) {
|
|
495
|
-
|
|
593
|
+
// The log keeps the exact check that refused, so a held press can be explained later.
|
|
594
|
+
report('held', reason === 'blocked' && holdReason !== null ? `blocked:${holdReason}` : reason);
|
|
496
595
|
return stop(reason);
|
|
497
596
|
}
|
|
498
597
|
report('rebuild');
|
|
@@ -533,6 +632,7 @@ window.__ModuleLoader__.load({
|
|
|
533
632
|
const PROBE = {
|
|
534
633
|
prepareInterface,
|
|
535
634
|
startupLooksServable,
|
|
635
|
+
startupHoldReason: () => holdReason,
|
|
536
636
|
frozenStartupLooksServable,
|
|
537
637
|
desktopStartupPayload,
|
|
538
638
|
desktopShellPresent,
|
package/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
-
import { appendFileSync, existsSync, readFileSync, statSync, watch
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync, statSync, watch } from 'node:fs'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { join, resolve } from 'node:path'
|
|
5
5
|
|
|
@@ -30,12 +30,6 @@ export const inject = ['webServer']
|
|
|
30
30
|
/** A transaction that has not finished in this long is stuck; the press reports it instead of hanging. */
|
|
31
31
|
const RELOAD_TIMEOUT_MS = 20_000
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
* A saved file reaches the app through the reload service's transaction context. If a later version
|
|
35
|
-
* of the app stops exposing that context, the queue cannot be entered from a save; the profile's
|
|
36
|
-
* patch file is used instead, because the loader watches it and re-imports the modules that changed.
|
|
37
|
-
*/
|
|
38
|
-
const PROFILE_MARKER = '# dsh-unlag: apply saved plugin code'
|
|
39
33
|
|
|
40
34
|
const LOG_PATH = join(homedir(), '.dsh', 'unlag-reload.log')
|
|
41
35
|
/** Tests point this at a scratch file, so a test run leaves the live log readable. */
|
|
@@ -167,54 +161,6 @@ function transactionContext(service) {
|
|
|
167
161
|
return null
|
|
168
162
|
}
|
|
169
163
|
|
|
170
|
-
/** The patch file of the profile this plugin runs in, when the loader exposes the profile. */
|
|
171
|
-
function profilePatch(ctx) {
|
|
172
|
-
try {
|
|
173
|
-
const dir = ctx.get('profileContext')?.dir
|
|
174
|
-
if (typeof dir !== 'string' || dir === '') return null
|
|
175
|
-
const file = join(dir, 'cordis.patch.yml')
|
|
176
|
-
return existsSync(file) ? file : null
|
|
177
|
-
} catch {
|
|
178
|
-
return null
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** Drop the marker line again without touching anything else the file holds. */
|
|
183
|
-
function clearProfileMarker(file) {
|
|
184
|
-
try {
|
|
185
|
-
const text = readFileSync(file, 'utf8')
|
|
186
|
-
if (!text.includes(PROFILE_MARKER)) return
|
|
187
|
-
const withoutBlankLine = text.replace(`\n\n${PROFILE_MARKER}\n`, '\n')
|
|
188
|
-
writeFileSync(file, withoutBlankLine.replace(`\n${PROFILE_MARKER}\n`, '').replace(PROFILE_MARKER, ''))
|
|
189
|
-
} catch (error) {
|
|
190
|
-
log(`a reload marker is still in the profile: ${error?.message ?? error}`)
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Last resort when a save cannot be queued: one comment line in the profile's patch file makes the
|
|
196
|
-
* loader re-import the modules that changed, the same outcome a queued reload gives. The line is
|
|
197
|
-
* taken back out as soon as the loader has read it, and a line a crash leaves behind is removed at
|
|
198
|
-
* the next start, so the profile is left as the user wrote it.
|
|
199
|
-
* @returns whether the profile was asked, so the caller can say why the save did not apply.
|
|
200
|
-
*/
|
|
201
|
-
function markProfileForReload(ctx) {
|
|
202
|
-
const file = profilePatch(ctx)
|
|
203
|
-
if (file === null) return false
|
|
204
|
-
try {
|
|
205
|
-
const text = readFileSync(file, 'utf8')
|
|
206
|
-
if (text.includes(PROFILE_MARKER)) return false
|
|
207
|
-
appendFileSync(file, `\n${PROFILE_MARKER}\n`)
|
|
208
|
-
} catch (error) {
|
|
209
|
-
log(`the profile could not be asked for a reload: ${error?.message ?? error}`)
|
|
210
|
-
return false
|
|
211
|
-
}
|
|
212
|
-
log('the saved plugin code is applied by a profile reload instead')
|
|
213
|
-
const timer = setTimeout(() => clearProfileMarker(file), 3000)
|
|
214
|
-
timer.unref?.()
|
|
215
|
-
return true
|
|
216
|
-
}
|
|
217
|
-
|
|
218
164
|
function pluginReloader(ctx, config) {
|
|
219
165
|
/** HMR can start after this half, so ask per use instead of once at load. */
|
|
220
166
|
const hmrService = () => {
|
|
@@ -223,15 +169,14 @@ function pluginReloader(ctx, config) {
|
|
|
223
169
|
return hmr
|
|
224
170
|
}
|
|
225
171
|
const available = () => hmrService() !== null
|
|
226
|
-
/** A marker line this plugin left in the profile is not the user's; take it back out at start. */
|
|
227
|
-
const leftover = profilePatch(ctx)
|
|
228
|
-
if (leftover !== null) clearProfileMarker(leftover)
|
|
229
172
|
/** Opt-in: wait for a quiet moment instead of swapping at once. Off by default. */
|
|
230
173
|
const idleOnly = config?.idleOnly === true
|
|
231
174
|
const debounce = Number.isFinite(config?.debounce) ? config.debounce : 300
|
|
232
175
|
|
|
233
176
|
let timer = null
|
|
234
177
|
let running = false
|
|
178
|
+
/** The queue request this plugin is still waiting on, so a timeout is not reported as a cancel. */
|
|
179
|
+
let work = null
|
|
235
180
|
let again = false
|
|
236
181
|
let deferrals = 0
|
|
237
182
|
let lastRun = 0
|
|
@@ -274,30 +219,53 @@ function pluginReloader(ctx, config) {
|
|
|
274
219
|
const timer = setTimeout(() => reject(new Error('the plugin update did not finish in time')), limit)
|
|
275
220
|
timer.unref?.()
|
|
276
221
|
})
|
|
222
|
+
/**
|
|
223
|
+
* The queue request itself keeps running after this plugin stops waiting for it. It is tracked
|
|
224
|
+
* so a timeout is reported as still applying, never as a failed update that later fires anyway.
|
|
225
|
+
*/
|
|
226
|
+
const tracked = (request) => {
|
|
227
|
+
work = request
|
|
228
|
+
const settle = () => {
|
|
229
|
+
if (work === request) work = null
|
|
230
|
+
}
|
|
231
|
+
request.then(settle, settle)
|
|
232
|
+
return request
|
|
233
|
+
}
|
|
277
234
|
let result = null
|
|
278
235
|
for (let attempt = 0; ; attempt += 1) {
|
|
279
236
|
try {
|
|
280
|
-
result = await Promise.race([ask(hmr), abandoned()])
|
|
237
|
+
result = await Promise.race([tracked(ask(hmr)), abandoned()])
|
|
281
238
|
break
|
|
282
239
|
} catch (error) {
|
|
283
240
|
const message = String(error?.message ?? error)
|
|
284
|
-
if (message.includes('nested')
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
241
|
+
if (message.includes('nested')) {
|
|
242
|
+
if (attempt >= 9) throw error
|
|
243
|
+
const step = Number(process.env.DSH_UNLAG_RETRY_MS ?? '') || 250
|
|
244
|
+
await new Promise((resolve) => setTimeout(resolve, step * (attempt + 1)))
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
if (message.includes('did not finish in time')) {
|
|
248
|
+
error.stillRunning = work !== null
|
|
288
249
|
throw error
|
|
289
250
|
}
|
|
290
|
-
|
|
291
|
-
await new Promise((resolve) => setTimeout(resolve, step * (attempt + 1)))
|
|
251
|
+
throw error
|
|
292
252
|
}
|
|
293
253
|
}
|
|
294
254
|
const warnings = Array.isArray(result?.warnings) ? result.warnings : []
|
|
295
255
|
const pending = Array.isArray(result?.pending) ? result.pending : []
|
|
296
256
|
const restartReason = typeof result?.restartReason === 'string' ? result.restartReason : null
|
|
257
|
+
/**
|
|
258
|
+
* A reconciliation that returns without throwing can still have left a plugin unloaded: the app
|
|
259
|
+
* reports that as a warning. A set with an entry that did not start is not a set the page can be
|
|
260
|
+
* rebuilt against, so the press is reported as failed and names what did not start.
|
|
261
|
+
*/
|
|
262
|
+
const failedEntries = warnings
|
|
263
|
+
.map((warning) => (typeof warning === 'string' ? warning : String(warning?.message ?? '')))
|
|
264
|
+
.filter((warning) => /fail|cannot|missing|invalid|error/iu.test(warning))
|
|
297
265
|
log(
|
|
298
266
|
`plugins reloaded (warnings=${warnings.length} pending=${pending.length}${restartReason === null ? '' : ` restart=${restartReason}`})`,
|
|
299
267
|
)
|
|
300
|
-
return { status: 'done', warnings, pending, restartReason }
|
|
268
|
+
return { status: 'done', warnings, pending, restartReason, failedEntries }
|
|
301
269
|
}
|
|
302
270
|
|
|
303
271
|
const flush = async () => {
|
|
@@ -381,8 +349,12 @@ function pluginReloader(ctx, config) {
|
|
|
381
349
|
warnings: [],
|
|
382
350
|
pending: [],
|
|
383
351
|
restartReason: null,
|
|
352
|
+
failedEntries: [],
|
|
384
353
|
})
|
|
385
354
|
|
|
355
|
+
/** A press made while an update is still running joins that update instead of queueing another. */
|
|
356
|
+
const busyAnswer = () => (work !== null ? { status: 'applying', warnings: [], pending: [], restartReason: null, failedEntries: [] } : queuedAnswer(queueReload()))
|
|
357
|
+
|
|
386
358
|
/**
|
|
387
359
|
* The button path: run now and report what happened, so the page can tell the user why the
|
|
388
360
|
* press did not replace anything instead of looking broken.
|
|
@@ -390,12 +362,12 @@ function pluginReloader(ctx, config) {
|
|
|
390
362
|
*/
|
|
391
363
|
const reloadNow = async (force = false) => {
|
|
392
364
|
const hmr = hmrService()
|
|
393
|
-
if (hmr === null) return { status: 'unavailable', warnings: [], pending: [], restartReason: null }
|
|
365
|
+
if (hmr === null) return { status: 'unavailable', warnings: [], pending: [], restartReason: null, failedEntries: [] }
|
|
394
366
|
if (turnsRunning()) log('reloading while turns are still running; they keep running')
|
|
395
|
-
if (running || (force === false && busy())) return
|
|
367
|
+
if (running || (force === false && busy())) return busyAnswer()
|
|
396
368
|
const wait = lastRun + MIN_INTERVAL_MS - Date.now()
|
|
397
369
|
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(wait, 2000)))
|
|
398
|
-
if (running || (force === false && busy())) return
|
|
370
|
+
if (running || (force === false && busy())) return busyAnswer()
|
|
399
371
|
if (timer !== null) {
|
|
400
372
|
clearTimeout(timer)
|
|
401
373
|
timer = null
|
|
@@ -405,18 +377,53 @@ function pluginReloader(ctx, config) {
|
|
|
405
377
|
lastRun = Date.now()
|
|
406
378
|
try {
|
|
407
379
|
const outcome = await reload(hmr)
|
|
380
|
+
/**
|
|
381
|
+
* `pending` means the changed modules are not loaded yet and need a restart this plugin does
|
|
382
|
+
* not perform; it is not a queued update that finishes on its own.
|
|
383
|
+
*/
|
|
408
384
|
if (outcome.pending.length > 0 || outcome.restartReason !== null) {
|
|
409
385
|
// The app accepted the update but has not finished starting the new plugin set. Rebuilding
|
|
410
386
|
// the page now would start a document against a set that is still settling.
|
|
411
|
-
return {
|
|
387
|
+
return {
|
|
388
|
+
status: 'failed',
|
|
389
|
+
warnings: outcome.warnings,
|
|
390
|
+
pending: outcome.pending,
|
|
391
|
+
restartReason: outcome.restartReason,
|
|
392
|
+
failedEntries: outcome.failedEntries,
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (outcome.failedEntries.length > 0) {
|
|
396
|
+
return {
|
|
397
|
+
status: 'failed',
|
|
398
|
+
warnings: outcome.warnings,
|
|
399
|
+
pending: [],
|
|
400
|
+
restartReason: null,
|
|
401
|
+
failedEntries: outcome.failedEntries,
|
|
402
|
+
}
|
|
412
403
|
}
|
|
413
404
|
return outcome
|
|
414
405
|
} catch (error) {
|
|
406
|
+
if (error?.stillRunning === true) {
|
|
407
|
+
// The queue still holds the request: it may yet apply, so the press is not called failed.
|
|
408
|
+
return { status: 'applying', warnings: [], pending: [], restartReason: null, failedEntries: [] }
|
|
409
|
+
}
|
|
415
410
|
log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
|
|
416
|
-
return { status: 'failed', warnings: [], pending: [], restartReason: null }
|
|
411
|
+
return { status: 'failed', warnings: [], pending: [], restartReason: null, failedEntries: [] }
|
|
417
412
|
} finally {
|
|
418
|
-
|
|
419
|
-
|
|
413
|
+
if (work === null) {
|
|
414
|
+
running = false
|
|
415
|
+
again = false
|
|
416
|
+
} else {
|
|
417
|
+
// Stay busy until the queue request really settles, so a second press joins it.
|
|
418
|
+
const release = () => {
|
|
419
|
+
running = false
|
|
420
|
+
if (again) {
|
|
421
|
+
again = false
|
|
422
|
+
if (timer === null) timer = setTimeout(() => void flush(), debounce)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
work.then(release, release)
|
|
426
|
+
}
|
|
420
427
|
}
|
|
421
428
|
}
|
|
422
429
|
|
|
@@ -475,10 +482,22 @@ function sendJson(res, status, payload) {
|
|
|
475
482
|
* leaves it naming a module graph that no longer resolves, which is exactly the list a rebuild
|
|
476
483
|
* must not trust.
|
|
477
484
|
*/
|
|
478
|
-
function liveBootGraph(
|
|
485
|
+
function liveBootGraph(ctx) {
|
|
479
486
|
try {
|
|
480
|
-
|
|
481
|
-
|
|
487
|
+
/**
|
|
488
|
+
* The module table's own graph is the list the next document is built from, and reading it has
|
|
489
|
+
* no side effects. The injection table needs a peer for anything page-scoped, so it is not a
|
|
490
|
+
* substitute: a graph that cannot be read stays null and the page holds instead of guessing.
|
|
491
|
+
*/
|
|
492
|
+
const modules = ctx.get('clientModules')
|
|
493
|
+
if (typeof modules?.graph === 'function') {
|
|
494
|
+
const graph = modules.graph()
|
|
495
|
+
if (graph !== null && typeof graph === 'object' && Array.isArray(graph.entries)) return graph
|
|
496
|
+
return null
|
|
497
|
+
}
|
|
498
|
+
const server = ctx.get('webServer')
|
|
499
|
+
if (typeof server?.collectIndexInjections !== 'function') return null
|
|
500
|
+
const rows = server.collectIndexInjections({})
|
|
482
501
|
if (!Array.isArray(rows)) return null
|
|
483
502
|
for (const row of rows) {
|
|
484
503
|
if (row?.kind !== 'global') continue
|
|
@@ -570,6 +589,43 @@ async function fetchBundleAt(clientModules, url, method) {
|
|
|
570
589
|
return clientModules.fetchBundle({ method, url: address })
|
|
571
590
|
}
|
|
572
591
|
|
|
592
|
+
/**
|
|
593
|
+
* One plugin's current module body, read back from the table so it can be put in front of a
|
|
594
|
+
* document that expects a grouping the table no longer keeps.
|
|
595
|
+
*/
|
|
596
|
+
async function currentEntryBody(clientModules, graph, id) {
|
|
597
|
+
const entries = Array.isArray(graph?.entries) ? graph.entries : []
|
|
598
|
+
const entry = entries.find((candidate) => candidate?.id === id)
|
|
599
|
+
const raw = typeof entry?.url === 'string' ? entry.url : ''
|
|
600
|
+
if (raw === '') return null
|
|
601
|
+
const target = raw.startsWith('/') ? raw : `/${raw}`
|
|
602
|
+
const response = await fetchBundleAt(clientModules, target, 'GET')
|
|
603
|
+
if (response.status !== 200) return null
|
|
604
|
+
const body = Buffer.from(await response.arrayBuffer())
|
|
605
|
+
return body.length === 0 ? null : body
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* A plugin set from a generation the table no longer groups the same way. An app that started
|
|
610
|
+
* before plugins were installed asks for exactly the sets its document listed; those sets are not
|
|
611
|
+
* kept as sets any more, but every plugin in them is still served on its own. Reading each one and
|
|
612
|
+
* answering with them in the requested order registers exactly the plugins the document expects,
|
|
613
|
+
* so a boot that would otherwise 404 and end the app comes up instead.
|
|
614
|
+
*/
|
|
615
|
+
async function regroupedBundleAnswer(clientModules, graph, request) {
|
|
616
|
+
if (request.sourceMap) return null
|
|
617
|
+
const parts = []
|
|
618
|
+
for (const id of request.ids) {
|
|
619
|
+
const body = await currentEntryBody(clientModules, graph, id)
|
|
620
|
+
if (body === null) return null
|
|
621
|
+
parts.push(body)
|
|
622
|
+
}
|
|
623
|
+
if (parts.length === 0) return null
|
|
624
|
+
const body = Buffer.concat(parts)
|
|
625
|
+
if (!request.ids.every((id) => body.includes(id))) return null
|
|
626
|
+
return { body, contentType: 'text/javascript; charset=utf-8', ids: request.ids }
|
|
627
|
+
}
|
|
628
|
+
|
|
573
629
|
/**
|
|
574
630
|
* The plugin set one stale request names, answered at the revision the app serves now. Only a body
|
|
575
631
|
* that really contains every plugin it claims is handed back; anything unsure answers null, so the
|
|
@@ -578,22 +634,25 @@ async function fetchBundleAt(clientModules, url, method) {
|
|
|
578
634
|
async function currentBundleAnswer(clientModules, method, url) {
|
|
579
635
|
const request = bundleRequest(url)
|
|
580
636
|
if (request === null) return null
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
if (
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
body
|
|
592
|
-
|
|
593
|
-
|
|
637
|
+
const graph = clientModules.graph()
|
|
638
|
+
// A request already at the generation the table serves is the table's to answer.
|
|
639
|
+
if (typeof graph?.rev === 'string' && graph.rev !== '' && request.rev === graph.rev) return null
|
|
640
|
+
const urls = currentBundleUrls(graph, request)
|
|
641
|
+
if (urls.length > 0 && (request.rev === '' || !urls.some((target) => target.endsWith(`&rev=${request.rev}`)))) {
|
|
642
|
+
for (const target of urls) {
|
|
643
|
+
const response = await fetchBundleAt(clientModules, target, 'GET')
|
|
644
|
+
if (response.status !== 200) continue
|
|
645
|
+
const body = Buffer.from(await response.arrayBuffer())
|
|
646
|
+
if (body.length === 0) continue
|
|
647
|
+
if (!request.sourceMap && !request.ids.every((id) => body.includes(id))) continue
|
|
648
|
+
return {
|
|
649
|
+
body,
|
|
650
|
+
contentType: String(response.headers.get('content-type') ?? 'text/javascript; charset=utf-8'),
|
|
651
|
+
ids: request.ids,
|
|
652
|
+
}
|
|
594
653
|
}
|
|
595
654
|
}
|
|
596
|
-
return
|
|
655
|
+
return regroupedBundleAnswer(clientModules, graph, request)
|
|
597
656
|
}
|
|
598
657
|
|
|
599
658
|
/** Answer one stale request over the plain request/response route an app registers. */
|
|
@@ -618,6 +677,29 @@ async function serveCurrentBundle(clientModules, req, res) {
|
|
|
618
677
|
}
|
|
619
678
|
}
|
|
620
679
|
|
|
680
|
+
/** The revision the module table serves now, or null when it cannot be read. */
|
|
681
|
+
function currentRevision(clientModules) {
|
|
682
|
+
try {
|
|
683
|
+
const graph = clientModules.graph()
|
|
684
|
+
return typeof graph?.rev === 'string' && graph.rev !== '' ? graph.rev : null
|
|
685
|
+
} catch {
|
|
686
|
+
return null
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Point a bundle request at the current revision while keeping the plugin set it names. The table
|
|
692
|
+
* only keeps the previous generation of whole documents, and dynamic chunks are served at the
|
|
693
|
+
* current revision alone; a page that kept an old revision would get a main bundle it cannot load
|
|
694
|
+
* chunks for. Rewriting the revision keeps one generation in the whole answer.
|
|
695
|
+
*/
|
|
696
|
+
function retargetRevision(url, current) {
|
|
697
|
+
if (current === null || typeof url !== 'string') return null
|
|
698
|
+
const match = /([?&])rev=([^&#]*)/u.exec(url)
|
|
699
|
+
if (match === null || match[2] === current) return null
|
|
700
|
+
return `${url.slice(0, match.index)}${match[1]}rev=${current}${url.slice(match.index + match[0].length)}`
|
|
701
|
+
}
|
|
702
|
+
|
|
621
703
|
/** The same answer over the request-in/response-out route a newer app registers. */
|
|
622
704
|
async function serveCurrentBundleResource(clientModules, request) {
|
|
623
705
|
try {
|
|
@@ -657,13 +739,36 @@ function serveBundlesAtCurrentRevision(ctx) {
|
|
|
657
739
|
if (typeof route.resource === 'function') {
|
|
658
740
|
const original = route.resource
|
|
659
741
|
const wrapped = async (request) => {
|
|
742
|
+
/**
|
|
743
|
+
* The table's own answer comes first: it still holds the document generation this page booted
|
|
744
|
+
* from. Only when it has nothing is the current one served, and the request is retargeted at
|
|
745
|
+
* the current revision so the answer and its chunks belong to the same generation.
|
|
746
|
+
*/
|
|
747
|
+
const retargeted = retargetRevision(String(request?.url ?? ''), currentRevision(clientModules))
|
|
748
|
+
const method = String(request?.method ?? 'GET').toUpperCase()
|
|
749
|
+
let answer = null
|
|
750
|
+
try {
|
|
751
|
+
answer = await original(request)
|
|
752
|
+
} catch (error) {
|
|
753
|
+
log(`cannot answer a bundle request: ${error?.message ?? error}`)
|
|
754
|
+
}
|
|
755
|
+
if (retargeted !== null && (answer === null || answer === undefined || answer.status === 404)) {
|
|
756
|
+
try {
|
|
757
|
+
const next = method === 'GET' || method === 'HEAD' ? new Request(retargeted, { method, headers: request.headers }) : request
|
|
758
|
+
const second = await original(next)
|
|
759
|
+
if (second !== null && second !== undefined) answer = second
|
|
760
|
+
} catch (error) {
|
|
761
|
+
log(`cannot answer a bundle request: ${error?.message ?? error}`)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (answer !== null && answer !== undefined && answer.status !== 404) return answer
|
|
660
765
|
try {
|
|
661
766
|
const served = await serveCurrentBundleResource(clientModules, request)
|
|
662
767
|
if (served !== null) return served
|
|
663
768
|
} catch (error) {
|
|
664
769
|
log(`cannot answer a bundle request: ${error?.message ?? error}`)
|
|
665
770
|
}
|
|
666
|
-
return
|
|
771
|
+
return answer
|
|
667
772
|
}
|
|
668
773
|
route[SHIM_MARK] = true
|
|
669
774
|
route.resource = wrapped
|
|
@@ -672,14 +777,82 @@ function serveBundlesAtCurrentRevision(ctx) {
|
|
|
672
777
|
}
|
|
673
778
|
if (typeof route.handler !== 'function') return null
|
|
674
779
|
const original = route.handler
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
780
|
+
/**
|
|
781
|
+
* The table's own answer is kept, and only a 404 is held back long enough to ask whether the
|
|
782
|
+
* current generation can answer instead; the plugin set is retargeted at the current revision
|
|
783
|
+
* first, so the served document and the chunks it later asks for are one generation.
|
|
784
|
+
*/
|
|
785
|
+
const wrapped = async (req, res) => {
|
|
786
|
+
const originalUrl = String(req?.url ?? '')
|
|
787
|
+
const retargeted = retargetRevision(originalUrl, currentRevision(clientModules))
|
|
788
|
+
const realWriteHead = res.writeHead
|
|
789
|
+
const realEnd = res.end
|
|
790
|
+
const ask = async (url) => {
|
|
791
|
+
let held = null
|
|
792
|
+
let body
|
|
793
|
+
if (url !== originalUrl) {
|
|
794
|
+
try {
|
|
795
|
+
req.url = url
|
|
796
|
+
} catch {
|
|
797
|
+
/* a frozen request keeps its own address */
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (typeof realWriteHead === 'function') {
|
|
801
|
+
res.writeHead = (status, ...rest) => {
|
|
802
|
+
if (status === 404) {
|
|
803
|
+
held = { status, headers: rest[0] }
|
|
804
|
+
return res
|
|
805
|
+
}
|
|
806
|
+
return realWriteHead.call(res, status, ...rest)
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (typeof realEnd === 'function') {
|
|
810
|
+
res.end = (...args) => {
|
|
811
|
+
if (held !== null) {
|
|
812
|
+
body = typeof args[0] === 'string' || Buffer.isBuffer(args[0]) ? args[0] : ''
|
|
813
|
+
return res
|
|
814
|
+
}
|
|
815
|
+
return realEnd.apply(res, args)
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
try {
|
|
819
|
+
await Promise.resolve(original(req, res))
|
|
820
|
+
} catch (error) {
|
|
821
|
+
log(`cannot answer a bundle request: ${error?.message ?? error}`)
|
|
822
|
+
} finally {
|
|
823
|
+
try {
|
|
824
|
+
res.writeHead = realWriteHead
|
|
825
|
+
res.end = realEnd
|
|
826
|
+
} catch {
|
|
827
|
+
/* the response object owns its own shape */
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return { held, body }
|
|
831
|
+
}
|
|
832
|
+
// The table's own answer first: it still holds the generation this page booted from.
|
|
833
|
+
let outcome = await ask(originalUrl)
|
|
834
|
+
if (outcome.held !== null && retargeted !== null) outcome = await ask(retargeted)
|
|
835
|
+
if (outcome.held === null) return undefined
|
|
836
|
+
// The shim resolves the set from the address the page asked for, not the retried one.
|
|
837
|
+
try {
|
|
838
|
+
req.url = originalUrl
|
|
839
|
+
} catch {
|
|
840
|
+
/* a frozen request keeps its own address */
|
|
841
|
+
}
|
|
842
|
+
let served = false
|
|
843
|
+
try {
|
|
844
|
+
served = await serveCurrentBundle(clientModules, req, res)
|
|
845
|
+
} catch (error) {
|
|
678
846
|
log(`cannot answer a bundle request: ${error?.message ?? error}`)
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
847
|
+
}
|
|
848
|
+
if (served === true) return undefined
|
|
849
|
+
try {
|
|
850
|
+
realWriteHead.call(res, outcome.held.status, outcome.held.headers)
|
|
851
|
+
realEnd.call(res, outcome.body)
|
|
852
|
+
} catch {
|
|
853
|
+
/* the response is gone; nothing left to answer */
|
|
854
|
+
}
|
|
855
|
+
return undefined
|
|
683
856
|
}
|
|
684
857
|
route[SHIM_MARK] = true
|
|
685
858
|
route.handler = wrapped
|
|
@@ -754,7 +927,7 @@ function serveButton(ctx, controller, config) {
|
|
|
754
927
|
`${BASE_PATH}/graph`,
|
|
755
928
|
(_req, res) => {
|
|
756
929
|
ensureBundleShim(ctx)
|
|
757
|
-
sendJson(res, 200, { graph: liveBootGraph(
|
|
930
|
+
sendJson(res, 200, { graph: liveBootGraph(ctx) })
|
|
758
931
|
},
|
|
759
932
|
],
|
|
760
933
|
[
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-unlag",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
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",
|