dsh-unlag 1.7.4 → 1.7.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 +89 -2
  3. package/index.js +151 -0
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -41,12 +41,14 @@ dsh plugin install dsh-unlag
41
41
 
42
42
  ## 排错
43
43
 
44
- - 日志:`~/.dsh/unlag-reload.log`,换代码/跳过/被拦各一行含原因。
44
+ - 日志:`~/.dsh/unlag-reload.log`,换代码/跳过/被拦各一行,含原因。
45
45
  - 状态:`GET /dsh-unlag/state` → `{"plugins":true,"page":true}`。
46
46
 
47
47
  ## 测试
48
48
 
49
49
  ```sh
50
50
  node test/button.test.mjs
51
+ node test/bundle-shim.test.mjs
52
+ node test/client-guard.test.mjs
51
53
  node test/safety.test.mjs
52
54
  ```
package/client.js CHANGED
@@ -122,6 +122,81 @@ window.__ModuleLoader__.load({
122
122
  return true;
123
123
  }
124
124
 
125
+ /**
126
+ * The startup list the desktop shell hands the next document, exactly as it hands it. The
127
+ * shell reads that list once when the app starts and replays the same copy for every document
128
+ * after that, so this is the only way to see what a rebuild would really boot from.
129
+ * @returns the list rows, or null when this page is not running inside the desktop shell.
130
+ */
131
+ async function desktopStartupPayload() {
132
+ const bridge = window.dshDesktopBoot;
133
+ if (bridge === undefined || bridge === null || typeof bridge.ready !== 'function') return null;
134
+ try {
135
+ const payload = await bridge.ready();
136
+ const rows = Array.isArray(payload?.injections) ? payload.injections : [];
137
+ return rows.length === 0 ? null : rows;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Whether a document booting from the desktop shell's saved startup list would come up. Every
145
+ * script in that list is read back from where the document would load it, and every plugin it
146
+ * lists must still be one the running app serves: a missing script ends the desktop app, which
147
+ * would take every running turn with it, so this is answered before anything navigates.
148
+ * @param rows - the saved startup list.
149
+ * @returns true when each saved script loads and still contains its plugins.
150
+ */
151
+ async function frozenStartupLooksServable(rows) {
152
+ const scripts = [];
153
+ let frozen = null;
154
+ for (const row of rows) {
155
+ if (row === null || typeof row !== 'object') continue;
156
+ if (typeof row.src === 'string' && row.src !== '') scripts.push(row.src);
157
+ if (row.kind !== 'global' || row.name !== '__DSH_BOOT__') continue; // ui-copy-ignore
158
+ let value = row.value;
159
+ if (typeof value === 'string') {
160
+ try {
161
+ value = JSON.parse(value);
162
+ } catch {
163
+ value = null;
164
+ }
165
+ }
166
+ if (value !== null && typeof value === 'object') frozen = value;
167
+ }
168
+ if (scripts.length === 0) return false;
169
+ const live = await hostBootGraph();
170
+ if (live !== null && frozen !== null) {
171
+ const liveIds = new Set((Array.isArray(live.entries) ? live.entries : []).map((entry) => (typeof entry === 'string' ? entry : entry?.id)));
172
+ const kept = Array.isArray(frozen.entries) ? frozen.entries : [];
173
+ for (const entry of kept) {
174
+ const id = typeof entry === 'string' ? entry : entry?.id;
175
+ if (typeof id === 'string' && id !== '' && !liveIds.has(id)) return false;
176
+ }
177
+ }
178
+ for (const src of scripts) {
179
+ let target;
180
+ try {
181
+ target = new URL(src, location.href).toString();
182
+ } catch {
183
+ return false;
184
+ }
185
+ try {
186
+ const response = await fetch(target, { cache: 'no-store', credentials: 'same-origin' });
187
+ if (!response.ok) return false;
188
+ const body = await response.text();
189
+ if (body.length === 0) return false;
190
+ for (const id of bundledIds(target)) {
191
+ if (!body.includes(id)) return false;
192
+ }
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+ return true;
198
+ }
199
+
125
200
  /** The first balanced JSON object after `__DSH_BOOT__` in a served document. */
126
201
  function graphFromHtml(html) {
127
202
  const at = html.indexOf('__DSH_BOOT__'); // ui-copy-ignore
@@ -199,8 +274,12 @@ window.__ModuleLoader__.load({
199
274
  */
200
275
  async function startupLooksServable() {
201
276
  for (let attempt = 0; attempt < 2; attempt += 1) {
202
- const graph = (await servedBootGraph()) ?? window.__DSH_BOOT__;
203
- if (await bundlesCoverStartup(graph)) return true;
277
+ const saved = await desktopStartupPayload();
278
+ const servable =
279
+ saved === null
280
+ ? await bundlesCoverStartup((await servedBootGraph()) ?? window.__DSH_BOOT__)
281
+ : await frozenStartupLooksServable(saved);
282
+ if (servable) return true;
204
283
  if (attempt === 0) await new Promise((resolve) => setTimeout(resolve, 300)); // ui-copy-ignore
205
284
  }
206
285
  return false;
@@ -388,8 +467,16 @@ window.__ModuleLoader__.load({
388
467
  notice === null ? null : h('span', { role: 'status', 'aria-live': 'polite', style: PILL_STYLE }, notice));
389
468
  }
390
469
 
470
+ /**
471
+ * The guard as the press asks it. Exposed only to a headless harness: the failure this plugin
472
+ * must never cause is a navigation the saved startup list cannot survive, and that question is
473
+ * worth asking without starting a desktop app.
474
+ */
475
+ const PROBE = { prepareInterface, startupLooksServable, frozenStartupLooksServable, desktopStartupPayload, reloadFreshPage };
476
+
391
477
  return {
392
478
  inject: ['slots'], // ui-copy-ignore
479
+ ...(window.__DSH_UNLAG_PROBE__ === true ? { probe: PROBE } : {}), // ui-copy-ignore
393
480
  apply(ctx) {
394
481
  if (!desktop) return;
395
482
  // Leave a line in the log per rebuilt interface: it is the one place that shows which
package/index.js CHANGED
@@ -43,6 +43,12 @@ const DEFAULT_ROOTS = [join(homedir(), '.dsh', 'plugins')]
43
43
  /** A turn that never goes idle must not turn this into an endless poll. */
44
44
  const MAX_DEFERRALS = 40
45
45
  const MIN_INTERVAL_MS = 1500
46
+ /** The route the app publishes client bundles under; the same key the module table registers. */
47
+ const BUNDLE_ROUTE = '/plugins'
48
+ /** Marks a route handler already wrapped here, so a plugin reload cannot wrap it twice. */
49
+ const SHIM_MARK = Symbol.for('dsh-unlag.bundle-shim')
50
+ /** Holds the wrapper across a module replacement, so a reload can put the route back. */
51
+ const SHIM_KEY = Symbol.for('dsh-unlag.shim')
46
52
 
47
53
  /**
48
54
  * The two rows this plugin shows in Settings, in the schema dialect the settings form reads.
@@ -399,6 +405,136 @@ function liveBootGraph(server) {
399
405
  }
400
406
  }
401
407
 
408
+ /**
409
+ * The plugin ids, revision and source-map flag one bundle request names, read from the
410
+ * `??<id>/client.js,…&rev=<rev>` form the startup list uses. A request for a package-local chunk
411
+ * carries a real query string instead and is left alone: it is read from disk, so it is current.
412
+ */
413
+ function bundleRequest(rawUrl) {
414
+ const url = String(rawUrl ?? '')
415
+ const start = url.indexOf(`${BUNDLE_ROUTE}/??`)
416
+ if (start < 0) return null
417
+ const rest = url.slice(start + BUNDLE_ROUTE.length + 1)
418
+ const revAt = rest.indexOf('&rev=')
419
+ const target = revAt < 0 ? rest : rest.slice(0, revAt)
420
+ if (!target.startsWith('??')) return null
421
+ const sourceMap = target.endsWith('.map')
422
+ const list = sourceMap ? target.slice(0, -4) : target
423
+ const ids = []
424
+ for (const part of list.slice(2).split(',')) {
425
+ let id = part
426
+ try {
427
+ id = decodeURIComponent(part)
428
+ } catch {}
429
+ id = id.endsWith('/client.js') ? id.slice(0, -'/client.js'.length) : id
430
+ if (id !== '') ids.push(id)
431
+ }
432
+ if (ids.length === 0) return null
433
+ return { ids, sourceMap, rev: revAt < 0 ? '' : rest.slice(revAt + '&rev='.length) }
434
+ }
435
+
436
+ /**
437
+ * The same plugin set under the revision the running app serves it at. The startup list a
438
+ * document is given names one revision; after a plugin is swapped, that revision stops
439
+ * resolving, and a document that boots from the list is refused by its own module loader. The
440
+ * ids are unchanged, so the set can be re-pointed at the current revision instead.
441
+ */
442
+ function currentBundleUrls(graph, request) {
443
+ const wanted = new Set(request.ids)
444
+ const revisions = new Set()
445
+ for (const batch of Array.isArray(graph?.batches) ? graph.batches : []) {
446
+ const entries = Array.isArray(batch?.entries) ? batch.entries : []
447
+ if (entries.length !== request.ids.length) continue
448
+ if (!entries.every((id) => wanted.has(id))) continue
449
+ if (typeof batch.rev === 'string' && batch.rev !== '') revisions.add(batch.rev)
450
+ }
451
+ if (request.ids.length === 1) {
452
+ for (const entry of Array.isArray(graph?.entries) ? graph.entries : []) {
453
+ if (entry?.id !== request.ids[0]) continue
454
+ if (typeof entry.rev === 'string' && entry.rev !== '') revisions.add(entry.rev)
455
+ }
456
+ }
457
+ const list = request.ids.map((id) => `${id}/client.js${request.sourceMap ? '.map' : ''}`).join(',')
458
+ return [...revisions].map((rev) => `${BUNDLE_ROUTE}/??${list}&rev=${rev}`)
459
+ }
460
+
461
+ /**
462
+ * Answer one request the running app cannot serve because the revision it names is gone. The
463
+ * bundle module table is asked for the same set at its current revision, and only a body that
464
+ * really contains every plugin it claims is handed back; anything else falls through to the
465
+ * route as before, so an unknown id still ends as a plain 404.
466
+ */
467
+ async function serveCurrentBundle(clientModules, req, res) {
468
+ try {
469
+ const method = String(req?.method ?? 'GET').toUpperCase()
470
+ if (method !== 'GET' && method !== 'HEAD') return false
471
+ const request = bundleRequest(req?.url)
472
+ if (request === null) return false
473
+ const urls = currentBundleUrls(clientModules.graph(), request)
474
+ if (urls.length === 0) return false
475
+ if (request.rev !== '' && urls.some((url) => url.endsWith(`&rev=${request.rev}`))) return false
476
+ for (const url of urls) {
477
+ const response = await clientModules.fetchBundle({ method: 'GET', url: `http://unlag.invalid${url}` })
478
+ if (response.status !== 200) continue
479
+ const body = Buffer.from(await response.arrayBuffer())
480
+ if (body.length === 0) continue
481
+ if (!request.sourceMap && !request.ids.every((id) => body.includes(id))) continue
482
+ if (res.writableEnded === true || res.destroyed === true) return true
483
+ res.writeHead(200, {
484
+ 'content-type': String(response.headers.get('content-type') ?? 'text/javascript; charset=utf-8'),
485
+ 'cache-control': 'no-store',
486
+ 'content-length': String(body.length),
487
+ })
488
+ res.end(method === 'HEAD' ? undefined : body)
489
+ log(`served ${request.ids.join(', ')} at the current revision: a page asked for an older one`)
490
+ return true
491
+ }
492
+ return false
493
+ } catch (error) {
494
+ log(`cannot serve a bundle from an older revision: ${error?.message ?? error}`)
495
+ return false
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Wrap the bundle route so a document booting from an older startup list still runs. The route
501
+ * belongs to the module table, and a second registration of the same prefix is refused, so the
502
+ * handler already there is wrapped in place and put back on dispose. Every path through the
503
+ * wrapper falls back to that handler: a bundle it cannot place is answered exactly as before.
504
+ */
505
+ function serveBundlesAtCurrentRevision(ctx) {
506
+ const server = ctx.get('webServer')
507
+ const clientModules = ctx.get('clientModules')
508
+ if (server?.prefixes?.get === undefined) return null
509
+ if (typeof clientModules?.fetchBundle !== 'function' || typeof clientModules?.graph !== 'function') return null
510
+ const route = server.prefixes.get(BUNDLE_ROUTE)
511
+ if (route === null || route === undefined || typeof route.handler !== 'function') return null
512
+ if (route[SHIM_MARK] === true) return null
513
+ const original = route.handler
514
+ const wrapped = (req, res) => {
515
+ const fallback = () => original(req, res)
516
+ const answered = serveCurrentBundle(clientModules, req, res).catch((error) => {
517
+ log(`cannot answer a bundle request: ${error?.message ?? error}`)
518
+ return false
519
+ })
520
+ if (typeof answered?.then !== 'function') return fallback()
521
+ return answered.then((served) => (served === true ? undefined : fallback()))
522
+ }
523
+ route[SHIM_MARK] = true
524
+ route.handler = wrapped
525
+ log('bundles asked for under an older revision are answered at the current one')
526
+ return { route, original, wrapped }
527
+ }
528
+
529
+ /** Put the bundle route back the way it was found; a route replaced since then is left alone. */
530
+ function releaseBundleShim(shim) {
531
+ try {
532
+ if (shim === null || shim === undefined) return
533
+ if (shim.route.handler === shim.wrapped) shim.route.handler = shim.original
534
+ if (shim.route[SHIM_MARK] === true) delete shim.route[SHIM_MARK]
535
+ } catch {}
536
+ }
537
+
402
538
  /**
403
539
  * The entry points the header button calls. Each answers with the current switches so the page
404
540
  * never guesses: with both off it does nothing, and a turn still running is reported rather than
@@ -474,10 +610,21 @@ export function apply(ctx, config) {
474
610
  try {
475
611
  closeAll(globalThis[WATCHER_KEY])
476
612
  closeAll(globalThis[ROUTES_KEY])
613
+ releaseBundleShim(globalThis[SHIM_KEY])
477
614
  globalThis[WATCHER_KEY] = null
478
615
  globalThis[ROUTES_KEY] = null
616
+ globalThis[SHIM_KEY] = null
479
617
 
480
618
  const controller = pluginReloader(ctx, config ?? {})
619
+ /**
620
+ * The bundle route can be wrapped as soon as the module table has registered it. Doing it here
621
+ * as well as on the service event covers both orders: a table that is already up when this
622
+ * half starts, and one that appears after it.
623
+ */
624
+ const syncBundles = () => {
625
+ if (globalThis[SHIM_KEY] !== null) return
626
+ globalThis[SHIM_KEY] = serveBundlesAtCurrentRevision(ctx)
627
+ }
481
628
  /** Watching needs HMR; start as soon as it exists, drop the watcher when it goes away. */
482
629
  const syncWatching = () => {
483
630
  if (config?.plugins === 'off') return
@@ -497,16 +644,20 @@ export function apply(ctx, config) {
497
644
  if (typeof ctx.on === 'function') {
498
645
  ctx.on('internal/service', (service) => {
499
646
  if (service === 'hmr') syncWatching()
647
+ if (service === 'clientModules') syncBundles()
500
648
  })
501
649
  }
502
650
  globalThis[ROUTES_KEY] = serveButton(ctx, controller, config ?? {})
651
+ syncBundles()
503
652
 
504
653
  ctx.on('dispose', () => {
505
654
  controller.stop()
506
655
  closeAll(globalThis[WATCHER_KEY])
507
656
  closeAll(globalThis[ROUTES_KEY])
657
+ releaseBundleShim(globalThis[SHIM_KEY])
508
658
  globalThis[WATCHER_KEY] = null
509
659
  globalThis[ROUTES_KEY] = null
660
+ globalThis[SHIM_KEY] = null
510
661
  })
511
662
  } catch (error) {
512
663
  log(`plugin reload did not start: ${error?.message ?? error}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-unlag",
3
- "version": "1.7.4",
3
+ "version": "1.7.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",