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.
Files changed (4) hide show
  1. package/README.md +4 -0
  2. package/client.js +164 -76
  3. package/index.js +286 -78
  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
@@ -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
- /** 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. */
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
- 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;
133
183
  }
134
184
 
135
185
  /**
136
- * 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
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 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
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 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.
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: new URL(url, location.href), ids });
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 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.
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
- 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
+ });
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
- const previous = Number(window.sessionStorage.getItem(STAMP_KEY) ?? 0);
522
- 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;
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
- const settings = await readSettings();
530
- const replace = settings === null ? false : settings.plugins;
531
- const rebuild = settings === null ? true : settings.page;
532
- let replaced = false;
533
- // Both switches off: a press does nothing at all.
534
- if (!replace && !rebuild) {
535
- setPending(false);
536
- return;
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
- replaced = status === 'done';
558
- }
559
- if (!rebuild) {
560
- setPending(false);
561
- return;
562
- }
563
- window.sessionStorage.setItem(STAMP_KEY, String(Date.now()));
564
- const reason = await prepareInterface();
565
- if (reason !== null) {
566
- // The log keeps the exact check that refused, so a held press can be explained later.
567
- const gate = reason === 'blocked' && holdReason !== null ? `blocked:${holdReason}` : reason;
568
- report('held', replaced ? `applied:${gate}` : gate);
569
- return stop(reason, replaced);
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
- 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,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
- ensureBundleShim(ctx)
1229
- 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
+ }
1230
1413
  },
1231
1414
  ],
1232
1415
  [
1233
1416
  `${BASE_PATH}/verify`,
1234
1417
  async (req, res) => {
1235
- const method = String(req?.method ?? 'GET').toUpperCase()
1236
- if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
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
- 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)
1247
1436
  } catch (error) {
1248
- 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' })
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
- query = new URL(String(req?.url ?? ''), 'http://x').searchParams.toString()
1261
- } catch {}
1262
- const params = new URLSearchParams(query)
1263
- const event = params.get('event') ?? ''
1264
- const gate = params.get('gate') ?? ''
1265
- if (event === 'mounted') log('interface rebuilt; the page runs the plugins as served now')
1266
- else if (event === 'rebuild') log('rebuild allowed: the page re-enters on the served modules')
1267
- else if (event === 'held') log(`rebuild held back: ${gate === '' ? 'unknown' : gate}`)
1268
- 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
+ }
1269
1464
  },
1270
1465
  ],
1271
1466
  [
1272
1467
  `${BASE_PATH}/reload`,
1273
1468
  async (req, res) => {
1274
- const method = String(req?.method ?? 'GET').toUpperCase()
1275
- if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
1276
- const header = req?.headers?.[BUTTON_HEADER]
1277
- if (typeof header !== 'string' || header === '') return sendJson(res, 403, { status: 'forbidden' })
1278
- if (!settings.reloadPlugins) return sendJson(res, 200, { status: 'off' })
1279
- const outcome = await controller.reloadNow()
1280
- 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
+ }
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.1",
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",