dsh-unlag 1.8.2 → 1.8.4

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 +2 -1
  2. package/client.js +62 -164
  3. package/index.js +327 -39
  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 带走。
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
@@ -1,7 +1,9 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks'
2
- import { appendFileSync, existsSync, readFileSync, statSync, watch } from 'node:fs'
2
+ import { createHash } from 'node:crypto'
3
+ import { appendFileSync, existsSync, readFileSync, readdirSync, statSync, watch } from 'node:fs'
3
4
  import { homedir } from 'node:os'
4
5
  import { join, resolve } from 'node:path'
6
+ import { Script } from 'node:vm'
5
7
 
6
8
  /**
7
9
  * One header button, three ways new code reaches the running app:
@@ -38,12 +40,19 @@ const logTarget = () => process.env.DSH_UNLAG_LOG ?? LOG_PATH
38
40
  const WATCHER_KEY = Symbol.for('dsh-unlag.watchers')
39
41
  /** The same, for the HTTP entry points the header button calls. */
40
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')
41
45
  const BASE_PATH = '/dsh-unlag'
42
46
  /** A same-origin page sets this itself; a cross-site form post cannot, and fails the preflight. */
43
47
  const BUTTON_HEADER = 'x-dsh-unlag'
44
48
  const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs'])
45
49
  /** Build output and dependency trees change without the source a developer edits changing. */
46
50
  const IGNORED_PATH = /(^|\/)(node_modules|\.git|\.cache|dist|build|coverage)([./]|$)/u
51
+ /**
52
+ * A test edit does not change what a running plugin does, so it is not a reason to restart the
53
+ * plugin set. A development session runs its tests far more often than it changes source.
54
+ */
55
+ const TEST_PATH = /(^|\/)(test|tests|__tests__|__mocks__)(\/|$)|[.-](test|spec)\.[cm]?js$/u
47
56
  const DEFAULT_ROOTS = [join(homedir(), '.dsh', 'plugins')]
48
57
  /** A turn that never goes idle must not turn this into an endless poll. */
49
58
  const MAX_DEFERRALS = 40
@@ -119,6 +128,40 @@ function closeAll(closer) {
119
128
  }
120
129
  }
121
130
 
131
+ /**
132
+ * One line for the source a reload would act on: the same files, the same sizes, the same times.
133
+ * A watch event that arrives without any of that changing (a log beside a plugin, a save that
134
+ * wrote back the same bytes, a burst of events for one edit) is not a reason to swap anything,
135
+ * and swapping anyway is what put eleven applies next to a running turn.
136
+ */
137
+ function fingerprintOf(roots) {
138
+ const rows = []
139
+ const walk = (dir, depth) => {
140
+ if (depth > 6) return
141
+ let names
142
+ try {
143
+ names = readdirSync(dir, { withFileTypes: true })
144
+ } catch {
145
+ return
146
+ }
147
+ for (const item of names) {
148
+ const path = join(dir, item.name)
149
+ if (item.isDirectory()) {
150
+ walk(path, depth + 1)
151
+ continue
152
+ }
153
+ if (item.isFile() === false || !worthReloading(path)) continue
154
+ try {
155
+ const stat = statSync(path)
156
+ rows.push(`${path}\u0000${stat.size}\u0000${stat.mtimeMs}`)
157
+ } catch {}
158
+ }
159
+ }
160
+ for (const root of roots) walk(root, 0)
161
+ rows.sort()
162
+ return createHash('sha1').update(rows.join('\u0001')).digest('hex')
163
+ }
164
+
122
165
  /** Development roots the profile links by path; a packaged dependency is not watched. */
