dsh-unlag 1.9.0 → 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.
Files changed (4) hide show
  1. package/README.md +4 -0
  2. package/client.js +174 -78
  3. package/index.js +305 -80
  4. 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
@@ -27,11 +27,20 @@ window.__ModuleLoader__.load({
27
27
  return false;
28
28
  }
29
29
 
30
- /** The notice for one copy key, naming the reason when a readiness check stopped the press. */
31
- function noticeFor(key) {
30
+ /**
31
+ * The notice for one copy key, naming the reason when a readiness check stopped the press.
32
+ * A press that already replaced the plugins did its work even when the page was left alone, and
33
+ * is said so: the update is live either way, and only the new document was skipped.
34
+ */
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
+ }
32
40
  if (key !== 'blocked' || holdReason === null) return COPY[key];
33
41
  const why = REASONS[holdReason] ?? REASONS.list;
34
- return zh ? `暂时无法重建:${why}。稍后再点一次。` : `Cannot rebuild yet: ${why}. Press again shortly.`;
42
+ if (applied) return zh ? `插件已更新,页面未重开:${why}。` : `Plugins updated; the page stayed: ${why}.`;
43
+ return zh ? `暂时无法重建:${why}。` : `Cannot rebuild yet: ${why}.`;
35
44
  }
36
45
 
37
46
  // Desktop shell only: the macOS and Windows desktop build owns the page and the running
