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.
- package/README.md +3 -1
- package/client.js +89 -2
- package/index.js +151 -0
- 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
|
|
203
|
-
|
|
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.
|
|
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",
|