123
166
  function linkedRoots(baseDir) {
124
167
  const roots = []
@@ -142,7 +185,8 @@ function worthReloading(filename) {
142
185
  const name = typeof filename === 'string' ? filename : ''
143
186
  if (name === '') return false
144
187
  if (!CODE_EXTENSIONS.has(name.slice(name.lastIndexOf('.')))) return false
145
- return !IGNORED_PATH.test(name)
188
+ if (IGNORED_PATH.test(name) || TEST_PATH.test(name)) return false
189
+ return true
146
190
  }
147
191
 
148
192
  /**
@@ -172,14 +216,38 @@ function pluginReloader(ctx, config) {
172
216
  /** Opt-in: wait for a quiet moment instead of swapping at once. Off by default. */
173
217
  const idleOnly = config?.idleOnly === true
174
218
  const debounce = Number.isFinite(config?.debounce) ? config.debounce : 300
219
+ /**
220
+ * How long a saved file waits for the rest of its burst. Editors write a file more than once and
221
+ * an agent saves a set of files in a row; a swap per write restarts the plugin set under the
222
+ * user's feet, so the watcher waits for the writes to stop instead of racing them. A press is
223
+ * never delayed by this.
224
+ */
225
+ /** How close together two applies may land; one apply restarts the whole plugin set. */
226
+ const minInterval = Number.isFinite(config?.minIntervalMs) ? Math.max(0, config.minIntervalMs) : MIN_INTERVAL_MS
227
+ const settle = Number.isFinite(config?.settleMs)
228
+ ? Math.max(0, config.settleMs)
229
+ : Math.max(debounce, Math.min(1200, debounce * 4))
175
230
 
176
231
  let timer = null
177
232
  let running = false
178
- /** The queue request this plugin is still waiting on, so a timeout is not reported as a cancel. */
179
- let work = null
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 })
180
240
  let again = false
181
241
  let deferrals = 0
182
242
  let lastRun = 0
243
+ /** The source the last successful reload applied, so an unchanged save costs nothing. */
244
+ let lastStamp = null
245
+ /** The roots the watcher covers; empty when watching never started. */
246
+ let watchRoots = []
247
+ /** Whether the pending flush came from a saved file rather than from the button. */
248
+ let fromWatch = false
249
+ /** The file named by the newest watch event, so a reload can say what caused it. */
250
+ let lastChange = ''
183
251
 
