dsh-unlag 1.9.1 → 1.9.2
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 +4 -0
- package/client.js +164 -76
- package/index.js +286 -78
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,6 +52,10 @@ dsh plugin install dsh-unlag
|
|
|
52
52
|
- 独立进程和进程级的东西:宠物窗口自己的配置、DSH 自身升级、原生依赖变化。
|
|
53
53
|
- 万一新页面还是起不来:App 会进入错误恢复(退出,或请你停用第三方插件)。本插件不替你选,也不会自己重启或退出 App。
|
|
54
54
|
|
|
55
|
+
## 与官方约定的差异
|
|
56
|
+
|
|
57
|
+
官方的插件资源路由对未知修订返回 404。本插件对旧页面记着的旧修订返回当前内容:桌面版在窗口启动时固定了启动清单,页面按旧修订请求脚本是常态,返回 404 会让新页面起不来、App 直接退出。这是本插件的兼容措施,不是官方行为;不需要时可以整个关掉(设置里两行都关)。
|
|
58
|
+
|
|
55
59
|
## 排错
|
|
56
60
|
|
|
57
61
|
- 日志:`~/.dsh/unlag-reload.log`,换代码/跳过/被拦各一行,含原因。
|
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: '更新已排队,稍后再按一次', 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' };
|
|
9
|
+
? { label: '解除卡顿', hint: '卡顿时用:换掉插件代码并重建界面;运行中的任务、排队的消息保留', blocked: '暂时无法重建界面,稍后再试', unsaved: '有未保存的改动,先保存再重试', dialog: '先关闭打开的窗口再重载', queued: '更新已排队,稍后再按一次', applying: '还在应用这次更新,稍后再按一次', off: '两个开关都关着,先在设置里打开一个', replaced: '插件代码已更新,页面保持不动', done: '插件代码已是最新', 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', off: 'Both switches are off; turn one on in settings', replaced: 'Plugin code updated; the page stayed as it is', done: 'Plugin code is already up to date', restart: 'This change needs the app reopened before it can apply', failed: 'The plugin update did not finish; press again later' };
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Why the interface was left as it is, in words a person can act on. The checks below record
|
|
@@ -33,6 +33,10 @@ window.__ModuleLoader__.load({
|
|
|
33
33
|
* is said so: the update is live either way, and only the new document was skipped.
|
|
34
34
|
*/
|
|
35
35
|
function noticeFor(key, applied = false) {
|
|
36
|
+
// The plugin half already went live; whatever held the page is said on top of that.
|
|
37
|
+
if (applied && key !== 'blocked') {
|
|
38
|
+
return zh ? `插件已更新,页面未重开:${COPY[key]}。` : `Plugins updated; the page stayed: ${COPY[key]}.`;
|
|
39
|
+
}
|
|
36
40
|
if (key !== 'blocked' || holdReason === null) return COPY[key];
|
|
37
41
|
const why = REASONS[holdReason] ?? REASONS.list;
|
|
38
42
|
if (applied) return zh ? `插件已更新,页面未重开:${why}。` : `Plugins updated; the page stayed: ${why}.`;
|
|
@@ -51,8 +55,43 @@ window.__ModuleLoader__.load({
|
|
|
51
55
|
*/
|
|
52
56
|
const PREPARE_EVENT = 'dsh-unlag:before';
|
|
53
57
|
|
|
54
|
-
/**
|
|
58
|
+
/** What this document remembers about its last press, so two presses cannot overlap. */
|
|
55
59
|
const STAMP_KEY = 'dsh-unlag:at'; // ui-copy-ignore
|
|
60
|
+
let pressInFlight = false;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Session storage is denied in some profiles and full in others. A press must not be lost to
|
|
64
|
+
* that: the stamp only paces repeated presses, so an unreadable stamp reads as "no press yet".
|
|
65
|
+
*/
|
|
66
|
+
function stampRead() {
|
|
67
|
+
try {
|
|
68
|
+
const at = Number(window.sessionStorage.getItem(STAMP_KEY) ?? 0);
|
|
69
|
+
return Number.isFinite(at) ? at : 0;
|
|
70
|
+
} catch {
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stampWrite() {
|
|
76
|
+
try {
|
|
77
|
+
window.sessionStorage.setItem(STAMP_KEY, String(Date.now()));
|
|
78
|
+
} catch {
|
|
79
|
+
// Pacing is the only thing this stamp feeds; a denied store costs nothing else.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One bound for every call a press makes: a stalled answer ends the press with a notice
|
|
85
|
+
* instead of holding the button until the page is reloaded by hand.
|
|
86
|
+
*/
|
|
87
|
+
const HOST_TIMEOUT_MS = 12000; // ui-copy-ignore
|
|
88
|
+
function withTimeout(promise, ms = HOST_TIMEOUT_MS) {
|
|
89
|
+
let id = null;
|
|
90
|
+
const timer = new Promise((_resolve, reject) => {
|
|
91
|
+
id = setTimeout(() => reject(new Error('timeout')), ms); // ui-copy-ignore
|
|
92
|
+
});
|
|
93
|
+
return Promise.race([promise, timer]).finally(() => clearTimeout(id));
|
|
94
|
+
}
|
|
56
95
|
|
|
57
96
|
/**
|
|
58
97
|
* What a press does is decided by the plugin's settings row, read from the running app, so
|
|
@@ -63,9 +102,9 @@ window.__ModuleLoader__.load({
|
|
|
63
102
|
|
|
64
103
|
async function readSettings() {
|
|
65
104
|
try {
|
|
66
|
-
const response = await fetch(`${HOST_BASE}/state`, { cache: 'no-store', credentials: 'same-origin' });
|
|
105
|
+
const response = await withTimeout(fetch(`${HOST_BASE}/state`, { cache: 'no-store', credentials: 'same-origin' }));
|
|
67
106
|
if (!response.ok) return null;
|
|
68
|
-
const data = await response.json();
|
|
107
|
+
const data = await withTimeout(response.json());
|
|
69
108
|
if (data === null || typeof data !== 'object') return null;
|
|
70
109
|
return { plugins: data.plugins !== false, page: data.page !== false };
|
|
71
110
|
} catch {
|
|
@@ -93,14 +132,14 @@ window.__ModuleLoader__.load({
|
|
|
93
132
|
/** Ask the running app to replace changed plugins now; a running turn is reported, never cut. */
|
|
94
133
|
async function replacePlugins() {
|
|
95
134
|
try {
|
|
96
|
-
const response = await fetch(`${HOST_BASE}/reload`, {
|
|
135
|
+
const response = await withTimeout(fetch(`${HOST_BASE}/reload`, {
|
|
97
136
|
method: 'POST', // ui-copy-ignore
|
|
98
137
|
credentials: 'same-origin',
|
|
99
138
|
headers: { 'content-type': 'application/json', 'x-dsh-unlag': '1' }, // ui-copy-ignore
|
|
100
139
|
body: '{}', // ui-copy-ignore
|
|
101
|
-
});
|
|
140
|
+
}));
|
|
102
141
|
if (!response.ok) return 'failed';
|
|
103
|
-
const data = await response.json();
|
|
142
|
+
const data = await withTimeout(response.json());
|
|
104
143
|
return reloadVerdict(data);
|
|
105
144
|
} catch {
|
|
106
145
|
return 'failed';
|
|
@@ -129,25 +168,36 @@ window.__ModuleLoader__.load({
|
|
|
129
168
|
function bundledIds(url) {
|
|
130
169
|
const match = /\?\?([^&]+)/.exec(url);
|
|
131
170
|
if (match === null) return [];
|
|
132
|
-
|
|
171
|
+
const ids = [];
|
|
172
|
+
for (const part of match[1].split(',')) {
|
|
173
|
+
let decoded = part;
|
|
174
|
+
try {
|
|
175
|
+
decoded = decodeURIComponent(part);
|
|
176
|
+
} catch {
|
|
177
|
+
decoded = part;
|
|
178
|
+
}
|
|
179
|
+
decoded = decoded.replace(/\/client\.js.*$/u, '');
|
|
180
|
+
if (decoded !== '') ids.push(decoded);
|
|
181
|
+
}
|
|
182
|
+
return ids;
|
|
133
183
|
}
|
|
134
184
|
|
|
135
185
|
/**
|
|
136
|
-
* Ask the
|
|
186
|
+
* Ask the running app whether the scripts the next document would run can start it. The page has no
|
|
137
187
|
* parser that agrees with a script tag, and compiling a candidate body here would execute the
|
|
138
|
-
* plugin inside the page that carries the turn, so the verdict is the
|
|
188
|
+
* plugin inside the page that carries the turn, so the verdict is the app's real parse bound to
|
|
139
189
|
* the bytes it read.
|
|
140
190
|
* @returns 'ok' | 'blocked'
|
|
141
191
|
*/
|
|
142
192
|
async function hostVerifyStartup(scripts) {
|
|
143
193
|
let response;
|
|
144
194
|
try {
|
|
145
|
-
response = await fetch(`${HOST_BASE}/verify`, {
|
|
195
|
+
response = await withTimeout(fetch(`${HOST_BASE}/verify`, {
|
|
146
196
|
method: 'POST', // ui-copy-ignore
|
|
147
197
|
credentials: 'same-origin',
|
|
148
198
|
headers: { 'content-type': 'application/json', 'x-dsh-unlag': '1' }, // ui-copy-ignore
|
|
149
199
|
body: JSON.stringify({ rows: scripts }), // ui-copy-ignore
|
|
150
|
-
});
|
|
200
|
+
}));
|
|
151
201
|
} catch {
|
|
152
202
|
held('verify');
|
|
153
203
|
return 'blocked';
|
|
@@ -158,7 +208,7 @@ window.__ModuleLoader__.load({
|
|
|
158
208
|
}
|
|
159
209
|
let data = null;
|
|
160
210
|
try {
|
|
161
|
-
data = await response.json();
|
|
211
|
+
data = await withTimeout(response.json());
|
|
162
212
|
} catch {
|
|
163
213
|
data = null;
|
|
164
214
|
}
|
|
@@ -171,15 +221,15 @@ window.__ModuleLoader__.load({
|
|
|
171
221
|
return 'blocked';
|
|
172
222
|
}
|
|
173
223
|
|
|
174
|
-
// What the
|
|
224
|
+
// What the app's refusal means to the person pressing: the same gates the checks above use.
|
|
175
225
|
const VERIFY_GATES = {
|
|
176
|
-
empty: 'empty',
|
|
177
|
-
missing: 'script',
|
|
178
|
-
network: 'script',
|
|
179
|
-
parse: 'parse',
|
|
180
|
-
register: 'plugin',
|
|
226
|
+
empty: 'empty', // ui-copy-ignore
|
|
227
|
+
missing: 'script', // ui-copy-ignore
|
|
228
|
+
network: 'script', // ui-copy-ignore
|
|
229
|
+
parse: 'parse', // ui-copy-ignore
|
|
230
|
+
register: 'plugin', // ui-copy-ignore
|
|
181
231
|
dependencies: 'covers',
|
|
182
|
-
list: 'list',
|
|
232
|
+
list: 'list', // ui-copy-ignore
|
|
183
233
|
};
|
|
184
234
|
|
|
185
235
|
/** Read every bundle the next boot would load and prove each listed plugin is inside. */
|
|
@@ -196,8 +246,14 @@ window.__ModuleLoader__.load({
|
|
|
196
246
|
const listed = Array.isArray(batch.entries) && batch.entries.every((id) => typeof id === 'string') ? batch.entries : [];
|
|
197
247
|
const ids = listed.length > 0 ? listed : bundledIds(url);
|
|
198
248
|
if (ids.length === 0) return held('row');
|
|
249
|
+
let resolved = null;
|
|
250
|
+
try {
|
|
251
|
+
resolved = new URL(url, location.href);
|
|
252
|
+
} catch {
|
|
253
|
+
return held('row');
|
|
254
|
+
}
|
|
199
255
|
for (const id of ids) covered.add(id);
|
|
200
|
-
sources.push({ url:
|
|
256
|
+
sources.push({ url: resolved, ids });
|
|
201
257
|
}
|
|
202
258
|
if (sources.length === 0) return held('graph');
|
|
203
259
|
for (const entry of entries) {
|
|
@@ -205,7 +261,7 @@ window.__ModuleLoader__.load({
|
|
|
205
261
|
if (typeof id !== 'string' || !covered.has(id)) return held('covers');
|
|
206
262
|
}
|
|
207
263
|
// Reading these bodies here would need a parser the page does not have and would not prove
|
|
208
|
-
// what the new document registers; the
|
|
264
|
+
// what the new document registers; the app compiles the same bytes with the real parser.
|
|
209
265
|
const verdict = await hostVerifyStartup(sources.map((source) => source.url.toString()));
|
|
210
266
|
if (verdict !== 'ok') return false;
|
|
211
267
|
holdReason = null;
|
|
@@ -231,7 +287,7 @@ window.__ModuleLoader__.load({
|
|
|
231
287
|
const bridge = window.dshDesktopBoot;
|
|
232
288
|
if (bridge === undefined || bridge === null || typeof bridge.ready !== 'function') return null;
|
|
233
289
|
try {
|
|
234
|
-
const payload = await bridge.ready();
|
|
290
|
+
const payload = await withTimeout(bridge.ready(), 8000);
|
|
235
291
|
const rows = Array.isArray(payload?.injections) ? payload.injections : [];
|
|
236
292
|
return rows.length === 0 ? null : rows;
|
|
237
293
|
} catch {
|
|
@@ -342,9 +398,9 @@ window.__ModuleLoader__.load({
|
|
|
342
398
|
*/
|
|
343
399
|
async function hostBootGraph() {
|
|
344
400
|
try {
|
|
345
|
-
const response = await fetch(`${HOST_BASE}/graph`, { cache: 'no-store', credentials: 'same-origin' });
|
|
401
|
+
const response = await withTimeout(fetch(`${HOST_BASE}/graph`, { cache: 'no-store', credentials: 'same-origin' }));
|
|
346
402
|
if (!response.ok) return null;
|
|
347
|
-
const data = await response.json();
|
|
403
|
+
const data = await withTimeout(response.json());
|
|
348
404
|
const graph = data?.graph;
|
|
349
405
|
return graph !== null && typeof graph === 'object' ? graph : null;
|
|
350
406
|
} catch {
|
|
@@ -356,9 +412,9 @@ window.__ModuleLoader__.load({
|
|
|
356
412
|
const fromHost = await hostBootGraph();
|
|
357
413
|
if (fromHost !== null) return fromHost;
|
|
358
414
|
try {
|
|
359
|
-
const response = await fetch(location.href, { cache: 'no-store', credentials: 'same-origin' });
|
|
415
|
+
const response = await withTimeout(fetch(location.href, { cache: 'no-store', credentials: 'same-origin' }));
|
|
360
416
|
if (!response.ok) return null;
|
|
361
|
-
return graphFromHtml(await response.text());
|
|
417
|
+
return graphFromHtml(await withTimeout(response.text()));
|
|
362
418
|
} catch {
|
|
363
419
|
return null;
|
|
364
420
|
}
|
|
@@ -426,7 +482,22 @@ window.__ModuleLoader__.load({
|
|
|
426
482
|
async function commitFocusedField() {
|
|
427
483
|
const active = document.activeElement;
|
|
428
484
|
if (active !== null && typeof active.blur === 'function') active.blur();
|
|
429
|
-
|
|
485
|
+
// One frame lets the field write what it holds. A page that is not painting (a background
|
|
486
|
+
// window, a dropped frame) never answers a frame request, so the wait is also bounded.
|
|
487
|
+
await new Promise((resolve) => {
|
|
488
|
+
let done = false;
|
|
489
|
+
const finish = () => {
|
|
490
|
+
if (done) return;
|
|
491
|
+
done = true;
|
|
492
|
+
resolve();
|
|
493
|
+
};
|
|
494
|
+
try {
|
|
495
|
+
requestAnimationFrame(finish);
|
|
496
|
+
} catch {
|
|
497
|
+
finish();
|
|
498
|
+
}
|
|
499
|
+
setTimeout(finish, 250); // ui-copy-ignore
|
|
500
|
+
});
|
|
430
501
|
}
|
|
431
502
|
|
|
432
503
|
/** Re-enter the interface on a fresh document, keeping the current view in the address. */
|
|
@@ -452,7 +523,7 @@ window.__ModuleLoader__.load({
|
|
|
452
523
|
function stillHeldBack() {
|
|
453
524
|
const probe = new Event('beforeunload', { cancelable: true }); // ui-copy-ignore
|
|
454
525
|
window.dispatchEvent(probe);
|
|
455
|
-
return probe.defaultPrevented === true;
|
|
526
|
+
return probe.defaultPrevented === true || probe.returnValue === false;
|
|
456
527
|
}
|
|
457
528
|
|
|
458
529
|
/**
|
|
@@ -517,60 +588,77 @@ window.__ModuleLoader__.load({
|
|
|
517
588
|
: BUTTON_STYLE;
|
|
518
589
|
|
|
519
590
|
const onClick = async () => {
|
|
520
|
-
if (pending) return;
|
|
521
|
-
|
|
522
|
-
if (
|
|
591
|
+
if (pending || pressInFlight) return;
|
|
592
|
+
// A press the page already answered is not answered twice. Saying so beats a dead button.
|
|
593
|
+
if (Date.now() - stampRead() < 3000) { // ui-copy-ignore
|
|
594
|
+
setNotice(COPY.applying);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
pressInFlight = true;
|
|
523
598
|
setPending(true);
|
|
524
599
|
setNotice(null);
|
|
600
|
+
let navigated = false;
|
|
525
601
|
const stop = (key, applied = false) => {
|
|
526
602
|
setPending(false);
|
|
527
603
|
setNotice(noticeFor(key, applied));
|
|
528
604
|
};
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
// Replace first: a plugin that only changed the interface is then covered by the rebuild.
|
|
539
|
-
// A replacement that did not finish stops the press here: rebuilding the page anyway would
|
|
540
|
-
// start a document against a plugin set the app has not finished applying.
|
|
541
|
-
if (replace) {
|
|
542
|
-
const status = await replacePlugins();
|
|
543
|
-
if (status !== 'done' && status !== 'off') {
|
|
544
|
-
const key =
|
|
545
|
-
status === 'queued'
|
|
546
|
-
? 'queued'
|
|
547
|
-
: status === 'applying'
|
|
548
|
-
? 'applying'
|
|
549
|
-
: status === 'restart'
|
|
550
|
-
? 'restart'
|
|
551
|
-
: status === 'unavailable'
|
|
552
|
-
? 'blocked'
|
|
553
|
-
: 'failed';
|
|
554
|
-
report('plugin-held', key);
|
|
555
|
-
return stop(key);
|
|
605
|
+
try {
|
|
606
|
+
const settings = await readSettings();
|
|
607
|
+
const replace = settings === null ? false : settings.plugins;
|
|
608
|
+
const rebuild = settings === null ? true : settings.page;
|
|
609
|
+
let replaced = false;
|
|
610
|
+
// Nothing is switched on: the press says so instead of looking broken.
|
|
611
|
+
if (!replace && !rebuild) {
|
|
612
|
+
report('off');
|
|
613
|
+
return stop('off');
|
|
556
614
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
615
|
+
// Replace first: a plugin that only changed the interface is then covered by the rebuild.
|
|
616
|
+
// A replacement that did not finish stops the press here: rebuilding the page anyway would
|
|
617
|
+
// start a document against a plugin set the app has not finished applying.
|
|
618
|
+
if (replace) {
|
|
619
|
+
const status = await withTimeout(replacePlugins()).catch(() => 'failed');
|
|
620
|
+
if (status !== 'done' && status !== 'off') {
|
|
621
|
+
const key =
|
|
622
|
+
status === 'queued'
|
|
623
|
+
? 'queued'
|
|
624
|
+
: status === 'applying'
|
|
625
|
+
? 'applying'
|
|
626
|
+
: status === 'restart'
|
|
627
|
+
? 'restart'
|
|
628
|
+
: status === 'unavailable'
|
|
629
|
+
? 'blocked'
|
|
630
|
+
: 'failed';
|
|
631
|
+
report('plugin-held', key);
|
|
632
|
+
return stop(key);
|
|
633
|
+
}
|
|
634
|
+
replaced = status === 'done';
|
|
635
|
+
}
|
|
636
|
+
if (!rebuild) {
|
|
637
|
+
// The plugin half is on and the page half is off: what the press did is worth saying.
|
|
638
|
+
report('replaced');
|
|
639
|
+
return stop(replaced ? 'replaced' : 'done');
|
|
640
|
+
}
|
|
641
|
+
stampWrite();
|
|
642
|
+
const reason = await prepareInterface();
|
|
643
|
+
if (reason !== null) {
|
|
644
|
+
// The log keeps the exact check that refused, so a held press can be explained later.
|
|
645
|
+
const gate = reason === 'blocked' && holdReason !== null ? `blocked:${holdReason}` : reason;
|
|
646
|
+
report('held', replaced ? `applied:${gate}` : gate);
|
|
647
|
+
return stop(reason, replaced);
|
|
648
|
+
}
|
|
649
|
+
report('rebuild');
|
|
650
|
+
navigated = true;
|
|
651
|
+
reloadFreshPage();
|
|
652
|
+
return undefined;
|
|
653
|
+
} catch (error) {
|
|
654
|
+
report('failed', 'press');
|
|
655
|
+
stop('failed');
|
|
656
|
+
return undefined;
|
|
657
|
+
} finally {
|
|
658
|
+
pressInFlight = false;
|
|
659
|
+
// The document goes away on a rebuild, so the button only needs re-enabling when it does not.
|
|
660
|
+
if (!navigated) setPending(false);
|
|
570
661
|
}
|
|
571
|
-
report('rebuild');
|
|
572
|
-
reloadFreshPage();
|
|
573
|
-
setTimeout(() => setPending(false), 4000); // ui-copy-ignore
|
|
574
662
|
};
|
|
575
663
|
|
|
576
664
|
return h('span', { style: WRAP_STYLE },
|
package/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
|
-
import { appendFileSync, existsSync, readFileSync, readdirSync, statSync, watch } from 'node:fs'
|
|
3
|
+
import { appendFileSync, existsSync, readFileSync, readdirSync, statSync, watch, writeFileSync } from 'node:fs'
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
import { join, resolve } from 'node:path'
|
|
6
6
|
import { Script } from 'node:vm'
|
|
@@ -34,6 +34,9 @@ const RELOAD_TIMEOUT_MS = 20_000
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
const LOG_PATH = join(homedir(), '.dsh', 'unlag-reload.log')
|
|
37
|
+
/** Past this many bytes the log starts over: it records recent presses, not an archive. */
|
|
38
|
+
const LOG_MAX_BYTES = 1_000_000 // ui-copy-ignore
|
|
39
|
+
let logWrites = 0
|
|
37
40
|
/** Tests point this at a scratch file, so a test run leaves the live log readable. */
|
|
38
41
|
const logTarget = () => process.env.DSH_UNLAG_LOG ?? LOG_PATH
|
|
39
42
|
/** Survives a module replacement, so a reload cannot leave a second watcher behind. */
|
|
@@ -48,6 +51,8 @@ const BUTTON_HEADER = 'x-dsh-unlag'
|
|
|
48
51
|
const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs'])
|
|
49
52
|
/** Build output and dependency trees change without the source a developer edits changing. */
|
|
50
53
|
const IGNORED_PATH = /(^|\/)(node_modules|\.git|\.cache|dist|build|coverage)([./]|$)/u
|
|
54
|
+
/** The same trees by directory name, so they are not walked at all instead of walked and skipped. */
|
|
55
|
+
const IGNORED_DIR = /^(node_modules|\.git|\.cache|dist|build|coverage|\.next|\.turbo)$/u
|
|
51
56
|
/**
|
|
52
57
|
* A test edit does not change what a running plugin does, so it is not a reason to restart the
|
|
53
58
|
* plugin set. A development session runs its tests far more often than it changes source.
|
|
@@ -63,6 +68,8 @@ const BUNDLE_ROUTE = '/plugins'
|
|
|
63
68
|
const SHIM_MARK = Symbol.for('dsh-unlag.bundle-shim')
|
|
64
69
|
/** Holds the wrapper across a module replacement, so a reload can put the route back. */
|
|
65
70
|
const SHIM_KEY = Symbol.for('dsh-unlag.shim')
|
|
71
|
+
/** The instance that currently owns the routes and the shim; a late dispose checks it first. */
|
|
72
|
+
const INSTANCE_KEY = Symbol.for('dsh-unlag.instance')
|
|
66
73
|
|
|
67
74
|
/**
|
|
68
75
|
* The three rows this plugin shows in Settings, in the schema dialect the settings form reads.
|
|
@@ -124,7 +131,18 @@ function settingsOf(config) {
|
|
|
124
131
|
|
|
125
132
|
function log(line) {
|
|
126
133
|
try {
|
|
127
|
-
|
|
134
|
+
const target = logTarget()
|
|
135
|
+
// A file that only grows eventually costs the app its own memory budget: every 200 lines the
|
|
136
|
+
// size is checked once, and a log past the cap starts over instead of growing without end.
|
|
137
|
+
logWrites += 1
|
|
138
|
+
if (logWrites % 200 === 1) {
|
|
139
|
+
try {
|
|
140
|
+
if (statSync(target).size > LOG_MAX_BYTES) writeFileSync(target, '')
|
|
141
|
+
} catch {
|
|
142
|
+
/* a missing or unreadable log is created by the append below */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
appendFileSync(target, `${new Date().toISOString()} ${line}\n`)
|
|
128
146
|
} catch {}
|
|
129
147
|
}
|
|
130
148
|
|
|
@@ -155,6 +173,9 @@ function fingerprintOf(roots) {
|
|
|
155
173
|
for (const item of names) {
|
|
156
174
|
const path = join(dir, item.name)
|
|
157
175
|
if (item.isDirectory()) {
|
|
176
|
+
// A dependency or build tree can hold tens of thousands of files, and stat-ing them on
|
|
177
|
+
// every watch event blocks the event loop every running turn needs.
|
|
178
|
+
if (IGNORED_DIR.test(item.name)) continue
|
|
158
179
|
walk(path, depth + 1)
|
|
159
180
|
continue
|
|
160
181
|
}
|
|
@@ -240,7 +261,7 @@ function pluginReloader(ctx, config) {
|
|
|
240
261
|
let running = false
|
|
241
262
|
/**
|
|
242
263
|
* The queue request this plugin is still waiting on, so a timeout is not reported as a cancel.
|
|
243
|
-
* It lives
|
|
264
|
+
* It lives outside this closure: replacing this plugin builds a new closure, and
|
|
244
265
|
* a press landing after that must still join the update the first press started rather than queue
|
|
245
266
|
* a second one beside it.
|
|
246
267
|
*/
|
|
@@ -398,7 +419,7 @@ function pluginReloader(ctx, config) {
|
|
|
398
419
|
running = false
|
|
399
420
|
if (again) {
|
|
400
421
|
again = false
|
|
401
|
-
if (timer === null) timer = setTimeout(() => void flush(), debounce)
|
|
422
|
+
if (timer === null) timer = unrefTimer(setTimeout(() => void flush(), debounce))
|
|
402
423
|
}
|
|
403
424
|
}
|
|
404
425
|
workState.current.then(release, release)
|
|
@@ -409,18 +430,25 @@ function pluginReloader(ctx, config) {
|
|
|
409
430
|
} finally {
|
|
410
431
|
if (again) {
|
|
411
432
|
again = false
|
|
412
|
-
if (timer === null) timer = setTimeout(() => void flush(), debounce)
|
|
433
|
+
if (timer === null) timer = unrefTimer(setTimeout(() => void flush(), debounce))
|
|
413
434
|
}
|
|
414
435
|
}
|
|
415
436
|
}
|
|
416
437
|
|
|
438
|
+
/** A timer here must not keep the app alive on its own once this plugin is done. */
|
|
439
|
+
const unrefTimer = (handle) => {
|
|
440
|
+
handle?.unref?.()
|
|
441
|
+
return handle
|
|
442
|
+
}
|
|
443
|
+
|
|
417
444
|
const schedule = (filename) => {
|
|
418
445
|
try {
|
|
446
|
+
if (stopped) return
|
|
419
447
|
if (!worthReloading(filename)) return
|
|
420
448
|
fromWatch = true
|
|
421
449
|
lastChange = String(filename).split('/').pop() ?? ''
|
|
422
450
|
if (timer !== null) clearTimeout(timer)
|
|
423
|
-
timer = setTimeout(() => void flush(), settle)
|
|
451
|
+
timer = unrefTimer(setTimeout(() => void flush(), settle))
|
|
424
452
|
} catch (error) {
|
|
425
453
|
log(`ignored a watch event: ${error?.message ?? error}`)
|
|
426
454
|
}
|
|
@@ -432,23 +460,26 @@ function pluginReloader(ctx, config) {
|
|
|
432
460
|
*/
|
|
433
461
|
const WAIT_MAX_MS = Number.isFinite(config?.busyWaitMs) ? Math.max(0, config.busyWaitMs) : 60_000
|
|
434
462
|
let queued = null
|
|
463
|
+
let stopped = false
|
|
435
464
|
const queueReload = () => {
|
|
465
|
+
if (stopped) return 'unavailable'
|
|
436
466
|
if (queued !== null) return 'queued'
|
|
437
467
|
const started = Date.now()
|
|
438
468
|
const tick = () => {
|
|
439
469
|
queued = null
|
|
470
|
+
if (stopped) return
|
|
440
471
|
if (running) {
|
|
441
|
-
queued = setTimeout(tick, 500)
|
|
472
|
+
queued = unrefTimer(setTimeout(tick, 500))
|
|
442
473
|
return
|
|
443
474
|
}
|
|
444
475
|
if (busy() && Date.now() - started < WAIT_MAX_MS) {
|
|
445
|
-
queued = setTimeout(tick, 1000)
|
|
476
|
+
queued = unrefTimer(setTimeout(tick, 1000))
|
|
446
477
|
return
|
|
447
478
|
}
|
|
448
479
|
if (busy()) log('reloading while turns are still running; they keep running')
|
|
449
|
-
void reloadNow(true)
|
|
480
|
+
void reloadNow(true).catch(() => {})
|
|
450
481
|
}
|
|
451
|
-
queued = setTimeout(tick, 1000)
|
|
482
|
+
queued = unrefTimer(setTimeout(tick, 1000))
|
|
452
483
|
return 'queued'
|
|
453
484
|
}
|
|
454
485
|
|
|
@@ -469,7 +500,7 @@ function pluginReloader(ctx, config) {
|
|
|
469
500
|
* press did not replace anything instead of looking broken.
|
|
470
501
|
* @returns {{status: string, warnings: string[], pending: string[], restartReason: string|null}}
|
|
471
502
|
*/
|
|
472
|
-
const
|
|
503
|
+
const runNow = async (force = false) => {
|
|
473
504
|
const hmr = hmrService()
|
|
474
505
|
if (hmr === null) return { status: 'unavailable', warnings: [], pending: [], restartReason: null, failedEntries: [] }
|
|
475
506
|
log('manual reload: the refresh button was pressed')
|
|
@@ -495,18 +526,18 @@ function pluginReloader(ctx, config) {
|
|
|
495
526
|
// The app accepted the update but has not finished starting the new plugin set. Rebuilding
|
|
496
527
|
// the page now would start a document against a set that is still settling.
|
|
497
528
|
return {
|
|
498
|
-
status: 'failed',
|
|
499
|
-
warnings: outcome.warnings,
|
|
500
|
-
pending: outcome.pending,
|
|
529
|
+
status: 'failed', // ui-copy-ignore
|
|
530
|
+
warnings: outcome.warnings, // ui-copy-ignore
|
|
531
|
+
pending: outcome.pending, // ui-copy-ignore
|
|
501
532
|
restartReason: outcome.restartReason,
|
|
502
533
|
failedEntries: outcome.failedEntries,
|
|
503
534
|
}
|
|
504
535
|
}
|
|
505
536
|
if (outcome.failedEntries.length > 0) {
|
|
506
537
|
return {
|
|
507
|
-
status: 'failed',
|
|
508
|
-
warnings: outcome.warnings,
|
|
509
|
-
pending: [],
|
|
538
|
+
status: 'failed', // ui-copy-ignore
|
|
539
|
+
warnings: outcome.warnings, // ui-copy-ignore
|
|
540
|
+
pending: [], // ui-copy-ignore
|
|
510
541
|
restartReason: null,
|
|
511
542
|
failedEntries: outcome.failedEntries,
|
|
512
543
|
}
|
|
@@ -529,7 +560,7 @@ function pluginReloader(ctx, config) {
|
|
|
529
560
|
running = false
|
|
530
561
|
if (again) {
|
|
531
562
|
again = false
|
|
532
|
-
if (timer === null) timer = setTimeout(() => void flush(), debounce)
|
|
563
|
+
if (timer === null) timer = unrefTimer(setTimeout(() => void flush(), debounce))
|
|
533
564
|
}
|
|
534
565
|
}
|
|
535
566
|
workState.current.then(release, release)
|
|
@@ -537,6 +568,19 @@ function pluginReloader(ctx, config) {
|
|
|
537
568
|
}
|
|
538
569
|
}
|
|
539
570
|
|
|
571
|
+
/**
|
|
572
|
+
* The press path never rejects: a rejection here would be unhandled and end the app, and the page
|
|
573
|
+
* would wait for an answer that never comes.
|
|
574
|
+
*/
|
|
575
|
+
const reloadNow = async (force = false) => {
|
|
576
|
+
try {
|
|
577
|
+
return await runNow(force)
|
|
578
|
+
} catch (error) {
|
|
579
|
+
log(`a press could not be answered: ${error?.message ?? error}`)
|
|
580
|
+
return { status: 'failed', warnings: [], pending: [], restartReason: null, failedEntries: [] }
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
540
584
|
/** Watch the development roots; a missing or unreadable root is logged, never fatal. */
|
|
541
585
|
const startWatching = () => {
|
|
542
586
|
const hmr = hmrService()
|
|
@@ -567,8 +611,11 @@ function pluginReloader(ctx, config) {
|
|
|
567
611
|
}
|
|
568
612
|
|
|
569
613
|
const stop = () => {
|
|
614
|
+
stopped = true
|
|
570
615
|
if (queued !== null) clearTimeout(queued)
|
|
571
616
|
queued = null
|
|
617
|
+
if (timer !== null) clearTimeout(timer)
|
|
618
|
+
timer = null
|
|
572
619
|
}
|
|
573
620
|
|
|
574
621
|
return { reloadNow, startWatching, available, stop }
|
|
@@ -578,37 +625,123 @@ function pluginReloader(ctx, config) {
|
|
|
578
625
|
function sendJson(res, status, payload) {
|
|
579
626
|
try {
|
|
580
627
|
if (res.writableEnded === true || res.destroyed === true) return
|
|
581
|
-
|
|
628
|
+
let body = ''
|
|
629
|
+
try {
|
|
630
|
+
body = JSON.stringify(payload)
|
|
631
|
+
} catch {
|
|
632
|
+
body = '{"status":"error","detail":"unserializable"}' // ui-copy-ignore
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* A live graph can hold cycles and a socket can already carry a status: neither may leave the
|
|
636
|
+
* response open, because the page waits on this request and a press would never finish.
|
|
637
|
+
*/
|
|
638
|
+
if (res.headersSent === true) {
|
|
639
|
+
res.end()
|
|
640
|
+
return
|
|
641
|
+
}
|
|
582
642
|
res.writeHead(status, {
|
|
583
643
|
'content-type': 'application/json; charset=utf-8',
|
|
584
644
|
'cache-control': 'no-store',
|
|
585
645
|
'content-length': Buffer.byteLength(body),
|
|
586
646
|
})
|
|
587
647
|
res.end(body)
|
|
588
|
-
} catch {
|
|
648
|
+
} catch {
|
|
649
|
+
try {
|
|
650
|
+
if (res.writableEnded !== true && res.destroyed !== true) res.end()
|
|
651
|
+
} catch {
|
|
652
|
+
/* the socket is already gone */
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* A page this machine may have opened: loopback, a private network address, or a `.local` name.
|
|
659
|
+
* A public name that resolves here is the rebinding case, where another site becomes
|
|
660
|
+
* same-origin by pointing at this machine.
|
|
661
|
+
*/
|
|
662
|
+
const LOCAL_ADDRESS = /^(127\.\d+\.\d+\.\d+|localhost|\[::1\]|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|169\.254\.\d+\.\d+|[a-z0-9-]+\.local)(:\d+)?$/u
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Whether one request may act for the button. The page sets a header a cross-site form cannot set,
|
|
666
|
+
* a browser sends it only after a preflight this server never allows, and the address check covers
|
|
667
|
+
* the case that bypasses preflight: a page on a public name that resolves to this machine.
|
|
668
|
+
*/
|
|
669
|
+
function buttonAllowed(req) {
|
|
670
|
+
try {
|
|
671
|
+
const header = req?.headers?.[BUTTON_HEADER]
|
|
672
|
+
if (typeof header !== 'string' || header === '') return false
|
|
673
|
+
const site = typeof req?.headers?.host === 'string' ? req.headers.host.trim().toLowerCase() : '' // ui-copy-ignore
|
|
674
|
+
// No address at all is not a browser request; nothing about it can be checked, so the header is
|
|
675
|
+
// the whole gate, exactly as it was for earlier versions.
|
|
676
|
+
if (site !== '' && LOCAL_ADDRESS.test(site) === false) {
|
|
677
|
+
log(`refused a press from ${site}`)
|
|
678
|
+
return false
|
|
679
|
+
}
|
|
680
|
+
const origin = typeof req?.headers?.origin === 'string' ? req.headers.origin.trim().toLowerCase() : ''
|
|
681
|
+
if (origin === 'null') return false
|
|
682
|
+
if (origin !== '') {
|
|
683
|
+
let originHost = ''
|
|
684
|
+
try {
|
|
685
|
+
originSite = new URL(origin).host.toLowerCase() // ui-copy-ignore
|
|
686
|
+
} catch {
|
|
687
|
+
return false
|
|
688
|
+
}
|
|
689
|
+
if (originSite !== site) {
|
|
690
|
+
log(`refused a press from ${origin}`)
|
|
691
|
+
return false
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return true
|
|
695
|
+
} catch {
|
|
696
|
+
return false
|
|
697
|
+
}
|
|
589
698
|
}
|
|
590
699
|
|
|
591
700
|
/** Read one small JSON request body; anything unreadable answers null instead of hanging. */
|
|
592
701
|
function readJsonBody(req, limit = 64 * 1024) {
|
|
702
|
+
|
|
593
703
|
return new Promise((resolve) => {
|
|
594
704
|
let size = 0
|
|
705
|
+
let done = false
|
|
595
706
|
const parts = []
|
|
707
|
+
const finish = (value) => {
|
|
708
|
+
if (done === true) return
|
|
709
|
+
done = true
|
|
710
|
+
clearTimeout(timer)
|
|
711
|
+
resolve(value)
|
|
712
|
+
}
|
|
713
|
+
// A request that opens a body and never finishes it would hold this handler forever.
|
|
714
|
+
const timer = setTimeout(() => {
|
|
715
|
+
finish(null)
|
|
716
|
+
try {
|
|
717
|
+
req.destroy?.()
|
|
718
|
+
} catch {
|
|
719
|
+
/* the request is already gone */
|
|
720
|
+
}
|
|
721
|
+
}, 10000) // ui-copy-ignore
|
|
722
|
+
// A body that never ends must not keep the process alive on its own.
|
|
723
|
+
timer.unref?.()
|
|
596
724
|
try {
|
|
597
725
|
req.on('data', (chunk) => {
|
|
598
726
|
size += chunk.length
|
|
599
|
-
if (size
|
|
727
|
+
if (size > limit) {
|
|
728
|
+
finish(null)
|
|
729
|
+
return
|
|
730
|
+
}
|
|
731
|
+
parts.push(chunk)
|
|
600
732
|
})
|
|
601
733
|
req.on('end', () => {
|
|
602
|
-
if (
|
|
734
|
+
if (done === true) return
|
|
603
735
|
try {
|
|
604
|
-
|
|
736
|
+
finish(JSON.parse(Buffer.concat(parts).toString('utf8')))
|
|
605
737
|
} catch {
|
|
606
|
-
|
|
738
|
+
finish(null)
|
|
607
739
|
}
|
|
608
740
|
})
|
|
609
|
-
req.on('error', () =>
|
|
741
|
+
req.on('error', () => finish(null))
|
|
742
|
+
req.on('aborted', () => finish(null))
|
|
610
743
|
} catch {
|
|
611
|
-
|
|
744
|
+
finish(null)
|
|
612
745
|
}
|
|
613
746
|
})
|
|
614
747
|
}
|
|
@@ -761,11 +894,14 @@ async function regroupedBundleAnswer(clientModules, graph, request) {
|
|
|
761
894
|
/**
|
|
762
895
|
* Each entry body is a script of its own and need not end where another can begin: an entry
|
|
763
896
|
* that ends in `})` followed by one that starts with an identifier is a syntax error, and a
|
|
764
|
-
* document
|
|
897
|
+
* document runs neither. A line holding only `;` between them keeps every body its own
|
|
765
898
|
* statement whatever the two sides end and start with, and leaves each body itself untouched.
|
|
766
899
|
*/
|
|
767
900
|
const body = Buffer.concat(parts.flatMap((part, index) => (index === parts.length - 1 ? [part] : [part, Buffer.from('\n;\n')])))
|
|
768
|
-
|
|
901
|
+
// A substring proves nothing: an id like "ui" is inside a body registering "ui-conversation".
|
|
902
|
+
// Only ids the assembled script really registers through the loader count as an answer.
|
|
903
|
+
const text = body.toString('utf8')
|
|
904
|
+
if (!request.ids.every((id) => bodyNamesId(text, id))) return null
|
|
769
905
|
return { body, contentType: 'text/javascript; charset=utf-8', ids: request.ids }
|
|
770
906
|
}
|
|
771
907
|
|
|
@@ -787,7 +923,12 @@ async function currentBundleAnswer(clientModules, method, url) {
|
|
|
787
923
|
if (response.status !== 200) continue
|
|
788
924
|
const body = Buffer.from(await response.arrayBuffer())
|
|
789
925
|
if (body.length === 0) continue
|
|
790
|
-
if (!request.sourceMap
|
|
926
|
+
if (!request.sourceMap) {
|
|
927
|
+
// Every plugin the request names has to appear in the body as a whole id, not as part of
|
|
928
|
+
// a longer one.
|
|
929
|
+
const text = body.toString('utf8')
|
|
930
|
+
if (!request.ids.every((id) => bodyNamesId(text, id))) continue
|
|
931
|
+
}
|
|
791
932
|
return {
|
|
792
933
|
body,
|
|
793
934
|
contentType: String(response.headers.get('content-type') ?? 'text/javascript; charset=utf-8'),
|
|
@@ -889,7 +1030,11 @@ function serveBundlesAtCurrentRevision(ctx) {
|
|
|
889
1030
|
if (server?.prefixes?.get === undefined) return null
|
|
890
1031
|
if (typeof clientModules?.fetchBundle !== 'function' || typeof clientModules?.graph !== 'function') return null
|
|
891
1032
|
const route = server.prefixes.get(BUNDLE_ROUTE)
|
|
892
|
-
if (route === null || route === undefined
|
|
1033
|
+
if (route === null || route === undefined) return null
|
|
1034
|
+
// The mark names the wrapper it belongs to: a route copied from a wrapped one carries a stale
|
|
1035
|
+
// mark, and only a mark pointing at the function installed right now means "already wrapped".
|
|
1036
|
+
const installed = typeof route.resource === 'function' ? route.resource : route.handler
|
|
1037
|
+
if (route[SHIM_MARK] === installed && installed !== undefined) return null
|
|
893
1038
|
if (typeof route.resource === 'function') {
|
|
894
1039
|
const original = route.resource
|
|
895
1040
|
const wrapped = async (request) => {
|
|
@@ -925,8 +1070,8 @@ function serveBundlesAtCurrentRevision(ctx) {
|
|
|
925
1070
|
}
|
|
926
1071
|
return answer
|
|
927
1072
|
}
|
|
928
|
-
route[SHIM_MARK] = true
|
|
929
1073
|
route.resource = wrapped
|
|
1074
|
+
route[SHIM_MARK] = wrapped
|
|
930
1075
|
log('bundles asked for under an older revision are answered at the current one')
|
|
931
1076
|
return { route, slot: 'resource', original, wrapped }
|
|
932
1077
|
}
|
|
@@ -1002,15 +1147,25 @@ function serveBundlesAtCurrentRevision(ctx) {
|
|
|
1002
1147
|
}
|
|
1003
1148
|
if (served === true) return undefined
|
|
1004
1149
|
try {
|
|
1150
|
+
// A body that already wrote a status cannot take another one; ending is what the document
|
|
1151
|
+
// needs, and leaving the socket open is what hangs it.
|
|
1152
|
+
if (res.headersSent === true) {
|
|
1153
|
+
if (res.writableEnded !== true && res.destroyed !== true) realEnd.call(res, outcome.body)
|
|
1154
|
+
return undefined
|
|
1155
|
+
}
|
|
1005
1156
|
realWriteHead.call(res, outcome.held.status, outcome.held.headers)
|
|
1006
1157
|
realEnd.call(res, outcome.body)
|
|
1007
1158
|
} catch {
|
|
1008
|
-
|
|
1159
|
+
try {
|
|
1160
|
+
if (res.writableEnded !== true && res.destroyed !== true) realEnd.call(res)
|
|
1161
|
+
} catch {
|
|
1162
|
+
/* the response is gone; nothing left to answer */
|
|
1163
|
+
}
|
|
1009
1164
|
}
|
|
1010
1165
|
return undefined
|
|
1011
1166
|
}
|
|
1012
|
-
route[SHIM_MARK] = true
|
|
1013
1167
|
route.handler = wrapped
|
|
1168
|
+
route[SHIM_MARK] = wrapped
|
|
1014
1169
|
log('bundles asked for under an older revision are answered at the current one')
|
|
1015
1170
|
return { route, slot: 'handler', original, wrapped }
|
|
1016
1171
|
}
|
|
@@ -1019,10 +1174,18 @@ function serveBundlesAtCurrentRevision(ctx) {
|
|
|
1019
1174
|
function releaseBundleShim(shim) {
|
|
1020
1175
|
try {
|
|
1021
1176
|
if (shim === null || shim === undefined) return
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1177
|
+
/**
|
|
1178
|
+
* Only the wrapper this record installed may be removed. Without the identity check a dispose
|
|
1179
|
+
* that lands after the next apply would put the original back over a newer wrapper, or strip the
|
|
1180
|
+
* mark off one it does not own and let the route be wrapped twice around one response.
|
|
1181
|
+
*/
|
|
1182
|
+
const current = shim.slot === 'resource' ? shim.route.resource : shim.route.handler
|
|
1183
|
+
if (current === shim.wrapped) {
|
|
1184
|
+
if (shim.slot === 'resource') shim.route.resource = shim.original
|
|
1185
|
+
else shim.route.handler = shim.original
|
|
1186
|
+
}
|
|
1187
|
+
// The mark belongs to this wrapper only; a mark left by a newer one stays on the route.
|
|
1188
|
+
if (shim.route[SHIM_MARK] === shim.wrapped) delete shim.route[SHIM_MARK]
|
|
1026
1189
|
} catch {}
|
|
1027
1190
|
}
|
|
1028
1191
|
|
|
@@ -1035,7 +1198,7 @@ function ensureBundleShim(ctx) {
|
|
|
1035
1198
|
const shim = globalThis[SHIM_KEY]
|
|
1036
1199
|
if (shim !== null && shim !== undefined) {
|
|
1037
1200
|
const route = ctx.get('webServer')?.prefixes?.get?.(BUNDLE_ROUTE)
|
|
1038
|
-
if (route === shim.route && route[shim.slot] === shim.wrapped && route[SHIM_MARK] ===
|
|
1201
|
+
if (route === shim.route && route[shim.slot] === shim.wrapped && route[SHIM_MARK] === shim.wrapped) return shim
|
|
1039
1202
|
releaseBundleShim(shim)
|
|
1040
1203
|
globalThis[SHIM_KEY] = null
|
|
1041
1204
|
}
|
|
@@ -1052,12 +1215,26 @@ function bundleShimStatus(ctx) {
|
|
|
1052
1215
|
return {
|
|
1053
1216
|
route: route !== null && route !== undefined,
|
|
1054
1217
|
kind: shape,
|
|
1055
|
-
wrapped: route?.[SHIM_MARK]
|
|
1218
|
+
wrapped: route?.[SHIM_MARK] !== undefined && route?.[SHIM_MARK] === route?.[shape === 'none' ? 'handler' : shape],
|
|
1056
1219
|
fetch: typeof clientModules?.fetchBundle === 'function',
|
|
1057
1220
|
graph: typeof clientModules?.graph === 'function',
|
|
1058
1221
|
}
|
|
1059
1222
|
}
|
|
1060
1223
|
|
|
1224
|
+
/**
|
|
1225
|
+
* Whether a served body names one plugin as a whole word. A bare substring is not enough: "ui" is
|
|
1226
|
+
* inside "ui-conversation", and a body for the wrong generation would then be served as if sound.
|
|
1227
|
+
*/
|
|
1228
|
+
function bodyNamesId(body, id) {
|
|
1229
|
+
let at = body.indexOf(id)
|
|
1230
|
+
while (at >= 0) {
|
|
1231
|
+
const next = body[at + id.length]
|
|
1232
|
+
if (next === undefined || /[-\w$./@]/u.test(next) === false) return true
|
|
1233
|
+
at = body.indexOf(id, at + 1)
|
|
1234
|
+
}
|
|
1235
|
+
return false
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1061
1238
|
/** Ids a classic-script body registers through the module loader's public entry point. */
|
|
1062
1239
|
function registeredIds(body) {
|
|
1063
1240
|
const ids = new Set()
|
|
@@ -1074,7 +1251,7 @@ function requiredIds(body) {
|
|
|
1074
1251
|
|
|
1075
1252
|
/**
|
|
1076
1253
|
* Compile one body the way the document compiles it, and never run it: running a plugin body here
|
|
1077
|
-
* would execute its top level inside the
|
|
1254
|
+
* would execute its top level inside the app that owns every running turn.
|
|
1078
1255
|
* @returns the parser's complaint, or null when the body compiles as a classic script.
|
|
1079
1256
|
*/
|
|
1080
1257
|
function parseAsScript(body, name) {
|
|
@@ -1161,14 +1338,14 @@ export async function verifyStartupRows(graph, fetchBody, rows) {
|
|
|
1161
1338
|
}
|
|
1162
1339
|
if (absent.size > 0) {
|
|
1163
1340
|
return {
|
|
1164
|
-
status: 'blocked',
|
|
1165
|
-
reason: 'dependencies',
|
|
1341
|
+
status: 'blocked', // ui-copy-ignore
|
|
1342
|
+
reason: 'dependencies', // ui-copy-ignore
|
|
1166
1343
|
detail: `not registered by the startup list: ${[...absent].slice(0, 4).join(', ')}`,
|
|
1167
1344
|
}
|
|
1168
1345
|
}
|
|
1169
1346
|
return {
|
|
1170
|
-
status: 'ok',
|
|
1171
|
-
reason: 'ok',
|
|
1347
|
+
status: 'ok', // ui-copy-ignore
|
|
1348
|
+
reason: 'ok', // ui-copy-ignore
|
|
1172
1349
|
revision: typeof graph?.rev === 'string' ? graph.rev : null,
|
|
1173
1350
|
scripts: bodies.map((body) => ({ url: body.url, digest: body.digest, ids: body.ids })),
|
|
1174
1351
|
}
|
|
@@ -1225,59 +1402,81 @@ function serveButton(ctx, controller, config) {
|
|
|
1225
1402
|
[
|
|
1226
1403
|
`${BASE_PATH}/graph`,
|
|
1227
1404
|
(_req, res) => {
|
|
1228
|
-
|
|
1229
|
-
|
|
1405
|
+
// The page reads this before a rebuild; an answer that throws would leave the press with
|
|
1406
|
+
// no verdict at all, so the whole body is bounded and answers either way.
|
|
1407
|
+
try {
|
|
1408
|
+
ensureBundleShim(ctx)
|
|
1409
|
+
sendJson(res, 200, { graph: liveBootGraph(ctx) })
|
|
1410
|
+
} catch (error) {
|
|
1411
|
+
sendJson(res, 500, { status: 'error', detail: String(error?.message ?? error) })
|
|
1412
|
+
}
|
|
1230
1413
|
},
|
|
1231
1414
|
],
|
|
1232
1415
|
[
|
|
1233
1416
|
`${BASE_PATH}/verify`,
|
|
1234
1417
|
async (req, res) => {
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
const header = req?.headers?.[BUTTON_HEADER]
|
|
1238
|
-
if (typeof header !== 'string' || header === '') return sendJson(res, 403, { status: 'forbidden' })
|
|
1239
|
-
const clientModules = ctx.get('clientModules')
|
|
1240
|
-
if (typeof clientModules?.fetchBundle !== 'function' || typeof clientModules?.graph !== 'function') {
|
|
1241
|
-
return sendJson(res, 200, { status: 'blocked', reason: 'list', detail: 'the plugin table is not running' })
|
|
1242
|
-
}
|
|
1243
|
-
const body = await readJsonBody(req)
|
|
1244
|
-
let verdict = null
|
|
1418
|
+
// Nothing a request does may reject: an unhandled rejection ends the whole app, and with
|
|
1419
|
+
// it every turn running in it.
|
|
1245
1420
|
try {
|
|
1246
|
-
|
|
1421
|
+
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
1422
|
+
if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
|
|
1423
|
+
if (buttonAllowed(req) === false) return sendJson(res, 403, { status: 'forbidden' })
|
|
1424
|
+
const clientModules = ctx.get('clientModules')
|
|
1425
|
+
if (typeof clientModules?.fetchBundle !== 'function' || typeof clientModules?.graph !== 'function') {
|
|
1426
|
+
return sendJson(res, 200, { status: 'blocked', reason: 'list', detail: 'the plugin table is not running' })
|
|
1427
|
+
}
|
|
1428
|
+
const body = await readJsonBody(req)
|
|
1429
|
+
let verdict = null
|
|
1430
|
+
try {
|
|
1431
|
+
verdict = await verifyStartup(clientModules, Array.isArray(body?.rows) ? body.rows : [])
|
|
1432
|
+
} catch (error) {
|
|
1433
|
+
verdict = { status: 'blocked', reason: 'list', detail: String(error?.message ?? error) }
|
|
1434
|
+
}
|
|
1435
|
+
sendJson(res, 200, verdict)
|
|
1247
1436
|
} catch (error) {
|
|
1248
|
-
|
|
1437
|
+
log(`cannot answer ${BASE_PATH}/verify: ${error?.message ?? error}`)
|
|
1438
|
+
sendJson(res, 500, { status: 'error' })
|
|
1249
1439
|
}
|
|
1250
|
-
sendJson(res, 200, verdict)
|
|
1251
1440
|
},
|
|
1252
1441
|
],
|
|
1253
1442
|
[
|
|
1254
1443
|
`${BASE_PATH}/report`,
|
|
1255
1444
|
(req, res) => {
|
|
1256
|
-
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
1257
|
-
if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
|
|
1258
|
-
let query = ''
|
|
1259
1445
|
try {
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1446
|
+
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
1447
|
+
if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
|
|
1448
|
+
let query = ''
|
|
1449
|
+
try {
|
|
1450
|
+
query = new URL(String(req?.url ?? ''), 'http://x').searchParams.toString()
|
|
1451
|
+
} catch {}
|
|
1452
|
+
const params = new URLSearchParams(query)
|
|
1453
|
+
const event = params.get('event') ?? ''
|
|
1454
|
+
// A query comes from outside: one shaped gate keeps a forged line out of the log.
|
|
1455
|
+
const gate = (params.get('gate') ?? '').replace(/[^a-z-]/gu, '').slice(0, 24)
|
|
1456
|
+
if (event === 'mounted') log('interface rebuilt; the page runs the plugins as served now')
|
|
1457
|
+
else if (event === 'rebuild') log('rebuild allowed: the page re-enters on the served modules')
|
|
1458
|
+
else if (event === 'held') log(`rebuild held back: ${gate === '' ? 'unknown' : gate}`)
|
|
1459
|
+
sendJson(res, 200, { status: 'done' })
|
|
1460
|
+
} catch (error) {
|
|
1461
|
+
log(`cannot record a press report: ${error?.message ?? error}`)
|
|
1462
|
+
sendJson(res, 500, { status: 'error' })
|
|
1463
|
+
}
|
|
1269
1464
|
},
|
|
1270
1465
|
],
|
|
1271
1466
|
[
|
|
1272
1467
|
`${BASE_PATH}/reload`,
|
|
1273
1468
|
async (req, res) => {
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1469
|
+
try {
|
|
1470
|
+
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
1471
|
+
if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
|
|
1472
|
+
if (buttonAllowed(req) === false) return sendJson(res, 403, { status: 'forbidden' })
|
|
1473
|
+
if (!settings.reloadPlugins) return sendJson(res, 200, { status: 'off' })
|
|
1474
|
+
const outcome = await controller.reloadNow()
|
|
1475
|
+
sendJson(res, 200, outcome)
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
log(`cannot apply a plugin reload: ${error?.message ?? error}`)
|
|
1478
|
+
sendJson(res, 500, { status: 'failed', detail: String(error?.message ?? error) })
|
|
1479
|
+
}
|
|
1281
1480
|
},
|
|
1282
1481
|
],
|
|
1283
1482
|
]
|
|
@@ -1297,6 +1496,7 @@ function serveButton(ctx, controller, config) {
|
|
|
1297
1496
|
|
|
1298
1497
|
export function apply(ctx, config) {
|
|
1299
1498
|
try {
|
|
1499
|
+
const instance = {}
|
|
1300
1500
|
closeAll(globalThis[WATCHER_KEY])
|
|
1301
1501
|
closeAll(globalThis[ROUTES_KEY])
|
|
1302
1502
|
releaseBundleShim(globalThis[SHIM_KEY])
|
|
@@ -1345,8 +1545,15 @@ export function apply(ctx, config) {
|
|
|
1345
1545
|
}
|
|
1346
1546
|
globalThis[ROUTES_KEY] = serveButton(ctx, controller, config ?? {})
|
|
1347
1547
|
syncBundles()
|
|
1548
|
+
globalThis[INSTANCE_KEY] = instance
|
|
1348
1549
|
|
|
1349
1550
|
ctx.on('dispose', () => {
|
|
1551
|
+
/**
|
|
1552
|
+
* A dispose that lands after the next apply belongs to an instance that no longer owns
|
|
1553
|
+
* anything; tearing the routes or the shim down here would break the newer one, and a page
|
|
1554
|
+
* that then cannot read a startup script ends the app.
|
|
1555
|
+
*/
|
|
1556
|
+
if (globalThis[INSTANCE_KEY] !== instance) return
|
|
1350
1557
|
controller.stop()
|
|
1351
1558
|
closeAll(globalThis[WATCHER_KEY])
|
|
1352
1559
|
closeAll(globalThis[ROUTES_KEY])
|
|
@@ -1354,6 +1561,7 @@ export function apply(ctx, config) {
|
|
|
1354
1561
|
globalThis[WATCHER_KEY] = null
|
|
1355
1562
|
globalThis[ROUTES_KEY] = null
|
|
1356
1563
|
globalThis[SHIM_KEY] = null
|
|
1564
|
+
globalThis[INSTANCE_KEY] = null
|
|
1357
1565
|
})
|
|
1358
1566
|
} catch (error) {
|
|
1359
1567
|
log(`plugin reload did not start: ${error?.message ?? error}`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-unlag",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A press applies plugin edits right away and brings a laggy interface back; automatic reloading stays off unless you turn it on, so the press is the only trigger, and nothing restarts.",
|
|
6
6
|
"license": "MIT",
|