dsh-unlag 1.7.10 → 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.
- package/README.md +3 -7
- package/client.js +56 -5
- package/index.js +241 -28
- 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
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
1
2
|
import { appendFileSync, existsSync, readFileSync, statSync, watch } from 'node:fs'
|
|
2
3
|
import { homedir } from 'node:os'
|
|
3
4
|
import { join, resolve } from 'node:path'
|
|
@@ -29,6 +30,7 @@ export const inject = ['webServer']
|
|
|
29
30
|
/** A transaction that has not finished in this long is stuck; the press reports it instead of hanging. */
|
|
30
31
|
const RELOAD_TIMEOUT_MS = 20_000
|
|
31
32
|
|
|
33
|
+
|
|
32
34
|
const LOG_PATH = join(homedir(), '.dsh', 'unlag-reload.log')
|
|
33
35
|
/** Tests point this at a scratch file, so a test run leaves the live log readable. */
|
|
34
36
|
const logTarget = () => process.env.DSH_UNLAG_LOG ?? LOG_PATH
|
|
@@ -147,6 +149,18 @@ function worthReloading(filename) {
|
|
|
147
149
|
* The plugin half. One controller serves both callers: a saved file (debounced) and the header
|
|
148
150
|
* button (immediate). A swap never waits for turns, never interrupts them, and never re-enters.
|
|
149
151
|
*/
|
|
152
|
+
/**
|
|
153
|
+
* The service keeps its transaction context in an AsyncLocalStorage on the instance. Resolve it by
|
|
154
|
+
* type as well as by name, so a renamed field still works; the name stays the fast path.
|
|
155
|
+
*/
|
|
156
|
+
function transactionContext(service) {
|
|
157
|
+
try {
|
|
158
|
+
if (typeof service?.executing?.exit === 'function') return service.executing
|
|
159
|
+
for (const value of Object.values(service ?? {})) if (value instanceof AsyncLocalStorage) return value
|
|
160
|
+
} catch {}
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
150
164
|
function pluginReloader(ctx, config) {
|
|
151
165
|
/** HMR can start after this half, so ask per use instead of once at load. */
|
|
152
166
|
const hmrService = () => {
|
|
@@ -161,6 +175,8 @@ function pluginReloader(ctx, config) {
|
|
|
161
175
|
|
|
162
176
|
let timer = null
|
|
163
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
|
|
164
180
|
let again = false
|
|
165
181
|
let deferrals = 0
|
|
166
182
|
let lastRun = 0
|
|
@@ -192,35 +208,64 @@ function pluginReloader(ctx, config) {
|
|
|
192
208
|
* that context first is what the service's own watcher does, and it keeps the request queued
|
|
193
209
|
* instead of running a second recomposition beside the first.
|
|
194
210
|
*/
|
|
211
|
+
const context = transactionContext(hmr)
|
|
195
212
|
const ask = (service) =>
|
|
196
|
-
|
|
197
|
-
? service.
|
|
198
|
-
: service.runExclusive(() => service.reconcileProfile([]))
|
|
213
|
+
context === null
|
|
214
|
+
? service.runExclusive(() => service.reconcileProfile([]))
|
|
215
|
+
: context.exit(() => service.runExclusive(() => service.reconcileProfile([])))
|
|
199
216
|
const abandoned = () =>
|
|
200
217
|
new Promise((_resolve, reject) => {
|
|
201
218
|
const limit = Number(process.env.DSH_UNLAG_TIMEOUT_MS ?? '') || RELOAD_TIMEOUT_MS
|
|
202
219
|
const timer = setTimeout(() => reject(new Error('the plugin update did not finish in time')), limit)
|
|
203
220
|
timer.unref?.()
|
|
204
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
|
+
}
|
|
205
234
|
let result = null
|
|
206
235
|
for (let attempt = 0; ; attempt += 1) {
|
|
207
236
|
try {
|
|
208
|
-
result = await Promise.race([ask(hmr), abandoned()])
|
|
237
|
+
result = await Promise.race([tracked(ask(hmr)), abandoned()])
|
|
209
238
|
break
|
|
210
239
|
} catch (error) {
|
|
211
240
|
const message = String(error?.message ?? error)
|
|
212
|
-
if (message.includes('nested')
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
249
|
+
throw error
|
|
250
|
+
}
|
|
251
|
+
throw error
|
|
215
252
|
}
|
|
216
253
|
}
|
|
217
254
|
const warnings = Array.isArray(result?.warnings) ? result.warnings : []
|
|
218
255
|
const pending = Array.isArray(result?.pending) ? result.pending : []
|
|
219
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))
|
|
220
265
|
log(
|
|
221
266
|
`plugins reloaded (warnings=${warnings.length} pending=${pending.length}${restartReason === null ? '' : ` restart=${restartReason}`})`,
|
|
222
267
|
)
|
|
223
|
-
return { status: 'done', warnings, pending, restartReason }
|
|
268
|
+
return { status: 'done', warnings, pending, restartReason, failedEntries }
|
|
224
269
|
}
|
|
225
270
|
|
|
226
271
|
const flush = async () => {
|
|
@@ -304,8 +349,12 @@ function pluginReloader(ctx, config) {
|
|
|
304
349
|
warnings: [],
|
|
305
350
|
pending: [],
|
|
306
351
|
restartReason: null,
|
|
352
|
+
failedEntries: [],
|
|
307
353
|
})
|
|
308
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
|
+
|
|
309
358
|
/**
|
|
310
359
|
* The button path: run now and report what happened, so the page can tell the user why the
|
|
311
360
|
* press did not replace anything instead of looking broken.
|
|
@@ -313,12 +362,12 @@ function pluginReloader(ctx, config) {
|
|
|
313
362
|
*/
|
|
314
363
|
const reloadNow = async (force = false) => {
|
|
315
364
|
const hmr = hmrService()
|
|
316
|
-
if (hmr === null) return { status: 'unavailable', warnings: [], pending: [], restartReason: null }
|
|
365
|
+
if (hmr === null) return { status: 'unavailable', warnings: [], pending: [], restartReason: null, failedEntries: [] }
|
|
317
366
|
if (turnsRunning()) log('reloading while turns are still running; they keep running')
|
|
318
|
-
if (running || (force === false && busy())) return
|
|
367
|
+
if (running || (force === false && busy())) return busyAnswer()
|
|
319
368
|
const wait = lastRun + MIN_INTERVAL_MS - Date.now()
|
|
320
369
|
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(wait, 2000)))
|
|
321
|
-
if (running || (force === false && busy())) return
|
|
370
|
+
if (running || (force === false && busy())) return busyAnswer()
|
|
322
371
|
if (timer !== null) {
|
|
323
372
|
clearTimeout(timer)
|
|
324
373
|
timer = null
|
|
@@ -328,18 +377,53 @@ function pluginReloader(ctx, config) {
|
|
|
328
377
|
lastRun = Date.now()
|
|
329
378
|
try {
|
|
330
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
|
+
*/
|
|
331
384
|
if (outcome.pending.length > 0 || outcome.restartReason !== null) {
|
|
332
385
|
// The app accepted the update but has not finished starting the new plugin set. Rebuilding
|
|
333
386
|
// the page now would start a document against a set that is still settling.
|
|
334
|
-
return {
|
|
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
|
+
}
|
|
335
403
|
}
|
|
336
404
|
return outcome
|
|
337
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
|
+
}
|
|
338
410
|
log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
|
|
339
|
-
return { status: 'failed', warnings: [], pending: [], restartReason: null }
|
|
411
|
+
return { status: 'failed', warnings: [], pending: [], restartReason: null, failedEntries: [] }
|
|
340
412
|
} finally {
|
|
341
|
-
|
|
342
|
-
|
|
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
|
+
}
|
|
343
427
|
}
|
|
344
428
|
}
|
|
345
429
|
|
|
@@ -398,10 +482,22 @@ function sendJson(res, status, payload) {
|
|
|
398
482
|
* leaves it naming a module graph that no longer resolves, which is exactly the list a rebuild
|
|
399
483
|
* must not trust.
|
|
400
484
|
*/
|
|
401
|
-
function liveBootGraph(
|
|
485
|
+
function liveBootGraph(ctx) {
|
|
402
486
|
try {
|
|
403
|
-
|
|
404
|
-
|
|
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({})
|
|
405
501
|
if (!Array.isArray(rows)) return null
|
|
406
502
|
for (const row of rows) {
|
|
407
503
|
if (row?.kind !== 'global') continue
|
|
@@ -501,8 +597,11 @@ async function fetchBundleAt(clientModules, url, method) {
|
|
|
501
597
|
async function currentBundleAnswer(clientModules, method, url) {
|
|
502
598
|
const request = bundleRequest(url)
|
|
503
599
|
if (request === null) return null
|
|
504
|
-
const
|
|
600
|
+
const graph = clientModules.graph()
|
|
601
|
+
const urls = currentBundleUrls(graph, request)
|
|
505
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
|
|
506
605
|
if (request.rev !== '' && urls.some((target) => target.endsWith(`&rev=${request.rev}`))) return null
|
|
507
606
|
for (const target of urls) {
|
|
508
607
|
const response = await fetchBundleAt(clientModules, target, 'GET')
|
|
@@ -541,6 +640,29 @@ async function serveCurrentBundle(clientModules, req, res) {
|
|
|
541
640
|
}
|
|
542
641
|
}
|
|
543
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
|
+
|
|
544
666
|
/** The same answer over the request-in/response-out route a newer app registers. */
|
|
545
667
|
async function serveCurrentBundleResource(clientModules, request) {
|
|
546
668
|
try {
|
|
@@ -580,13 +702,36 @@ function serveBundlesAtCurrentRevision(ctx) {
|
|
|
580
702
|
if (typeof route.resource === 'function') {
|
|
581
703
|
const original = route.resource
|
|
582
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
|
|
583
728
|
try {
|
|
584
729
|
const served = await serveCurrentBundleResource(clientModules, request)
|
|
585
730
|
if (served !== null) return served
|
|
586
731
|
} catch (error) {
|
|
587
732
|
log(`cannot answer a bundle request: ${error?.message ?? error}`)
|
|
588
733
|
}
|
|
589
|
-
return
|
|
734
|
+
return answer
|
|
590
735
|
}
|
|
591
736
|
route[SHIM_MARK] = true
|
|
592
737
|
route.resource = wrapped
|
|
@@ -595,14 +740,82 @@ function serveBundlesAtCurrentRevision(ctx) {
|
|
|
595
740
|
}
|
|
596
741
|
if (typeof route.handler !== 'function') return null
|
|
597
742
|
const original = route.handler
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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) {
|
|
601
809
|
log(`cannot answer a bundle request: ${error?.message ?? error}`)
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
|
606
819
|
}
|
|
607
820
|
route[SHIM_MARK] = true
|
|
608
821
|
route.handler = wrapped
|
|
@@ -677,7 +890,7 @@ function serveButton(ctx, controller, config) {
|
|
|
677
890
|
`${BASE_PATH}/graph`,
|
|
678
891
|
(_req, res) => {
|
|
679
892
|
ensureBundleShim(ctx)
|
|
680
|
-
sendJson(res, 200, { graph: liveBootGraph(
|
|
893
|
+
sendJson(res, 200, { graph: liveBootGraph(ctx) })
|
|
681
894
|
},
|
|
682
895
|
],
|
|
683
896
|
[
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-unlag",
|
|
3
|
-
"version": "1.
|
|
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",
|