dsh-unlag 1.8.3 → 1.8.5

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 +3 -1
  2. package/client.js +62 -164
  3. package/index.js +241 -34
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -32,7 +32,8 @@ dsh plugin install dsh-unlag
32
32
 
33
33
  ## 不会丢什么
34
34
 
35
- - 轮次、子代理、终端进程:照常继续。
35
+ - 正在跑的轮次、子代理、终端进程:都跑在宿主进程里,换代码不重启进程,所以不被打断,照常跑完。
36
+ - 界面重建会重新进入同一个地址:屏幕上正在显示的内容重新载入一次,会话记录还在。
36
37
  - 排队消息、输入草稿、附件:重建前先提交焦点里的内容;有人没存完就拦下,不硬刷。
37
38
  - 有未保存改动、开着弹窗、或下个页面要用的模块读不全:拦下并提示,提示里写明卡在哪一项,宁可不刷。
38
39
  - 旧页面记着的启动清单分组变了(装过/卸过插件):按插件逐个补齐再交给新页面,不会因为清单 404 把 App 带走。
@@ -42,6 +43,7 @@ dsh plugin install dsh-unlag
42
43
 
43
44
  - 改动替插件加原生能力、改服务声明或权限:这类改动不在一次点击的范围里。
44
45
  - 独立进程和进程级的东西:宠物窗口自己的配置、DSH 自身升级、原生依赖变化。
46
+ - 万一新页面还是起不来:App 会进入错误恢复(退出,或请你停用第三方插件)。本插件不替你选,也不会自己重启或退出 App。
45
47
 
46
48
  ## 排错
47
49
 