@@ -46,8 +55,43 @@ window.__ModuleLoader__.load({
46
55
  */
47
56
  const PREPARE_EVENT = 'dsh-unlag:before';
48
57
 
49
- /** Set while a rebuild is pending, so a repeated click cannot start a second one. */
58
+ /** What this document remembers about its last press, so two presses cannot overlap. */
50
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
+ }
51
95
 
52
96
  /**
53
97
  * What a press does is decided by the plugin's settings row, read from the running app, so
@@ -58,9 +102,9 @@ window.__ModuleLoader__.load({
58
102
 
59
103
  async function readSettings() {
60
104
  try {
61
- 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' }));
62
106
  if (!response.ok) return null;
63
- const data = await response.json();
107
+ const data = await withTimeout(response.json());
64
108
  if (data === null || typeof data !== 'object') return null;
65
109
  return { plugins: data.plugins !== false, page: data.page !== false };
66
110
  } catch {
@@ -88,14 +132,14 @@ window.__ModuleLoader__.load({
88
132
  /** Ask the running app to replace changed plugins now; a running turn is reported, never cut. */
89
133
  async function replacePlugins() {
90
134
  try {
91
- const response = await fetch(`${HOST_BASE}/reload`, {
135
+ const response = await withTimeout(fetch(`${HOST_BASE}/reload`, {
92
136
  method: 'POST', // ui-copy-ignore
93
137
  credentials: 'same-origin',
94
138
  headers: { 'content-type': 'application/json', 'x-dsh-unlag': '1' }, // ui-copy-ignore
95
139
  body: '{}', // ui-copy-ignore
96
- });
140
+ }));
97
141
  if (!response.ok) return 'failed';
98
- const data = await response.json();
142
+ const data = await withTimeout(response.json());
99
143
  return reloadVerdict(data);
100
144
  } catch {
101
145
  return 'failed';
@@ -124,25 +168,36 @@ window.__ModuleLoader__.load({
124
168
  function bundledIds(url) {
125
169
  const match = /\?\?([^&]+)/.exec(url);
126
170
  if (match === null) return [];
127
- return match[1].split(',').map((part) => decodeURIComponent(part).replace(/\/client\.js.*$/u, '')).filter((id) => id !== '');
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;
128
183
  }
129
184
 
130
185
  /**
131
- * Ask the host half whether the scripts the next document would run can start it. The page has no
186
+ * Ask the running app whether the scripts the next document would run can start it. The page has no
132
187
  * parser that agrees with a script tag, and compiling a candidate body here would execute the
133
- * plugin inside the page that carries the turn, so the verdict is the host's real parse bound to
188
+ * plugin inside the page that carries the turn, so the verdict is the app's real parse bound to
134
189
  * the bytes it read.
135
190
  * @returns 'ok' | 'blocked'
136
191
  */
137
192
  async function hostVerifyStartup(scripts) {
138
193
  let response;
139
194
  try {
140
- response = await fetch(`${HOST_BASE}/verify`, {
195
+ response = await withTimeout(fetch(`${HOST_BASE}/verify`, {
141
196
  method: 'POST', // ui-copy-ignore
142
197
  credentials: 'same-origin',
143
198
  headers: { 'content-type': 'application/json', 'x-dsh-unlag': '1' }, // ui-copy-ignore
144
199
  body: JSON.stringify({ rows: scripts }), // ui-copy-ignore
145
- });
200
+ }));
146
201
  } catch {
147
202
  held('verify');
148
203
  return 'blocked';
@@ -153,7 +208,7 @@ window.__ModuleLoader__.load({
153
208
  }
154
209
  let data = null;
155
210
  try {
156
- data = await response.json();
211
+ data = await withTimeout(response.json());
157
212
  } catch {
158
213
  data = null;
159
214
  }
@@ -166,15 +221,15 @@ window.__ModuleLoader__.load({
166
221
  return 'blocked';
167
222
  }
168
223
 
169
- // What the host's refusal means to the person pressing: the same gates the checks above use.
224
+ // What the app's refusal means to the person pressing: the same gates the checks above use.
170
225
  const VERIFY_GATES = {
171
- empty: 'empty',
172
- missing: 'script',
173
- network: 'script',
174
- parse: 'parse',
175
- 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
176
231
  dependencies: 'covers',
177
- list: 'list',
232
+ list: 'list', // ui-copy-ignore
178
233
  };
179
234
 
180
235
  /** Read every bundle the next boot would load and prove each listed plugin is inside. */
@@ -191,8 +246,14 @@ window.__ModuleLoader__.load({
191
246
  const listed = Array.isArray(batch.entries) && batch.entries.every((id) => typeof id === 'string') ? batch.entries : [];
192
247
  const ids = listed.length > 0 ? listed : bundledIds(url);
193
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
+ }
194
255
  for (const id of ids) covered.add(id);
195
- sources.push({ url: new URL(url, location.href), ids });
256
+ sources.push({ url: resolved, ids });
196
257
  }
197
258
  if (sources.length === 0) return held('graph');
198
259
  for (const entry of entries) {
@@ -200,7 +261,7 @@ window.__ModuleLoader__.load({
200
261
  if (typeof id !== 'string' || !covered.has(id)) return held('covers');
201
262
  }
202
263
  // Reading these bodies here would need a parser the page does not have and would not prove
203
- // what the new document registers; the host half compiles the same bytes with the real parser.
264
+ // what the new document registers; the app compiles the same bytes with the real parser.
204
265
  const verdict = await hostVerifyStartup(sources.map((source) => source.url.toString()));
205
266
  if (verdict !== 'ok') return false;
206
267
  holdReason = null;
@@ -226,7 +287,7 @@ window.__ModuleLoader__.load({
226
287
  const bridge = window.dshDesktopBoot;
227
288
  if (bridge === undefined || bridge === null || typeof bridge.ready !== 'function') return null;
228
289
  try {
229
- const payload = await bridge.ready();
290
+ const payload = await withTimeout(bridge.ready(), 8000);
230
291
  const rows = Array.isArray(payload?.injections) ? payload.injections : [];
231
292
  return rows.length === 0 ? null : rows;
232
293
  } catch {
@@ -337,9 +398,9 @@ window.__ModuleLoader__.load({
337
398
  */
338
399
  async function hostBootGraph() {
339
400
  try {
340
- 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' }));
341
402
  if (!response.ok) return null;
342
- const data = await response.json();
403
+ const data = await withTimeout(response.json());
343
404
  const graph = data?.graph;
344
405
  return graph !== null && typeof graph === 'object' ? graph : null;
345
406
  } catch {
@@ -351,9 +412,9 @@ window.__ModuleLoader__.load({
351
412
  const fromHost = await hostBootGraph();
352
413
  if (fromHost !== null) return fromHost;
353
414
  try {
354
- 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' }));
355
416
  if (!response.ok) return null;
356
- return graphFromHtml(await response.text());
417
+ return graphFromHtml(await withTimeout(response.text()));
357
418
  } catch {
358
419
  return null;
359
420
  }
@@ -421,7 +482,22 @@ window.__ModuleLoader__.load({
421
482
  async function commitFocusedField() {
422
483
  const active = document.activeElement;
423
484
  if (active !== null && typeof active.blur === 'function') active.blur();
424
- await new Promise((resolve) => requestAnimationFrame(() => resolve()));
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
+ });
425
501
  }
426
502
 
427
503
  /** Re-enter the interface on a fresh document, keeping the current view in the address. */
@@ -447,7 +523,7 @@ window.__ModuleLoader__.load({
447
523
  function stillHeldBack() {
448
524
  const probe = new Event('beforeunload', { cancelable: true }); // ui-copy-ignore
449
525
  window.dispatchEvent(probe);
450
- return probe.defaultPrevented === true;
526
+ return probe.defaultPrevented === true || probe.returnValue === false;
451
527
  }
452
528
 
453
529
  /**
@@ -512,57 +588,77 @@ window.__ModuleLoader__.load({
512
588
  : BUTTON_STYLE;
513
589
 
514
590
  const onClick = async () => {
515
- if (pending) return;
516
- const previous = Number(window.sessionStorage.getItem(STAMP_KEY) ?? 0);
517
- if (Number.isFinite(previous) && Date.now() - previous < 3000) return; // ui-copy-ignore
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;
518
598
  setPending(true);
519
599
  setNotice(null);
520
- const stop = (key) => {
600
+ let navigated = false;
601
+ const stop = (key, applied = false) => {
521
602
  setPending(false);
522
- setNotice(noticeFor(key));
603
+ setNotice(noticeFor(key, applied));
523
604
  };
524
- const settings = await readSettings();
525
- const replace = settings === null ? false : settings.plugins;
526
- const rebuild = settings === null ? true : settings.page;
527
- // Both switches off: a press does nothing at all.
528
- if (!replace && !rebuild) {
529
- setPending(false);
530
- return;
531
- }
532
- // Replace first: a plugin that only changed the interface is then covered by the rebuild.
533
- // A replacement that did not finish stops the press here: rebuilding the page anyway would
534
- // start a document against a plugin set the app has not finished applying.
535
- if (replace) {
536
- const status = await replacePlugins();
537
- if (status !== 'done' && status !== 'off') {
538
- const key =
539
- status === 'queued'
540
- ? 'queued'
541
- : status === 'applying'
542
- ? 'applying'
543
- : status === 'restart'
544
- ? 'restart'
545
- : status === 'unavailable'
546
- ? 'blocked'
547
- : 'failed';
548
- report('plugin-held', key);
549
- 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');
550
614
  }
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);
551
661
  }
552
- if (!rebuild) {
553
- setPending(false);
554
- return;
555
- }
556
- window.sessionStorage.setItem(STAMP_KEY, String(Date.now()));
557
- const reason = await prepareInterface();
558
- if (reason !== null) {
559
- // The log keeps the exact check that refused, so a held press can be explained later.
560
- report('held', reason === 'blocked' && holdReason !== null ? `blocked:${holdReason}` : reason);
561
- return stop(reason);
562
- }
563
- report('rebuild');
564
- reloadFreshPage();
565
- setTimeout(() => setPending(false), 4000); // ui-copy-ignore
566
662
  };
567
663
 
568
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
- appendFileSync(logTarget(), `${new Date().toISOString()} ${line}\n`)
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 on the host instead of in this closure: replacing this plugin builds a new closure, and
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 reloadNow = async (force = false) => {
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
- const body = JSON.stringify(payload)
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 <= limit) parts.push(chunk)
727
+ if (size > limit) {
728
+ finish(null)
729
+ return
730
+ }
731
+ parts.push(chunk)
600
732
  })
601
733
  req.on('end', () => {
602
- if (size > limit) return resolve(null)
734
+ if (done === true) return
603
735
  try {
604
- resolve(JSON.parse(Buffer.concat(parts).toString('utf8')))
736
+ finish(JSON.parse(Buffer.concat(parts).toString('utf8')))
605
737
  } catch {
606
- resolve(null)
738
+ finish(null)
607
739
  }
608
740
  })
609
- req.on('error', () => resolve(null))
741
+ req.on('error', () => finish(null))
742
+ req.on('aborted', () => finish(null))
610
743
  } catch {
611
- resolve(null)
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 would run neither. A line holding just `;` between them keeps every body its own
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
- if (!request.ids.every((id) => body.includes(id))) return null
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 && !request.ids.every((id) => body.includes(id))) continue
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 || route[SHIM_MARK] === true) return null
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
- /* the response is gone; nothing left to answer */
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
- if (shim.slot === 'resource') {
1023
- if (shim.route.resource === shim.wrapped) shim.route.resource = shim.original
1024
- } else if (shim.route.handler === shim.wrapped) shim.route.handler = shim.original
1025
- if (shim.route[SHIM_MARK] === true) delete shim.route[SHIM_MARK]
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] === true) return shim
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] === true && typeof route?.[shape === 'none' ? 'handler' : shape] === 'function',
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 host that owns every running turn.
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,28 +1338,45 @@ 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
  }
1175
1352
  }
1176
1353
 
1354
+ /**
1355
+ * Read one startup script the way the document itself would get it.
1356
+ *
1357
+ * The route answers a script from an older generation at the revision that still holds it, so a
1358
+ * document booting from the shell's frozen list comes up instead of 404. Reading the address
1359
+ * directly would see that same 404 and refuse a rebuild the button can actually carry out, so the
1360
+ * answer is taken through the same mapping and only falls back to the raw address when the mapping
1361
+ * has nothing to say.
1362
+ */
1363
+ export async function readStartupScript(clientModules, url) {
1364
+ try {
1365
+ const answer = await currentBundleAnswer(clientModules, 'GET', url)
1366
+ if (answer !== null) return { status: 200, arrayBuffer: async () => answer.body }
1367
+ } catch {}
1368
+ return fetchBundleAt(clientModules, url, 'GET')
1369
+ }
1370
+
1177
1371
  /** Ask the same question of the plugin table the plugin itself talks to. */
1178
- async function verifyStartup(clientModules, rows) {
1372
+ export async function verifyStartup(clientModules, rows) {
1179
1373
  let graph = null
1180
1374
  try {
1181
1375
  graph = clientModules.graph()
1182
1376
  } catch (error) {
1183
1377
  return { status: 'blocked', reason: 'list', detail: String(error?.message ?? error) }
1184
1378
  }
1185
- return verifyStartupRows(graph, (url) => fetchBundleAt(clientModules, url, 'GET'), rows)
1379
+ return verifyStartupRows(graph, (url) => readStartupScript(clientModules, url), rows)
1186
1380
  }
1187
1381
 
1188
1382
  /**
@@ -1208,59 +1402,81 @@ function serveButton(ctx, controller, config) {
1208
1402
  [
1209
1403
  `${BASE_PATH}/graph`,
1210
1404
  (_req, res) => {
1211
- ensureBundleShim(ctx)
1212
- sendJson(res, 200, { graph: liveBootGraph(ctx) })
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
+ }
1213
1413
  },
1214
1414
  ],
1215
1415
  [
1216
1416
  `${BASE_PATH}/verify`,
1217
1417
  async (req, res) => {
1218
- const method = String(req?.method ?? 'GET').toUpperCase()
1219
- if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
1220
- const header = req?.headers?.[BUTTON_HEADER]
1221
- if (typeof header !== 'string' || header === '') return sendJson(res, 403, { status: 'forbidden' })
1222
- const clientModules = ctx.get('clientModules')
1223
- if (typeof clientModules?.fetchBundle !== 'function' || typeof clientModules?.graph !== 'function') {
1224
- return sendJson(res, 200, { status: 'blocked', reason: 'list', detail: 'the plugin table is not running' })
1225
- }
1226
- const body = await readJsonBody(req)
1227
- 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.
1228
1420
  try {
1229
- verdict = await verifyStartup(clientModules, Array.isArray(body?.rows) ? body.rows : [])
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)
1230
1436
  } catch (error) {
1231
- verdict = { status: 'blocked', reason: 'list', detail: String(error?.message ?? error) }
1437
+ log(`cannot answer ${BASE_PATH}/verify: ${error?.message ?? error}`)
1438
+ sendJson(res, 500, { status: 'error' })
1232
1439
  }
1233
- sendJson(res, 200, verdict)
1234
1440
  },
1235
1441
  ],
1236
1442
  [
1237
1443
  `${BASE_PATH}/report`,
1238
1444
  (req, res) => {
1239
- const method = String(req?.method ?? 'GET').toUpperCase()
1240
- if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
1241
- let query = ''
1242
1445
  try {
1243
- query = new URL(String(req?.url ?? ''), 'http://x').searchParams.toString()
1244
- } catch {}
1245
- const params = new URLSearchParams(query)
1246
- const event = params.get('event') ?? ''
1247
- const gate = params.get('gate') ?? ''
1248
- if (event === 'mounted') log('interface rebuilt; the page runs the plugins as served now')
1249
- else if (event === 'rebuild') log('rebuild allowed: the page re-enters on the served modules')
1250
- else if (event === 'held') log(`rebuild held back: ${gate === '' ? 'unknown' : gate}`)
1251
- sendJson(res, 200, { status: 'done' })
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
+ }
1252
1464
  },
1253
1465
  ],
1254
1466
  [
1255
1467
  `${BASE_PATH}/reload`,
1256
1468
  async (req, res) => {
1257
- const method = String(req?.method ?? 'GET').toUpperCase()
1258
- if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
1259
- const header = req?.headers?.[BUTTON_HEADER]
1260
- if (typeof header !== 'string' || header === '') return sendJson(res, 403, { status: 'forbidden' })
1261
- if (!settings.reloadPlugins) return sendJson(res, 200, { status: 'off' })
1262
- const outcome = await controller.reloadNow()
1263
- sendJson(res, 200, outcome)
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
+ }
1264
1480
  },
1265
1481
  ],
1266
1482
  ]
@@ -1280,6 +1496,7 @@ function serveButton(ctx, controller, config) {
1280
1496
 
1281
1497
  export function apply(ctx, config) {
1282
1498
  try {
1499
+ const instance = {}
1283
1500
  closeAll(globalThis[WATCHER_KEY])
1284
1501
  closeAll(globalThis[ROUTES_KEY])
1285
1502
  releaseBundleShim(globalThis[SHIM_KEY])
@@ -1328,8 +1545,15 @@ export function apply(ctx, config) {
1328
1545
  }
1329
1546
  globalThis[ROUTES_KEY] = serveButton(ctx, controller, config ?? {})
1330
1547
  syncBundles()
1548
+ globalThis[INSTANCE_KEY] = instance
1331
1549
 
1332
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
1333
1557
  controller.stop()
1334
1558
  closeAll(globalThis[WATCHER_KEY])
1335
1559
  closeAll(globalThis[ROUTES_KEY])
@@ -1337,6 +1561,7 @@ export function apply(ctx, config) {
1337
1561
  globalThis[WATCHER_KEY] = null
1338
1562
  globalThis[ROUTES_KEY] = null
1339
1563
  globalThis[SHIM_KEY] = null
1564
+ globalThis[INSTANCE_KEY] = null
1340
1565
  })
1341
1566
  } catch (error) {
1342
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.0",
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",