184
252
  const turnsRunning = () => {
185
253
  try {
@@ -224,9 +292,9 @@ function pluginReloader(ctx, config) {
224
292
  * so a timeout is reported as still applying, never as a failed update that later fires anyway.
225
293
  */
226
294
  const tracked = (request) => {
227
- work = request
295
+ workState.current = request
228
296
  const settle = () => {
229
- if (work === request) work = null
297
+ if (workState.current === request) workState.current = null
230
298
  }
231
299
  request.then(settle, settle)
232
300
  return request
@@ -245,7 +313,7 @@ function pluginReloader(ctx, config) {
245
313
  continue
246
314
  }
247
315
  if (message.includes('did not finish in time')) {
248
- error.stillRunning = work !== null
316
+ error.stillRunning = workState.current !== null
249
317
  throw error
250
318
  }
251
319
  throw error
@@ -255,13 +323,13 @@ function pluginReloader(ctx, config) {
255
323
  const pending = Array.isArray(result?.pending) ? result.pending : []
256
324
  const restartReason = typeof result?.restartReason === 'string' ? result.restartReason : null
257
325
  /**
258
- * A reconciliation that returns without throwing can still have left a plugin unloaded: the app
259
- * reports that as a warning. A set with an entry that did not start is not a set the page can be
260
- * rebuilt against, so the press is reported as failed and names what did not start.
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.
261
331
  */
262
- const failedEntries = warnings
263
- .map((warning) => (typeof warning === 'string' ? warning : String(warning?.message ?? '')))
264
- .filter((warning) => /fail|cannot|missing|invalid|error/iu.test(warning))
332
+ const failedEntries = warnings.map((warning) => (typeof warning === 'string' ? warning : String(warning?.message ?? warning)))
265
333
  log(
266
334
  `plugins reloaded (warnings=${warnings.length} pending=${pending.length}${restartReason === null ? '' : ` restart=${restartReason}`})`,
267
335
  )
@@ -270,7 +338,9 @@ function pluginReloader(ctx, config) {
270
338
 
271
339
  const flush = async () => {
272
340
  timer = null
273
- 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) {
274
344
  again = true
275
345
  return
276
346
  }
@@ -285,21 +355,50 @@ function pluginReloader(ctx, config) {
285
355
  return
286
356
  }
287
357
  }
288
- const wait = lastRun + MIN_INTERVAL_MS - Date.now()
358
+ const wait = lastRun + minInterval - Date.now()
289
359
  if (wait > 0) {
290
360
  timer = setTimeout(() => void flush(), wait)
291
361
  return
292
362
  }
293
363
  const hmr = hmrService()
294
364
  if (hmr === null) return
365
+ const watched = fromWatch
366
+ fromWatch = false
367
+ /**
368
+ * A saved file that did not change anything a reload would apply is not applied again: the
369
+ * watcher reports more events than edits (one save can deliver several), and each apply
370
+ * restarts the whole plugin set next to whatever the user is doing.
371
+ */
372
+ const stamp = watchRoots.length === 0 ? null : fingerprintOf(watchRoots)
373
+ if (watched && stamp !== null && stamp === lastStamp) {
374
+ log(`a change to ${lastChange || 'a watched file'} left the plugin set as it is; nothing applied`)
375
+ return
376
+ }
377
+ if (watched && lastChange !== '') log(`reloading after a change to ${lastChange}`)
295
378
  deferrals = 0
296
379
  lastRun = Date.now()
297
380
  running = true
298
- await reload(hmr)
381
+ try {
382
+ await reload(hmr)
383
+ if (stamp !== null) lastStamp = stamp
384
+ } finally {
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
+ }
398
+ }
299
399
  } catch (error) {
300
400
  log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
301
401
  } finally {
302
- running = false
303
402
  if (again) {
304
403
  again = false
305
404
  if (timer === null) timer = setTimeout(() => void flush(), debounce)
@@ -310,8 +409,10 @@ function pluginReloader(ctx, config) {
310
409
  const schedule = (filename) => {
311
410
  try {
312
411
  if (!worthReloading(filename)) return
412
+ fromWatch = true
413
+ lastChange = String(filename).split('/').pop() ?? ''
313
414
  if (timer !== null) clearTimeout(timer)
314
- timer = setTimeout(() => void flush(), debounce)
415
+ timer = setTimeout(() => void flush(), settle)
315
416
  } catch (error) {
316
417
  log(`ignored a watch event: ${error?.message ?? error}`)
317
418
  }
@@ -353,7 +454,7 @@ function pluginReloader(ctx, config) {
353
454
  })
354
455
 
355
456
  /** A press made while an update is still running joins that update instead of queueing another. */
356
- const busyAnswer = () => (work !== null ? { status: 'applying', warnings: [], pending: [], restartReason: null, failedEntries: [] } : queuedAnswer(queueReload()))
457
+ const busyAnswer = () => (workState.current !== null ? { status: 'applying', warnings: [], pending: [], restartReason: null, failedEntries: [] } : queuedAnswer(queueReload()))
357
458
 
358
459
  /**
359
460
  * The button path: run now and report what happened, so the page can tell the user why the
@@ -364,10 +465,10 @@ function pluginReloader(ctx, config) {
364
465
  const hmr = hmrService()
365
466
  if (hmr === null) return { status: 'unavailable', warnings: [], pending: [], restartReason: null, failedEntries: [] }
366
467
  if (turnsRunning()) log('reloading while turns are still running; they keep running')
367
- if (running || (force === false && busy())) return busyAnswer()
468
+ if (running || workState.current !== null || (force === false && busy())) return busyAnswer()
368
469
  const wait = lastRun + MIN_INTERVAL_MS - Date.now()
369
470
  if (wait > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(wait, 2000)))
370
- if (running || (force === false && busy())) return busyAnswer()
471
+ if (running || workState.current !== null || (force === false && busy())) return busyAnswer()
371
472
  if (timer !== null) {
372
473
  clearTimeout(timer)
373
474
  timer = null
@@ -410,7 +511,7 @@ function pluginReloader(ctx, config) {
410
511
  log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
411
512
  return { status: 'failed', warnings: [], pending: [], restartReason: null, failedEntries: [] }
412
513
  } finally {
413
- if (work === null) {
514
+ if (workState.current === null) {
414
515
  running = false
415
516
  again = false
416
517
  } else {
@@ -422,7 +523,7 @@ function pluginReloader(ctx, config) {
422
523
  if (timer === null) timer = setTimeout(() => void flush(), debounce)
423
524
  }
424
525
  }
425
- work.then(release, release)
526
+ workState.current.then(release, release)
426
527
  }
427
528
  }
428
529
  }
@@ -433,6 +534,8 @@ function pluginReloader(ctx, config) {
433
534
  if (hmr === null) return null
434
535
  const configured = Array.isArray(config?.roots) ? config.roots.filter((root) => typeof root === 'string') : []
435
536
  const roots = [...new Set([...configured, ...linkedRoots(hmr.baseDir ?? process.cwd()), ...DEFAULT_ROOTS])]
537
+ watchRoots = roots
538
+ lastStamp = fingerprintOf(roots)
436
539
  const closers = []
437
540
  let watching = 0
438
541
  for (const root of roots) {
@@ -476,6 +579,31 @@ function sendJson(res, status, payload) {
476
579
  } catch {}
477
580
  }
478
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
+
479
607
  /**
480
608
  * The startup list the server would hand the next document, read from the live index injection
481
609
  * table. The page's own copy describes the page already running: a plugin swapped since then
@@ -683,27 +811,38 @@ async function serveCurrentBundle(clientModules, req, res) {
683
811
  }
684
812
  }
685
813
 
686
- /** The revision the module table serves now, or null when it cannot be read. */
687
- 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 = ''
688
817
  try {
689
- const graph = clientModules.graph()
690
- return typeof graph?.rev === 'string' && graph.rev !== '' ? graph.rev : null
818
+ path = new URL(String(rawUrl ?? ''), 'http://unlag.invalid').pathname
691
819
  } catch {
692
820
  return null
693
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
694
829
  }
695
830
 
696
831
  /**
697
- * Point a bundle request at the current revision while keeping the plugin set it names. The table
698
- * only keeps the previous generation of whole documents, and dynamic chunks are served at the
699
- * current revision alone; a page that kept an old revision would get a main bundle it cannot load
700
- * 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.
701
839
  */
702
- function retargetRevision(url, current) {
703
- if (current === null || typeof url !== 'string') return null
840
+ function retargetRevision(url, revision) {
841
+ if (revision === null || typeof url !== 'string') return null
704
842
  const match = /([?&])rev=([^&#]*)/u.exec(url)
705
- if (match === null || match[2] === current) return null
706
- 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}`
707
846
  }
708
847
 
709
848
  /** The same answer over the request-in/response-out route a newer app registers. */
@@ -747,10 +886,11 @@ function serveBundlesAtCurrentRevision(ctx) {
747
886
  const wrapped = async (request) => {
748
887
  /**
749
888
  * The table's own answer comes first: it still holds the document generation this page booted
750
- * from. Only when it has nothing is the current one served, and the request is retargeted at
751
- * 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.
752
891
  */
753
- const retargeted = retargetRevision(String(request?.url ?? ''), currentRevision(clientModules))
892
+ const url = String(request?.url ?? '')
893
+ const retargeted = retargetRevision(url, entryRevisionFor(clientModules.graph(), url))
754
894
  const method = String(request?.method ?? 'GET').toUpperCase()
755
895
  let answer = null
756
896
  try {
@@ -790,7 +930,7 @@ function serveBundlesAtCurrentRevision(ctx) {
790
930
  */
791
931
  const wrapped = async (req, res) => {
792
932
  const originalUrl = String(req?.url ?? '')
793
- const retargeted = retargetRevision(originalUrl, currentRevision(clientModules))
933
+ const retargeted = retargetRevision(originalUrl, entryRevisionFor(clientModules.graph(), originalUrl))
794
934
  const realWriteHead = res.writeHead
795
935
  const realEnd = res.end
796
936
  const ask = async (url) => {
@@ -909,6 +1049,133 @@ function bundleShimStatus(ctx) {
909
1049
  }
910
1050
  }
911
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
+
912
1179
  /**
913
1180
  * The entry points the header button calls. Each answers with the current switches so the page
914
1181
  * never guesses: with both off it does nothing, and a turn still running is reported rather than
@@ -936,6 +1203,27 @@ function serveButton(ctx, controller, config) {
936
1203
  sendJson(res, 200, { graph: liveBootGraph(ctx) })
937
1204
  },
938
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
+ ],
939
1227
  [
940
1228
  `${BASE_PATH}/report`,
941
1229
  (req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-unlag",
3
- "version": "1.8.2",
3
+ "version": "1.8.4",
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",