dsh-unlag 1.7.11 → 1.8.0

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 -7
  2. package/client.js +56 -5
  3. package/index.js +222 -86
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -32,15 +32,14 @@ dsh plugin install dsh-unlag
32
32
 
33
33
  ## 不会丢什么
34
34
 
35
- - 轮次、子代理、终端进程:在宿主侧,一路继续。
35
+ - 轮次、子代理、终端进程:照常继续。
36
36
  - 排队消息、输入草稿、附件:重建前先提交焦点里的内容;有人没存完就拦下,不硬刷。
37
37
  - 有未保存改动、开着弹窗、或下个页面要用的模块读不全:拦下并提示,宁可不刷。
38
38
  - 从不自己重启或退出 App。
39
39
 
40
40
  管不到(它会拦下并说明,不会硬来):
41
41
 
42
- - 改动落在宿主侧的插件:新增原生能力、改服务声明、改权限。
43
- - 更新时正被某个任务使用的插件:那个任务可能随之结束。
42
+ - 改动替插件加原生能力、改服务声明或权限:这类改动不在一次点击的范围里。
44
43
  - 独立进程和进程级的东西:宠物窗口自己的配置、DSH 自身升级、原生依赖变化。
45
44
 
46
45
  ## 排错
@@ -51,8 +50,5 @@ dsh plugin install dsh-unlag
51
50
  ## 测试
52
51
 
53
52
  ```sh
54
- node test/button.test.mjs
55
- node test/bundle-shim.test.mjs
56
- node test/client-guard.test.mjs
57
- node test/safety.test.mjs
53
+ node --test test/*.test.mjs
58
54
  ```
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: '已排队:任务跑完自动重载', 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: 'Queued; it reloads once the turn ends', failed: 'The plugin update did not finish; press again later' };
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' };
11
11
 
12
12
  // Desktop shell only: the macOS and Windows desktop build owns the page and the running
13
13
  // app, so this is where the button belongs. A browser tab can reload freely on its own.