package/client.js CHANGED
@@ -15,8 +15,8 @@ window.__ModuleLoader__.load({
15
15
  * repeating one generic sentence.
16
16
  */
17
17
  const REASONS = zh
18
- ? { list: '读不到启动信息', row: '启动信息不完整', src: '启动脚本没有地址', graph: '已装插件清单读不到', plugin: '有插件已不在运行', script: '启动脚本读不到', empty: '启动脚本是空的', covers: '启动脚本缺插件', parse: '启动脚本内容不完整' }
19
- : { list: 'startup info is unavailable', row: 'startup info is incomplete', src: 'a startup script has no address', graph: 'the plugin list cannot be read', plugin: 'a plugin is no longer running', script: 'a startup script cannot be read', empty: 'a startup script is empty', covers: 'a startup script misses a plugin', parse: 'a startup script is incomplete' };
18
+ ? { list: '读不到启动信息', row: '启动信息不完整', src: '启动脚本没有地址', graph: '已装插件清单读不到', plugin: '有插件已不在运行', script: '启动脚本读不到', empty: '启动脚本是空的', covers: '启动脚本缺插件', parse: '启动脚本内容不完整', verify: '插件后端没有应答' }
19
+ : { list: 'startup info is unavailable', row: 'startup info is incomplete', src: 'a startup script has no address', graph: 'the plugin list cannot be read', plugin: 'a plugin is no longer running', script: 'a startup script cannot be read', empty: 'a startup script is empty', covers: 'a startup script misses a plugin', parse: 'a startup script is incomplete', verify: 'the plugin is not answering' };
20
20
 
21
21
  /** The last reason a readiness check refused for, or null after a check that passed. */
22
22
  let holdReason = null;
@@ -127,6 +127,56 @@ window.__ModuleLoader__.load({
127
127
  return match[1].split(',').map((part) => decodeURIComponent(part).replace(/\/client\.js.*$/u, '')).filter((id) => id !== '');
128
128
  }
129
129
 
130
+ /**
131
+ * Ask the host half whether the scripts the next document would run can start it. The page has no
132
+ * 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
134
+ * the bytes it read.
135
+ * @returns 'ok' | 'blocked'
136
+ */
137
+ async function hostVerifyStartup(scripts) {
138
+ let response;
139
+ try {
140
+ response = await fetch(`${HOST_BASE}/verify`, {
141
+ method: 'POST', // ui-copy-ignore
142
+ credentials: 'same-origin',
143
+ headers: { 'content-type': 'application/json', 'x-dsh-unlag': '1' }, // ui-copy-ignore
144
+ body: JSON.stringify({ rows: scripts }), // ui-copy-ignore
145
+ });
146
+ } catch {
147
+ held('verify');
148
+ return 'blocked';
149
+ }
150
+ if (!response.ok) {
151
+ held('verify');
152
+ return 'blocked';
153
+ }
154
+ let data = null;
155
+ try {
156
+ data = await response.json();
157
+ } catch {
158
+ data = null;
159
+ }
160
+ if (data !== null && typeof data === 'object' && data.status === 'ok') {
161
+ holdReason = null;
162
+ return 'ok';
163
+ }
164
+ const reason = data !== null && typeof data === 'object' && typeof data.reason === 'string' ? data.reason : 'list';
165
+ held(VERIFY_GATES[reason] ?? 'list');
166
+ return 'blocked';
167
+ }
168
+
169
+ // What the host's refusal means to the person pressing: the same gates the checks above use.
170
+ const VERIFY_GATES = {
171
+ empty: 'empty',
172
+ missing: 'script',
173
+ network: 'script',
174
+ parse: 'parse',
175
+ register: 'plugin',
176
+ dependencies: 'covers',
177
+ list: 'list',
178
+ };
179
+
130
180
  /** Read every bundle the next boot would load and prove each listed plugin is inside. */
131
181
  async function bundlesCoverStartup(graph) {
132
182
  if (graph === null || typeof graph !== 'object') return held('graph');
@@ -149,20 +199,10 @@ window.__ModuleLoader__.load({
149
199
  const id = typeof entry === 'string' ? entry : entry?.id;
150
200
  if (typeof id !== 'string' || !covered.has(id)) return held('covers');
151
201
  }
152
- for (const source of sources) {
153
- try {
154
- const response = await fetch(source.url.toString(), { cache: 'no-store', credentials: 'same-origin' });
155
- if (!response.ok) return held('script');
156
- const body = await response.text();
157
- if (body.length === 0) return held('empty');
158
- for (const id of source.ids) {
159
- if (!body.includes(id)) return held(source.ids.length === 1 ? 'plugin' : 'covers');
160
- }
161
- if (!parsesAsClassicScript(body)) return held('parse');
162
- } catch {
163
- return held('script');
164
- }
165
- }
202
+ // 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.
204
+ const verdict = await hostVerifyStartup(sources.map((source) => source.url.toString()));
205
+ if (verdict !== 'ok') return false;
166
206
  holdReason = null;
167
207
  return true;
168
208
  }
@@ -170,127 +210,10 @@ window.__ModuleLoader__.load({
170
210
  // Every row kind a document boot understands; an unknown one ends the document.
171
211
  const STARTUP_ROW_KINDS = new Set(['global', 'script', 'script-src', 'script-preload', 'style', 'html']);
172
212
 
173
- /**
174
- * Whether a script body parses the way the document will parse it: as a classic script. A body
175
- * that needs module syntax never runs, so the document that loads it fails to boot and the
176
- * desktop shell ends. Compiling runs nothing, so nothing here has a side effect; a page that
177
- * forbids compiling strings cannot answer, and then the checks already done stand.
178
- * @param body - the served script.
179
- * @returns true when the body parses, or when this page cannot compile one at all.
180
- */
181
- /** The source with comments and string contents blanked out, so a keyword scan sees code only. */
182
- function codeOnly(source) {
183
- let out = '';
184
- let index = 0;
185
- while (index < source.length) {
186
- const here = source[index];
187
- const next = source[index + 1];
188
- if (here === '/' && next === '*') {
189
- const end = source.indexOf('*/', index + 2);
190
- index = end < 0 ? source.length : end + 2;
191
- out += ' ';
192
- continue;
193
- }
194
- if (here === '/' && next === '/') {
195
- const end = source.indexOf('\n', index + 2);
196
- index = end < 0 ? source.length : end;
197
- continue;
198
- }
199
- if (here === '"' || here === "'" || here === '`') {
200
- index += 1;
201
- while (index < source.length && source[index] !== here) {
202
- if (source[index] === '\\') index += 1;
203
- index += 1;
204
- }
205
- index += 1;
206
- out += '""';
207
- continue;
208
- }
209
- out += here;
210
- index += 1;
211
- }
212
- return out;
213
- }
214
-
215
- /**
216
- * Whether a script has a `return` at the top level. A function body accepts one and a classic
217
- * script does not, so that difference decides whether the document runs the body at all. Only
218
- * depth zero counts: a return inside a callback, method or block is ordinary code, and reading
219
- * one as a refusal held every real bundle back.
220
- */
221
- function hasTopLevelReturn(source) {
222
- let depth = 0;
223
- let index = 0;
224
- let previous = '';
225
- while (index < source.length) {
226
- const here = source[index];
227
- const next = source[index + 1];
228
- if (here === '/' && next === '*') {
229
- const end = source.indexOf('*/', index + 2);
230
- index = end < 0 ? source.length : end + 2;
231
- continue;
232
- }
233
- if (here === '/' && next === '/') {
234
- const end = source.indexOf('\n', index + 2);
235
- index = end < 0 ? source.length : end;
236
- continue;
237
- }
238
- if (here === '"' || here === "'" || here === '`') {
239
- index += 1;
240
- while (index < source.length && source[index] !== here) {
241
- if (source[index] === '\\') index += 1;
242
- index += 1;
243
- }
244
- index += 1;
245
- previous = 'a';
246
- continue;
247
- }
248
- if (here === '/' && /(?:^|[(,=:[!&|?{};+\-*%<>~^])$/u.test(previous)) {
249
- // A regular expression literal; its body is pattern text, not code.
250
- index += 1;
251
- let inClass = false;
252
- while (index < source.length) {
253
- const part = source[index];
254
- if (part === '\\') index += 2;
255
- else if (part === '[') {
256
- inClass = true;
257
- index += 1;
258
- } else if (part === ']') {
259
- inClass = false;
260
- index += 1;
261
- } else if (part === '/' && !inClass) {
262
- index += 1;
263
- break;
264
- } else if (part === '\n') break;
265
- else index += 1;
266
- }
267
- previous = 'a';
268
- continue;
269
- }
270
- if (here === '(' || here === '[' || here === '{') depth += 1;
271
- else if (here === ')' || here === ']' || here === '}') depth = Math.max(0, depth - 1);
272
- else if (depth === 0 && here === 'r' && source.startsWith('return', index)) {
273
- const before = source[index - 1] ?? '';
274
- const after = source[index + 6] ?? '';
275
- if (!/[\w$]/u.test(before) && !/[\w$]/u.test(after)) return true;
276
- }
277
- if (!/\s/u.test(here)) previous = here;
278
- index += 1;
279
- }
280
- return false;
281
- }
282
-
283
- function parsesAsClassicScript(body) {
284
- try {
285
- // A top-level return parses in a function body but not as a classic script.
286
- if (hasTopLevelReturn(body)) return false;
287
- new Function(body); // eslint-disable-line no-new-func
288
- return true;
289
- } catch (error) {
290
- const text = String(error?.message ?? error);
291
- if (/unsafe-eval|Content Security Policy/iu.test(text)) return true;
292
- return false;
293
- }
213
+ /** Whether this document is owned by the desktop shell, whose saved list decides a boot. */
214
+ function desktopShellPresent() {
215
+ const bridge = window.dshDesktopBoot;
216
+ return bridge !== undefined && bridge !== null && typeof bridge.ready === 'function';
294
217
  }
295
218
 
296
219
  /**
@@ -299,12 +222,6 @@ window.__ModuleLoader__.load({
299
222
  * after that, so this is the only way to see what a rebuild would really boot from.
300
223
  * @returns the list rows, or null when this page is not running inside the desktop shell.
301
224
  */
302
- /** Whether this document is owned by the desktop shell, whose saved list decides a boot. */
303
- function desktopShellPresent() {
304
- const bridge = window.dshDesktopBoot;
305
- return bridge !== undefined && bridge !== null && typeof bridge.ready === 'function';
306
- }
307
-
308
225
  async function desktopStartupPayload() {
309
226
  const bridge = window.dshDesktopBoot;
310
227
  if (bridge === undefined || bridge === null || typeof bridge.ready !== 'function') return null;
@@ -369,27 +286,8 @@ window.__ModuleLoader__.load({
369
286
  if (typeof id === 'string' && id !== '' && !liveIds.has(id)) return held('plugin');
370
287
  }
371
288
  }
372
- for (const src of scripts) {
373
- let target;
374
- try {
375
- target = new URL(src, location.href).toString();
376
- } catch {
377
- return held('src');
378
- }
379
- try {
380
- const response = await fetch(target, { cache: 'no-store', credentials: 'same-origin' });
381
- if (!response.ok) return held('script');
382
- const body = await response.text();
383
- if (body.length === 0) return held('empty');
384
- const ids = bundledIds(target);
385
- for (const id of ids) {
386
- if (!body.includes(id)) return held(ids.length === 1 ? 'plugin' : 'covers');
387
- }
388
- if (!parsesAsClassicScript(body)) return held('parse');
389
- } catch {
390
- return held('script');
391
- }
392
- }
289
+ const verdict = await hostVerifyStartup(scripts);
290
+ if (verdict !== 'ok') return false;
393
291
  holdReason = null;
394
292
  return true;
395
293
  }
package/index.js CHANGED
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'
3
3
  import { appendFileSync, existsSync, readFileSync, readdirSync, statSync, watch } from 'node:fs'
4
4
  import { homedir } from 'node:os'
5
5
  import { join, resolve } from 'node:path'
6
+ import { Script } from 'node:vm'
6
7
 
7
8
  /**
8
9
  * One header button, three ways new code reaches the running app:
@@ -39,6 +40,8 @@ const logTarget = () => process.env.DSH_UNLAG_LOG ?? LOG_PATH
39
40
  const WATCHER_KEY = Symbol.for('dsh-unlag.watchers')
40
41
  /** The same, for the HTTP entry points the header button calls. */
41
42
  const ROUTES_KEY = Symbol.for('dsh-unlag.routes')
43
+ /** The queue request this plugin is waiting on, shared across a replacement of this plugin. */
44
+ const WORK_KEY = Symbol.for('dsh-unlag.work')
42
45
  const BASE_PATH = '/dsh-unlag'
43
46
  /** A same-origin page sets this itself; a cross-site form post cannot, and fails the preflight. */
44
47
  const BUTTON_HEADER = 'x-dsh-unlag'
@@ -227,8 +230,13 @@ function pluginReloader(ctx, config) {
227
230
 
228
231
  let timer = null
229
232
  let running = false
230
- /** The queue request this plugin is still waiting on, so a timeout is not reported as a cancel. */
231
- let work = null
233
+ /**
234
+ * The queue request this plugin is still waiting on, so a timeout is not reported as a cancel.
235
+ * It lives on the host instead of in this closure: replacing this plugin builds a new closure, and
236
+ * a press landing after that must still join the update the first press started rather than queue
237
+ * a second one beside it.
238
+ */
239
+ const workState = (globalThis[WORK_KEY] ??= { current: null })
232
240
  let again = false
233
241
  let deferrals = 0
234
242
  let lastRun = 0
@@ -284,9 +292,9 @@ function pluginReloader(ctx, config) {
284
292
  * so a timeout is reported as still applying, never as a failed update that later fires anyway.
285
293
  */
286
294
  const tracked = (request) => {
287
- work = request
295
+ workState.current = request
288
296
  const settle = () => {
289
- if (work === request) work = null
297
+ if (workState.current === request) workState.current = null
290
298
  }
291
299
  request.then(settle, settle)
292
300
  return request
@@ -305,7 +313,7 @@ function pluginReloader(ctx, config) {
305
313
  continue
306
314
  }
307
315
  if (message.includes('did not finish in time')) {
308
- error.stillRunning = work !== null
316
+ error.stillRunning = workState.current !== null
309
317
  throw error
310
318
  }
311
319
  throw error
@@ -315,13 +323,13 @@ function pluginReloader(ctx, config) {
315
323
  const pending = Array.isArray(result?.pending) ? result.pending : []
316
324
  const restartReason = typeof result?.restartReason === 'string' ? result.restartReason : null
317
325
  /**
318
- * A reconciliation that returns without throwing can still have left a plugin unloaded: the app
319
- * reports that as a warning. A set with an entry that did not start is not a set the page can be
320
- * rebuilt against, so the press is reported as failed and names what did not start.
326
+ * A reconciliation that returns without throwing can still have left part of the set unready, and
327
+ * the app names each such entry in `warnings`. An earlier test decided by English words inside
328
+ * that sentence, which missed "pending (waiting for service: addonService)" and invented failures
329
+ * out of harmless prose. The warning itself is the app saying an entry is not serving the page, so
330
+ * every warning counts and the sentence is passed on as it reads.
321
331
  */
322
- const failedEntries = warnings
323
- .map((warning) => (typeof warning === 'string' ? warning : String(warning?.message ?? '')))
324
- .filter((warning) => /fail|cannot|missing|invalid|error/iu.test(warning))
332
+ const failedEntries = warnings.map((warning) => (typeof warning === 'string' ? warning : String(warning?.message ?? warning)))
325
333
  log(
326
334
  `plugins reloaded (warnings=${warnings.length} pending=${pending.length}${restartReason === null ? '' : ` restart=${restartReason}`})`,
327
335
  )
@@ -330,7 +338,9 @@ function pluginReloader(ctx, config) {
330
338
 
331
339
  const flush = async () => {
332
340
  timer = null
333
- if (running) {
341
+ // A queue request that outlived its wait is still applying: a second flush would ask the plugin
342
+ // set to recompose beside it, which can leave half of it stopped.
343
+ if (running || workState.current !== null) {
334
344
  again = true
335
345
  return
336
346
  }
@@ -372,7 +382,19 @@ function pluginReloader(ctx, config) {
372
382
  await reload(hmr)
373
383
  if (stamp !== null) lastStamp = stamp
374
384
  } finally {
375
- running = false
385
+ // The wait can end while its queue request is still applying. Staying busy keeps a press
386
+ // from queueing a second update, and the release clears the flag when that request ends.
387
+ if (workState.current === null) running = false
388
+ else {
389
+ const release = () => {
390
+ running = false
391
+ if (again) {
392
+ again = false
393
+ if (timer === null) timer = setTimeout(() => void flush(), debounce)
394
+ }
395
+ }
396
+ workState.current.then(release, release)
397
+ }
376
398
  }
377
399
  } catch (error) {
378
400
  log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
@@ -432,7 +454,7 @@ function pluginReloader(ctx, config) {
432
454
  })
433
455
 
434
456
  /** A press made while an update is still running joins that update instead of queueing another. */
435
- const busyAnswer = () => (work !== null ? { status: 'applying', warnings: [], pending: [], restartReason: null, failedEntries: [] } : queuedAnswer(queueReload()))
457
+ const busyAnswer = () => (workState.current !== null ? { status: 'applying', warnings: [], pending: [], restartReason: null, failedEntries: [] } : queuedAnswer(queueReload()))
436
458
 
437
459
  /**
438
460
  * The button path: run now and report what happened, so the page can tell the user why the
@@ -443,10 +465,10 @@ function pluginReloader(ctx, config) {
443
465
  const hmr = hmrService()
444
466
  if (hmr === null) return { status: 'unavailable', warnings: [], pending: [], restartReason: null, failedEntries: [] }
445
467
  if (turnsRunning()) log('reloading while turns are still running; they keep running')
446
- if (running || (force === false && busy())) return busyAnswer()
468
+ if (running || workState.current !== null || (force === false && busy())) return busyAnswer()
447
469
  const wait = lastRun + MIN_INTERVAL_MS - Date.now()
448
470
  if (wait > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(wait, 2000)))
449
- if (running || (force === false && busy())) return busyAnswer()
471
+ if (running || workState.current !== null || (force === false && busy())) return busyAnswer()
450
472
  if (timer !== null) {
451
473
  clearTimeout(timer)
452
474
  timer = null
@@ -489,7 +511,7 @@ function pluginReloader(ctx, config) {
489
511
  log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
490
512
  return { status: 'failed', warnings: [], pending: [], restartReason: null, failedEntries: [] }
491
513
  } finally {
492
- if (work === null) {
514
+ if (workState.current === null) {
493
515
  running = false
494
516
  again = false
495
517
  } else {
@@ -501,7 +523,7 @@ function pluginReloader(ctx, config) {
501
523
  if (timer === null) timer = setTimeout(() => void flush(), debounce)
502
524
  }
503
525
  }
504
- work.then(release, release)
526
+ workState.current.then(release, release)
505
527
  }
506
528
  }
507
529
  }
@@ -557,6 +579,31 @@ function sendJson(res, status, payload) {
557
579
  } catch {}
558
580
  }
559
581
 
582
+ /** Read one small JSON request body; anything unreadable answers null instead of hanging. */
583
+ function readJsonBody(req, limit = 64 * 1024) {
584
+ return new Promise((resolve) => {
585
+ let size = 0
586
+ const parts = []
587
+ try {
588
+ req.on('data', (chunk) => {
589
+ size += chunk.length
590
+ if (size <= limit) parts.push(chunk)
591
+ })
592
+ req.on('end', () => {
593
+ if (size > limit) return resolve(null)
594
+ try {
595
+ resolve(JSON.parse(Buffer.concat(parts).toString('utf8')))
596
+ } catch {
597
+ resolve(null)
598
+ }
599
+ })
600
+ req.on('error', () => resolve(null))
601
+ } catch {
602
+ resolve(null)
603
+ }
604
+ })
605
+ }
606
+
560
607
  /**
561
608
  * The startup list the server would hand the next document, read from the live index injection
562
609
  * table. The page's own copy describes the page already running: a plugin swapped since then
@@ -764,27 +811,38 @@ async function serveCurrentBundle(clientModules, req, res) {
764
811
  }
765
812
  }
766
813
 
767
- /** The revision the module table serves now, or null when it cannot be read. */
768
- function currentRevision(clientModules) {
814
+ /** The revision the table serves one plugin at, or null when the set no longer holds it. */
815
+ function entryRevisionFor(graph, rawUrl) {
816
+ let path = ''
769
817
  try {
770
- const graph = clientModules.graph()
771
- return typeof graph?.rev === 'string' && graph.rev !== '' ? graph.rev : null
818
+ path = new URL(String(rawUrl ?? ''), 'http://unlag.invalid').pathname
772
819
  } catch {
773
820
  return null
774
821
  }
822
+ if (!path.startsWith(`${BUNDLE_ROUTE}/`)) return null
823
+ const rows = Array.isArray(graph?.entries) ? graph.entries : []
824
+ for (const row of rows) {
825
+ if (typeof row?.id !== 'string' || row.id === '' || typeof row?.rev !== 'string' || row.rev === '') continue
826
+ if (path === `${BUNDLE_ROUTE}/${row.id}` || path.startsWith(`${BUNDLE_ROUTE}/${row.id}/`)) return row.rev
827
+ }
828
+ return null
775
829
  }
776
830
 
777
831
  /**
778
- * Point a bundle request at the current revision while keeping the plugin set it names. The table
779
- * only keeps the previous generation of whole documents, and dynamic chunks are served at the
780
- * current revision alone; a page that kept an old revision would get a main bundle it cannot load
781
- * chunks for. Rewriting the revision keeps one generation in the whole answer.
832
+ * Point a request for one plugin's file at the revision the table serves that plugin at.
833
+ *
834
+ * A document and every chunk it later loads ask with the revision they booted from. The table keeps
835
+ * whole documents from the previous generation only, and a chunk is served at exactly the revision of
836
+ * its own entry: asking for one plugin's chunk at the set's revision, or at another plugin's revision,
837
+ * answers 404 and ends a document that already runs. The plugin path alone decides the revision, so
838
+ * only the revision of the file named in the path is replaced; everything else is left alone.
782
839
  */
783
- function retargetRevision(url, current) {
784
- if (current === null || typeof url !== 'string') return null
840
+ function retargetRevision(url, revision) {
841
+ if (revision === null || typeof url !== 'string') return null
785
842
  const match = /([?&])rev=([^&#]*)/u.exec(url)
786
- if (match === null || match[2] === current) return null
787
- return `${url.slice(0, match.index)}${match[1]}rev=${current}${url.slice(match.index + match[0].length)}`
843
+ if (match !== null && match[2] === revision) return null
844
+ if (match !== null) return `${url.slice(0, match.index)}${match[1]}rev=${revision}${url.slice(match.index + match[0].length)}`
845
+ return `${url}${url.includes('?') ? '&' : '?'}rev=${revision}`
788
846
  }
789
847
 
790
848
  /** The same answer over the request-in/response-out route a newer app registers. */
@@ -828,10 +886,11 @@ function serveBundlesAtCurrentRevision(ctx) {
828
886
  const wrapped = async (request) => {
829
887
  /**
830
888
  * The table's own answer comes first: it still holds the document generation this page booted
831
- * from. Only when it has nothing is the current one served, and the request is retargeted at
832
- * the current revision so the answer and its chunks belong to the same generation.
889
+ * from. Only when it has nothing is the same request tried at the revision of the plugin it
890
+ * names, because a chunk is served at its entry's revision and nowhere else.
833
891
  */
834
- const retargeted = retargetRevision(String(request?.url ?? ''), currentRevision(clientModules))
892
+ const url = String(request?.url ?? '')
893
+ const retargeted = retargetRevision(url, entryRevisionFor(clientModules.graph(), url))
835
894
  const method = String(request?.method ?? 'GET').toUpperCase()
836
895
  let answer = null
837
896
  try {
@@ -871,7 +930,7 @@ function serveBundlesAtCurrentRevision(ctx) {
871
930
  */
872
931
  const wrapped = async (req, res) => {
873
932
  const originalUrl = String(req?.url ?? '')
874
- const retargeted = retargetRevision(originalUrl, currentRevision(clientModules))
933
+ const retargeted = retargetRevision(originalUrl, entryRevisionFor(clientModules.graph(), originalUrl))
875
934
  const realWriteHead = res.writeHead
876
935
  const realEnd = res.end
877
936
  const ask = async (url) => {
@@ -990,6 +1049,133 @@ function bundleShimStatus(ctx) {
990
1049
  }
991
1050
  }
992
1051
 
1052
+ /** Ids a classic-script body registers through the module loader's public entry point. */
1053
+ function registeredIds(body) {
1054
+ const ids = new Set()
1055
+ for (const match of body.matchAll(/__ModuleLoader__\s*\.\s*load\s*\(\s*\{[\s\S]{0,8192}?\bid\s*:\s*(["'`])([^"'`]+)\1/gu)) ids.add(match[2])
1056
+ return ids
1057
+ }
1058
+
1059
+ /** Ids a body reads from the loader's table through `require`, which must already be registered. */
1060
+ function requiredIds(body) {
1061
+ const ids = new Set()
1062
+ for (const match of body.matchAll(/\brequire\s*\(\s*(["'])([^"']+)\1\s*\)/gu)) ids.add(match[2])
1063
+ return ids
1064
+ }
1065
+
1066
+ /**
1067
+ * Compile one body the way the document compiles it, and never run it: running a plugin body here
1068
+ * would execute its top level inside the host that owns every running turn.
1069
+ * @returns the parser's complaint, or null when the body compiles as a classic script.
1070
+ */
1071
+ function parseAsScript(body, name) {
1072
+ try {
1073
+ new Script(body, { filename: name })
1074
+ return null
1075
+ } catch (error) {
1076
+ return String(error?.message ?? error)
1077
+ }
1078
+ }
1079
+
1080
+ /**
1081
+ * Answer whether the next document would come up from the scripts a startup list names.
1082
+ *
1083
+ * The page cannot answer this itself: `new Function` is not the parser a script tag uses, and running
1084
+ * a candidate body there would execute the plugin inside the page that carries the turn. Here every
1085
+ * body is read from the address the document would use, compiled with the real parser, and checked
1086
+ * for the ids it registers and the modules it reads. The desktop shell boots every document from one
1087
+ * startup list captured at launch, so bytes that change after this answer stay outside what any check
1088
+ * can promise: disagreement between the bytes and the plugin list is a hold, never a rebuild.
1089
+ * @param graph the plugin table's live description.
1090
+ * @param fetchBody reads one script address and answers a response-like, or null when it cannot.
1091
+ * @param rows the startup script addresses, as the next document would request them.
1092
+ */
1093
+ export async function verifyStartupRows(graph, fetchBody, rows) {
1094
+ const entries = new Map(
1095
+ (Array.isArray(graph?.entries) ? graph.entries : [])
1096
+ .filter((row) => typeof row?.id === 'string' && row.id !== '')
1097
+ .map((row) => [row.id, row]),
1098
+ )
1099
+ const wanted = []
1100
+ for (const row of Array.isArray(rows) ? rows : []) {
1101
+ if (typeof row !== 'string' || row === '') continue
1102
+ const path = row.includes('://') ? row.replace(/^[a-z]+:\/\/[^/]+/iu, '') : row
1103
+ const url = path.startsWith('/') ? path : `/${path.replace(/^\.?\//u, '')}`
1104
+ const request = bundleRequest(url)
1105
+ wanted.push({ url, ids: request === null ? [] : request.ids })
1106
+ }
1107
+ if (wanted.length === 0) return { status: 'blocked', reason: 'list', detail: 'the startup list names no script' }
1108
+ const listed = new Set()
1109
+ const bodies = []
1110
+ for (const item of wanted) {
1111
+ for (const id of item.ids) listed.add(id)
1112
+ let response = null
1113
+ try {
1114
+ response = await fetchBody(item.url)
1115
+ } catch (error) {
1116
+ return { status: 'blocked', reason: 'network', detail: `${item.url}: ${String(error?.message ?? error)}` }
1117
+ }
1118
+ if (response === null || response === undefined || response.status !== 200) {
1119
+ return { status: 'blocked', reason: 'missing', detail: `${item.url} answers ${String(response?.status ?? 'nothing')}` }
1120
+ }
1121
+ const bytes = Buffer.from(await response.arrayBuffer())
1122
+ if (bytes.length === 0) return { status: 'blocked', reason: 'empty', detail: `${item.url} answers an empty body` }
1123
+ const text = bytes.toString('utf8')
1124
+ const complaint = parseAsScript(text, item.url)
1125
+ if (complaint !== null) return { status: 'blocked', reason: 'parse', detail: `${item.url}: ${complaint}` }
1126
+ const registered = registeredIds(text)
1127
+ const missing = item.ids.filter((id) => !registered.has(id))
1128
+ if (item.ids.length > 0 && missing.length > 0) {
1129
+ return { status: 'blocked', reason: 'register', detail: `${item.url} does not register ${missing.join(', ')}` }
1130
+ }
1131
+ bodies.push({
1132
+ url: item.url,
1133
+ text,
1134
+ ids: item.ids,
1135
+ digest: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
1136
+ })
1137
+ }
1138
+ /**
1139
+ * A body can read a plugin its own document does not list: the set changed after that document was
1140
+ * composed, and the missing module only shows up inside the new page as a failed import. Every id
1141
+ * that is one of the app's client entries and is neither registered by a listed script nor listed
1142
+ * itself is that case, so the press is held rather than booting a page that dies half way.
1143
+ */
1144
+ const available = new Set(listed)
1145
+ for (const body of bodies) for (const id of registeredIds(body.text)) available.add(id)
1146
+ const absent = new Set()
1147
+ for (const body of bodies) {
1148
+ for (const id of requiredIds(body.text)) {
1149
+ if (available.has(id) || !entries.has(id)) continue
1150
+ absent.add(id)
1151
+ }
1152
+ }
1153
+ if (absent.size > 0) {
1154
+ return {
1155
+ status: 'blocked',
1156
+ reason: 'dependencies',
1157
+ detail: `not registered by the startup list: ${[...absent].slice(0, 4).join(', ')}`,
1158
+ }
1159
+ }
1160
+ return {
1161
+ status: 'ok',
1162
+ reason: 'ok',
1163
+ revision: typeof graph?.rev === 'string' ? graph.rev : null,
1164
+ scripts: bodies.map((body) => ({ url: body.url, digest: body.digest, ids: body.ids })),
1165
+ }
1166
+ }
1167
+
1168
+ /** Ask the same question of the plugin table the plugin itself talks to. */
1169
+ async function verifyStartup(clientModules, rows) {
1170
+ let graph = null
1171
+ try {
1172
+ graph = clientModules.graph()
1173
+ } catch (error) {
1174
+ return { status: 'blocked', reason: 'list', detail: String(error?.message ?? error) }
1175
+ }
1176
+ return verifyStartupRows(graph, (url) => fetchBundleAt(clientModules, url, 'GET'), rows)
1177
+ }
1178
+
993
1179
  /**
994
1180
  * The entry points the header button calls. Each answers with the current switches so the page
995
1181
  * never guesses: with both off it does nothing, and a turn still running is reported rather than
@@ -1017,6 +1203,27 @@ function serveButton(ctx, controller, config) {
1017
1203
  sendJson(res, 200, { graph: liveBootGraph(ctx) })
1018
1204
  },
1019
1205
  ],
1206
+ [
1207
+ `${BASE_PATH}/verify`,
1208
+ async (req, res) => {
1209
+ const method = String(req?.method ?? 'GET').toUpperCase()
1210
+ if (method !== 'POST') return sendJson(res, 405, { status: 'method' })
1211
+ const header = req?.headers?.[BUTTON_HEADER]
1212
+ if (typeof header !== 'string' || header === '') return sendJson(res, 403, { status: 'forbidden' })
1213
+ const clientModules = ctx.get('clientModules')
1214
+ if (typeof clientModules?.fetchBundle !== 'function' || typeof clientModules?.graph !== 'function') {
1215
+ return sendJson(res, 200, { status: 'blocked', reason: 'list', detail: 'the plugin table is not running' })
1216
+ }
1217
+ const body = await readJsonBody(req)
1218
+ let verdict = null
1219
+ try {
1220
+ verdict = await verifyStartup(clientModules, Array.isArray(body?.rows) ? body.rows : [])
1221
+ } catch (error) {
1222
+ verdict = { status: 'blocked', reason: 'list', detail: String(error?.message ?? error) }
1223
+ }
1224
+ sendJson(res, 200, verdict)
1225
+ },
1226
+ ],
1020
1227
  [
1021
1228
  `${BASE_PATH}/report`,
1022
1229
  (req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-unlag",
3
- "version": "1.8.3",
3
+ "version": "1.8.5",
4
4
  "type": "module",
5
5
  "description": "One press applies plugin edits right away and brings a laggy interface back; running turns, queued messages and unsent input are untouched, and nothing restarts.",
6
6
  "license": "MIT",