@@ -87,7 +87,10 @@ window.__ModuleLoader__.load({
87
87
  function reloadVerdict(data) {
88
88
  if (data === null || typeof data !== 'object') return 'failed';
89
89
  const status = typeof data.status === 'string' ? data.status : 'failed';
90
- if (Array.isArray(data.pending) && data.pending.length > 0) return 'queued';
90
+ // A plugin that did not start makes the set unusable for a rebuild, whatever the status says.
91
+ if (Array.isArray(data.failedEntries) && data.failedEntries.length > 0) return 'failed';
92
+ // `pending` is not a queued update: the changed modules need a restart to load at all.
93
+ if (Array.isArray(data.pending) && data.pending.length > 0) return 'restart';
91
94
  if (typeof data.restartReason === 'string' && data.restartReason !== '') return 'failed';
92
95
  return status;
93
96
  }
@@ -149,8 +152,44 @@ window.__ModuleLoader__.load({
149
152
  * @param body - the served script.
150
153
  * @returns true when the body parses, or when this page cannot compile one at all.
151
154
  */
155
+ /** The source with comments and string contents blanked out, so a keyword scan sees code only. */
156
+ function codeOnly(source) {
157
+ let out = '';
158
+ let index = 0;
159
+ while (index < source.length) {
160
+ const here = source[index];
161
+ const next = source[index + 1];
162
+ if (here === '/' && next === '*') {
163
+ const end = source.indexOf('*/', index + 2);
164
+ index = end < 0 ? source.length : end + 2;
165
+ out += ' ';
166
+ continue;
167
+ }
168
+ if (here === '/' && next === '/') {
169
+ const end = source.indexOf('\n', index + 2);
170
+ index = end < 0 ? source.length : end;
171
+ continue;
172
+ }
173
+ if (here === '"' || here === "'" || here === '`') {
174
+ index += 1;
175
+ while (index < source.length && source[index] !== here) {
176
+ if (source[index] === '\\') index += 1;
177
+ index += 1;
178
+ }
179
+ index += 1;
180
+ out += '""';
181
+ continue;
182
+ }
183
+ out += here;
184
+ index += 1;
185
+ }
186
+ return out;
187
+ }
188
+
152
189
  function parsesAsClassicScript(body) {
153
190
  try {
191
+ // A top-level return parses in a function body but not as a classic script.
192
+ if (/(?:^|[;{}])\s*return\b/u.test(codeOnly(body))) return false;
154
193
  new Function(body); // eslint-disable-line no-new-func
155
194
  return true;
156
195
  } catch (error) {
@@ -213,7 +252,10 @@ window.__ModuleLoader__.load({
213
252
  }
214
253
  if (scripts.length === 0) return false;
215
254
  const live = await hostBootGraph();
216
- if (live !== null && frozen !== null) {
255
+ if (frozen !== null) {
256
+ // The frozen list decides what this document boots. Without the current graph there is
257
+ // nothing to prove that list still resolves, so the page is not rebuilt.
258
+ if (live === null) return false;
217
259
  const liveIds = new Set((Array.isArray(live.entries) ? live.entries : []).map((entry) => (typeof entry === 'string' ? entry : entry?.id)));
218
260
  const kept = Array.isArray(frozen.entries) ? frozen.entries : [];
219
261
  for (const entry of kept) {
@@ -480,7 +522,16 @@ window.__ModuleLoader__.load({
480
522
  if (replace) {
481
523
  const status = await replacePlugins();
482
524
  if (status !== 'done' && status !== 'off') {
483
- const key = status === 'queued' ? 'queued' : status === 'unavailable' ? 'blocked' : 'failed';
525
+ const key =
526
+ status === 'queued'
527
+ ? 'queued'
528
+ : status === 'applying'
529
+ ? 'applying'
530
+ : status === 'restart'
531
+ ? 'restart'
532
+ : status === 'unavailable'
533
+ ? 'blocked'
534
+ : 'failed';
484
535
  report('plugin-held', key);
485
536
  return stop(key);
486
537
  }
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks'
2
- import { appendFileSync, existsSync, readFileSync, statSync, watch, writeFileSync } from 'node:fs'
2
+ import { appendFileSync, existsSync, readFileSync, statSync, watch } from 'node:fs'
3
3
  import { homedir } from 'node:os'
4
4
  import { join, resolve } from 'node:path'
5
5
 
@@ -30,12 +30,6 @@ export const inject = ['webServer']
30
30
  /** A transaction that has not finished in this long is stuck; the press reports it instead of hanging. */
31
31
  const RELOAD_TIMEOUT_MS = 20_000
32
32
 
33
- /**
34
- * A saved file reaches the app through the reload service's transaction context. If a later version
35
- * of the app stops exposing that context, the queue cannot be entered from a save; the profile's
36
- * patch file is used instead, because the loader watches it and re-imports the modules that changed.
37
- */
38
- const PROFILE_MARKER = '# dsh-unlag: apply saved plugin code'
39
33
 
40
34
  const LOG_PATH = join(homedir(), '.dsh', 'unlag-reload.log')
41
35
  /** Tests point this at a scratch file, so a test run leaves the live log readable. */
@@ -167,54 +161,6 @@ function transactionContext(service) {
167
161
  return null
168
162
  }
169
163
 
170
- /** The patch file of the profile this plugin runs in, when the loader exposes the profile. */
171
- function profilePatch(ctx) {
172
- try {
173
- const dir = ctx.get('profileContext')?.dir
174
- if (typeof dir !== 'string' || dir === '') return null
175
- const file = join(dir, 'cordis.patch.yml')
176
- return existsSync(file) ? file : null
177
- } catch {
178
- return null
179
- }
180
- }
181
-
182
- /** Drop the marker line again without touching anything else the file holds. */
183
- function clearProfileMarker(file) {
184
- try {
185
- const text = readFileSync(file, 'utf8')
186
- if (!text.includes(PROFILE_MARKER)) return
187
- const withoutBlankLine = text.replace(`\n\n${PROFILE_MARKER}\n`, '\n')
188
- writeFileSync(file, withoutBlankLine.replace(`\n${PROFILE_MARKER}\n`, '').replace(PROFILE_MARKER, ''))
189
- } catch (error) {
190
- log(`a reload marker is still in the profile: ${error?.message ?? error}`)
191
- }
192
- }
193
-
194
- /**
195
- * Last resort when a save cannot be queued: one comment line in the profile's patch file makes the
196
- * loader re-import the modules that changed, the same outcome a queued reload gives. The line is
197
- * taken back out as soon as the loader has read it, and a line a crash leaves behind is removed at
198
- * the next start, so the profile is left as the user wrote it.
199
- * @returns whether the profile was asked, so the caller can say why the save did not apply.
200
- */
201
- function markProfileForReload(ctx) {
202
- const file = profilePatch(ctx)
203
- if (file === null) return false
204
- try {
205
- const text = readFileSync(file, 'utf8')
206
- if (text.includes(PROFILE_MARKER)) return false
207
- appendFileSync(file, `\n${PROFILE_MARKER}\n`)
208
- } catch (error) {
209
- log(`the profile could not be asked for a reload: ${error?.message ?? error}`)
210
- return false
211
- }
212
- log('the saved plugin code is applied by a profile reload instead')
213
- const timer = setTimeout(() => clearProfileMarker(file), 3000)
214
- timer.unref?.()
215
- return true
216
- }
217
-
218
164
  function pluginReloader(ctx, config) {
219
165
  /** HMR can start after this half, so ask per use instead of once at load. */
220
166
  const hmrService = () => {
@@ -223,15 +169,14 @@ function pluginReloader(ctx, config) {
223
169
  return hmr
224
170
  }
225
171
  const available = () => hmrService() !== null
226
- /** A marker line this plugin left in the profile is not the user's; take it back out at start. */
227
- const leftover = profilePatch(ctx)
228
- if (leftover !== null) clearProfileMarker(leftover)
229
172
  /** Opt-in: wait for a quiet moment instead of swapping at once. Off by default. */
230
173
  const idleOnly = config?.idleOnly === true
231
174
  const debounce = Number.isFinite(config?.debounce) ? config.debounce : 300
232
175
 
233
176
  let timer = null
234
177
  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
235
180
  let again = false
236
181
  let deferrals = 0
237
182
  let lastRun = 0
@@ -274,30 +219,53 @@ function pluginReloader(ctx, config) {
274
219
  const timer = setTimeout(() => reject(new Error('the plugin update did not finish in time')), limit)
275
220
  timer.unref?.()
276
221
  })
222
+ /**
223
+ * The queue request itself keeps running after this plugin stops waiting for it. It is tracked
224
+ * so a timeout is reported as still applying, never as a failed update that later fires anyway.
225
+ */
226
+ const tracked = (request) => {
227
+ work = request
228
+ const settle = () => {
229
+ if (work === request) work = null
230
+ }
231
+ request.then(settle, settle)
232
+ return request
233
+ }
277
234
  let result = null
278
235
  for (let attempt = 0; ; attempt += 1) {
279
236
  try {
280
- result = await Promise.race([ask(hmr), abandoned()])
237
+ result = await Promise.race([tracked(ask(hmr)), abandoned()])
281
238
  break
282
239
  } catch (error) {
283
240
  const message = String(error?.message ?? error)
284
- if (message.includes('nested') === false) throw error
285
- if (attempt >= 9) {
286
- /** No context to leave means a save cannot be queued at all: hand the file to the loader. */
287
- if (context === null) markProfileForReload(ctx)
241
+ if (message.includes('nested')) {
242
+ if (attempt >= 9) throw error
243
+ const step = Number(process.env.DSH_UNLAG_RETRY_MS ?? '') || 250
244
+ await new Promise((resolve) => setTimeout(resolve, step * (attempt + 1)))
245
+ continue
246
+ }
247
+ if (message.includes('did not finish in time')) {
248
+ error.stillRunning = work !== null
288
249
  throw error
289
250
  }
290
- const step = Number(process.env.DSH_UNLAG_RETRY_MS ?? '') || 250
291
- await new Promise((resolve) => setTimeout(resolve, step * (attempt + 1)))
251
+ throw error
292
252
  }
293
253
  }
294
254
  const warnings = Array.isArray(result?.warnings) ? result.warnings : []
295
255
  const pending = Array.isArray(result?.pending) ? result.pending : []
296
256
  const restartReason = typeof result?.restartReason === 'string' ? result.restartReason : null
257
+ /**
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.
261
+ */
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))
297
265
  log(
298
266
  `plugins reloaded (warnings=${warnings.length} pending=${pending.length}${restartReason === null ? '' : ` restart=${restartReason}`})`,
299
267
  )
300
- return { status: 'done', warnings, pending, restartReason }
268
+ return { status: 'done', warnings, pending, restartReason, failedEntries }
301
269
  }
302
270
 
303
271
  const flush = async () => {
@@ -381,8 +349,12 @@ function pluginReloader(ctx, config) {
381
349
  warnings: [],
382
350
  pending: [],
383
351
  restartReason: null,
352
+ failedEntries: [],
384
353
  })
385
354
 
355
+ /** 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()))
357
+
386
358
  /**
387
359
  * The button path: run now and report what happened, so the page can tell the user why the
388
360
  * press did not replace anything instead of looking broken.
@@ -390,12 +362,12 @@ function pluginReloader(ctx, config) {
390
362
  */
391
363
  const reloadNow = async (force = false) => {
392
364
  const hmr = hmrService()
393
- if (hmr === null) return { status: 'unavailable', warnings: [], pending: [], restartReason: null }
365
+ if (hmr === null) return { status: 'unavailable', warnings: [], pending: [], restartReason: null, failedEntries: [] }
394
366
  if (turnsRunning()) log('reloading while turns are still running; they keep running')
395
- if (running || (force === false && busy())) return queuedAnswer(queueReload())
367
+ if (running || (force === false && busy())) return busyAnswer()
396
368
  const wait = lastRun + MIN_INTERVAL_MS - Date.now()
397
369
  if (wait > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(wait, 2000)))
398
- if (running || (force === false && busy())) return queuedAnswer(queueReload())
370
+ if (running || (force === false && busy())) return busyAnswer()
399
371
  if (timer !== null) {
400
372
  clearTimeout(timer)
401
373
  timer = null
@@ -405,18 +377,53 @@ function pluginReloader(ctx, config) {
405
377
  lastRun = Date.now()
406
378
  try {
407
379
  const outcome = await reload(hmr)
380
+ /**
381
+ * `pending` means the changed modules are not loaded yet and need a restart this plugin does
382
+ * not perform; it is not a queued update that finishes on its own.
383
+ */
408
384
  if (outcome.pending.length > 0 || outcome.restartReason !== null) {
409
385
  // The app accepted the update but has not finished starting the new plugin set. Rebuilding
410
386
  // the page now would start a document against a set that is still settling.
411
- return { status: 'failed', warnings: outcome.warnings, pending: outcome.pending, restartReason: outcome.restartReason }
387
+ return {
388
+ status: 'failed',
389
+ warnings: outcome.warnings,
390
+ pending: outcome.pending,
391
+ restartReason: outcome.restartReason,
392
+ failedEntries: outcome.failedEntries,
393
+ }
394
+ }
395
+ if (outcome.failedEntries.length > 0) {
396
+ return {
397
+ status: 'failed',
398
+ warnings: outcome.warnings,
399
+ pending: [],
400
+ restartReason: null,
401
+ failedEntries: outcome.failedEntries,
402
+ }
412
403
  }
413
404
  return outcome
414
405
  } catch (error) {
406
+ if (error?.stillRunning === true) {
407
+ // The queue still holds the request: it may yet apply, so the press is not called failed.
408
+ return { status: 'applying', warnings: [], pending: [], restartReason: null, failedEntries: [] }
409
+ }
415
410
  log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
416
- return { status: 'failed', warnings: [], pending: [], restartReason: null }
411
+ return { status: 'failed', warnings: [], pending: [], restartReason: null, failedEntries: [] }
417
412
  } finally {
418
- running = false
419
- again = false
413
+ if (work === null) {
414
+ running = false
415
+ again = false
416
+ } else {
417
+ // Stay busy until the queue request really settles, so a second press joins it.
418
+ const release = () => {
419
+ running = false
420
+ if (again) {
421
+ again = false
422
+ if (timer === null) timer = setTimeout(() => void flush(), debounce)
423
+ }
424
+ }
425
+ work.then(release, release)
426
+ }
420
427
  }
421
428
  }
422
429
 
@@ -475,10 +482,22 @@ function sendJson(res, status, payload) {
475
482
  * leaves it naming a module graph that no longer resolves, which is exactly the list a rebuild
476
483
  * must not trust.
477
484
  */
478
- function liveBootGraph(server) {
485
+ function liveBootGraph(ctx) {
479
486
  try {
480
- if (typeof server.collectIndexInjections !== 'function') return null
481
- const rows = server.collectIndexInjections()
487
+ /**
488
+ * The module table's own graph is the list the next document is built from, and reading it has
489
+ * no side effects. The injection table needs a peer for anything page-scoped, so it is not a
490
+ * substitute: a graph that cannot be read stays null and the page holds instead of guessing.
491
+ */
492
+ const modules = ctx.get('clientModules')
493
+ if (typeof modules?.graph === 'function') {
494
+ const graph = modules.graph()
495
+ if (graph !== null && typeof graph === 'object' && Array.isArray(graph.entries)) return graph
496
+ return null
497
+ }
498
+ const server = ctx.get('webServer')
499
+ if (typeof server?.collectIndexInjections !== 'function') return null
500
+ const rows = server.collectIndexInjections({})
482
501
  if (!Array.isArray(rows)) return null
483
502
  for (const row of rows) {
484
503
  if (row?.kind !== 'global') continue
@@ -578,8 +597,11 @@ async function fetchBundleAt(clientModules, url, method) {
578
597
  async function currentBundleAnswer(clientModules, method, url) {
579
598
  const request = bundleRequest(url)
580
599
  if (request === null) return null
581
- const urls = currentBundleUrls(clientModules.graph(), request)
600
+ const graph = clientModules.graph()
601
+ const urls = currentBundleUrls(graph, request)
582
602
  if (urls.length === 0) return null
603
+ // A request already at the generation the table serves is the table's to answer.
604
+ if (typeof graph?.rev === 'string' && graph.rev !== '' && request.rev === graph.rev) return null
583
605
  if (request.rev !== '' && urls.some((target) => target.endsWith(`&rev=${request.rev}`))) return null
584
606
  for (const target of urls) {
585
607
  const response = await fetchBundleAt(clientModules, target, 'GET')
@@ -618,6 +640,29 @@ async function serveCurrentBundle(clientModules, req, res) {
618
640
  }
619
641
  }
620
642
 
643
+ /** The revision the module table serves now, or null when it cannot be read. */
644
+ function currentRevision(clientModules) {
645
+ try {
646
+ const graph = clientModules.graph()
647
+ return typeof graph?.rev === 'string' && graph.rev !== '' ? graph.rev : null
648
+ } catch {
649
+ return null
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Point a bundle request at the current revision while keeping the plugin set it names. The table
655
+ * only keeps the previous generation of whole documents, and dynamic chunks are served at the
656
+ * current revision alone; a page that kept an old revision would get a main bundle it cannot load
657
+ * chunks for. Rewriting the revision keeps one generation in the whole answer.
658
+ */
659
+ function retargetRevision(url, current) {
660
+ if (current === null || typeof url !== 'string') return null
661
+ const match = /([?&])rev=([^&#]*)/u.exec(url)
662
+ if (match === null || match[2] === current) return null
663
+ return `${url.slice(0, match.index)}${match[1]}rev=${current}${url.slice(match.index + match[0].length)}`
664
+ }
665
+
621
666
  /** The same answer over the request-in/response-out route a newer app registers. */
622
667
  async function serveCurrentBundleResource(clientModules, request) {
623
668
  try {
@@ -657,13 +702,36 @@ function serveBundlesAtCurrentRevision(ctx) {
657
702
  if (typeof route.resource === 'function') {
658
703
  const original = route.resource
659
704
  const wrapped = async (request) => {
705
+ /**
706
+ * The table's own answer comes first: it still holds the document generation this page booted
707
+ * from. Only when it has nothing is the current one served, and the request is retargeted at
708
+ * the current revision so the answer and its chunks belong to the same generation.
709
+ */
710
+ const retargeted = retargetRevision(String(request?.url ?? ''), currentRevision(clientModules))
711
+ const method = String(request?.method ?? 'GET').toUpperCase()
712
+ let answer = null
713
+ try {
714
+ answer = await original(request)
715
+ } catch (error) {
716
+ log(`cannot answer a bundle request: ${error?.message ?? error}`)
717
+ }
718
+ if (retargeted !== null && (answer === null || answer === undefined || answer.status === 404)) {
719
+ try {
720
+ const next = method === 'GET' || method === 'HEAD' ? new Request(retargeted, { method, headers: request.headers }) : request
721
+ const second = await original(next)
722
+ if (second !== null && second !== undefined) answer = second
723
+ } catch (error) {
724
+ log(`cannot answer a bundle request: ${error?.message ?? error}`)
725
+ }
726
+ }
727
+ if (answer !== null && answer !== undefined && answer.status !== 404) return answer
660
728
  try {
661
729
  const served = await serveCurrentBundleResource(clientModules, request)
662
730
  if (served !== null) return served
663
731
  } catch (error) {
664
732
  log(`cannot answer a bundle request: ${error?.message ?? error}`)
665
733
  }
666
- return original(request)
734
+ return answer
667
735
  }
668
736
  route[SHIM_MARK] = true
669
737
  route.resource = wrapped
@@ -672,14 +740,82 @@ function serveBundlesAtCurrentRevision(ctx) {
672
740
  }
673
741
  if (typeof route.handler !== 'function') return null
674
742
  const original = route.handler
675
- const wrapped = (req, res) => {
676
- const fallback = () => original(req, res)
677
- const answered = serveCurrentBundle(clientModules, req, res).catch((error) => {
743
+ /**
744
+ * The table's own answer is kept, and only a 404 is held back long enough to ask whether the
745
+ * current generation can answer instead; the plugin set is retargeted at the current revision
746
+ * first, so the served document and the chunks it later asks for are one generation.
747
+ */
748
+ const wrapped = async (req, res) => {
749
+ const originalUrl = String(req?.url ?? '')
750
+ const retargeted = retargetRevision(originalUrl, currentRevision(clientModules))
751
+ const realWriteHead = res.writeHead
752
+ const realEnd = res.end
753
+ const ask = async (url) => {
754
+ let held = null
755
+ let body
756
+ if (url !== originalUrl) {
757
+ try {
758
+ req.url = url
759
+ } catch {
760
+ /* a frozen request keeps its own address */
761
+ }
762
+ }
763
+ if (typeof realWriteHead === 'function') {
764
+ res.writeHead = (status, ...rest) => {
765
+ if (status === 404) {
766
+ held = { status, headers: rest[0] }
767
+ return res
768
+ }
769
+ return realWriteHead.call(res, status, ...rest)
770
+ }
771
+ }
772
+ if (typeof realEnd === 'function') {
773
+ res.end = (...args) => {
774
+ if (held !== null) {
775
+ body = typeof args[0] === 'string' || Buffer.isBuffer(args[0]) ? args[0] : ''
776
+ return res
777
+ }
778
+ return realEnd.apply(res, args)
779
+ }
780
+ }
781
+ try {
782
+ await Promise.resolve(original(req, res))
783
+ } catch (error) {
784
+ log(`cannot answer a bundle request: ${error?.message ?? error}`)
785
+ } finally {
786
+ try {
787
+ res.writeHead = realWriteHead
788
+ res.end = realEnd
789
+ } catch {
790
+ /* the response object owns its own shape */
791
+ }
792
+ }
793
+ return { held, body }
794
+ }
795
+ // The table's own answer first: it still holds the generation this page booted from.
796
+ let outcome = await ask(originalUrl)
797
+ if (outcome.held !== null && retargeted !== null) outcome = await ask(retargeted)
798
+ if (outcome.held === null) return undefined
799
+ // The shim resolves the set from the address the page asked for, not the retried one.
800
+ try {
801
+ req.url = originalUrl
802
+ } catch {
803
+ /* a frozen request keeps its own address */
804
+ }
805
+ let served = false
806
+ try {
807
+ served = await serveCurrentBundle(clientModules, req, res)
808
+ } catch (error) {
678
809
  log(`cannot answer a bundle request: ${error?.message ?? error}`)
679
- return false
680
- })
681
- if (typeof answered?.then !== 'function') return fallback()
682
- return answered.then((served) => (served === true ? undefined : fallback()))
810
+ }
811
+ if (served === true) return undefined
812
+ try {
813
+ realWriteHead.call(res, outcome.held.status, outcome.held.headers)
814
+ realEnd.call(res, outcome.body)
815
+ } catch {
816
+ /* the response is gone; nothing left to answer */
817
+ }
818
+ return undefined
683
819
  }
684
820
  route[SHIM_MARK] = true
685
821
  route.handler = wrapped
@@ -754,7 +890,7 @@ function serveButton(ctx, controller, config) {
754
890
  `${BASE_PATH}/graph`,
755
891
  (_req, res) => {
756
892
  ensureBundleShim(ctx)
757
- sendJson(res, 200, { graph: liveBootGraph(server) })
893
+ sendJson(res, 200, { graph: liveBootGraph(ctx) })
758
894
  },
759
895
  ],
760
896
  [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-unlag",
3
- "version": "1.7.11",
3
+ "version": "1.8.0",
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",