dsh-unlag 1.8.3 → 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.
- package/README.md +2 -1
- package/client.js +62 -164
- package/index.js +241 -34
- 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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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
|
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'
|
|
|
3
3
|
import { appendFileSync, existsSync, readFileSync, readdirSync, statSync, watch } from 'node:fs'
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
import { join, resolve } from 'node:path'
|
|
6
|
+
import { Script } from 'node:vm'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* One header button, three ways new code reaches the running app:
|
|
@@ -39,6 +40,8 @@ const logTarget = () => process.env.DSH_UNLAG_LOG ?? LOG_PATH
|
|
|
39
40
|
const WATCHER_KEY = Symbol.for('dsh-unlag.watchers')
|
|
40
41
|
/** The same, for the HTTP entry points the header button calls. */
|
|
41
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')
|
|
42
45
|
const BASE_PATH = '/dsh-unlag'
|
|
43
46
|
/** A same-origin page sets this itself; a cross-site form post cannot, and fails the preflight. */
|
|
44
47
|
const BUTTON_HEADER = 'x-dsh-unlag'
|
|
@@ -227,8 +230,13 @@ function pluginReloader(ctx, config) {
|
|
|
227
230
|
|
|
228
231
|
let timer = null
|
|
229
232
|
let running = false
|
|
230
|
-
/**
|
|
231
|
-
|
|
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 })
|
|
232
240
|
let again = false
|
|
233
241
|
let deferrals = 0
|
|
234
242
|
let lastRun = 0
|
|
@@ -284,9 +292,9 @@ function pluginReloader(ctx, config) {
|
|
|
284
292
|
* so a timeout is reported as still applying, never as a failed update that later fires anyway.
|
|
285
293
|
*/
|
|
286
294
|
const tracked = (request) => {
|
|
287
|
-
|
|
295
|
+
workState.current = request
|
|
288
296
|
const settle = () => {
|
|
289
|
-
if (
|
|
297
|
+
if (workState.current === request) workState.current = null
|
|
290
298
|
}
|
|
291
299
|
request.then(settle, settle)
|
|
292
300
|
return request
|
|
@@ -305,7 +313,7 @@ function pluginReloader(ctx, config) {
|
|
|
305
313
|
continue
|
|
306
314
|
}
|
|
307
315
|
if (message.includes('did not finish in time')) {
|
|
308
|
-
error.stillRunning =
|
|
316
|
+
error.stillRunning = workState.current !== null
|
|
309
317
|
throw error
|
|
310
318
|
}
|
|
311
319
|
throw error
|
|
@@ -315,13 +323,13 @@ function pluginReloader(ctx, config) {
|
|
|
315
323
|
const pending = Array.isArray(result?.pending) ? result.pending : []
|
|
316
324
|
const restartReason = typeof result?.restartReason === 'string' ? result.restartReason : null
|
|
317
325
|
/**
|
|
318
|
-
* A reconciliation that returns without throwing can still have left
|
|
319
|
-
*
|
|
320
|
-
*
|
|
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.
|
|
321
331
|
*/
|
|
322
|
-
const failedEntries = warnings
|
|
323
|
-
.map((warning) => (typeof warning === 'string' ? warning : String(warning?.message ?? '')))
|
|
324
|
-
.filter((warning) => /fail|cannot|missing|invalid|error/iu.test(warning))
|
|
332
|
+
const failedEntries = warnings.map((warning) => (typeof warning === 'string' ? warning : String(warning?.message ?? warning)))
|
|
325
333
|
log(
|
|
326
334
|
`plugins reloaded (warnings=${warnings.length} pending=${pending.length}${restartReason === null ? '' : ` restart=${restartReason}`})`,
|
|
327
335
|
)
|
|
@@ -330,7 +338,9 @@ function pluginReloader(ctx, config) {
|
|
|
330
338
|
|
|
331
339
|
const flush = async () => {
|
|
332
340
|
timer = null
|
|
333
|
-
|
|
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) {
|
|
334
344
|
again = true
|
|
335
345
|
return
|
|
336
346
|
}
|
|
@@ -372,7 +382,19 @@ function pluginReloader(ctx, config) {
|
|
|
372
382
|
await reload(hmr)
|
|
373
383
|
if (stamp !== null) lastStamp = stamp
|
|
374
384
|
} finally {
|
|
375
|
-
|
|
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
|
+
}
|
|
376
398
|
}
|
|
377
399
|
} catch (error) {
|
|
378
400
|
log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
|
|
@@ -432,7 +454,7 @@ function pluginReloader(ctx, config) {
|
|
|
432
454
|
})
|
|
433
455
|
|
|
434
456
|
/** A press made while an update is still running joins that update instead of queueing another. */
|
|
435
|
-
const busyAnswer = () => (
|
|
457
|
+
const busyAnswer = () => (workState.current !== null ? { status: 'applying', warnings: [], pending: [], restartReason: null, failedEntries: [] } : queuedAnswer(queueReload()))
|
|
436
458
|
|
|
437
459
|
/**
|
|
438
460
|
* The button path: run now and report what happened, so the page can tell the user why the
|
|
@@ -443,10 +465,10 @@ function pluginReloader(ctx, config) {
|
|
|
443
465
|
const hmr = hmrService()
|
|
444
466
|
if (hmr === null) return { status: 'unavailable', warnings: [], pending: [], restartReason: null, failedEntries: [] }
|
|
445
467
|
if (turnsRunning()) log('reloading while turns are still running; they keep running')
|
|
446
|
-
if (running || (force === false && busy())) return busyAnswer()
|
|
468
|
+
if (running || workState.current !== null || (force === false && busy())) return busyAnswer()
|
|
447
469
|
const wait = lastRun + MIN_INTERVAL_MS - Date.now()
|
|
448
470
|
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(wait, 2000)))
|
|
449
|
-
if (running || (force === false && busy())) return busyAnswer()
|
|
471
|
+
if (running || workState.current !== null || (force === false && busy())) return busyAnswer()
|
|
450
472
|
if (timer !== null) {
|
|
451
473
|
clearTimeout(timer)
|
|
452
474
|
timer = null
|
|
@@ -489,7 +511,7 @@ function pluginReloader(ctx, config) {
|
|
|
489
511
|
log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
|
|
490
512
|
return { status: 'failed', warnings: [], pending: [], restartReason: null, failedEntries: [] }
|
|
491
513
|
} finally {
|
|
492
|
-
if (
|
|
514
|
+
if (workState.current === null) {
|
|
493
515
|
running = false
|
|
494
516
|
again = false
|
|
495
517
|
} else {
|
|
@@ -501,7 +523,7 @@ function pluginReloader(ctx, config) {
|
|
|
501
523
|
if (timer === null) timer = setTimeout(() => void flush(), debounce)
|
|
502
524
|
}
|
|
503
525
|
}
|
|
504
|
-
|
|
526
|
+
workState.current.then(release, release)
|
|
505
527
|
}
|
|
506
528
|
}
|
|
507
529
|
}
|
|
@@ -557,6 +579,31 @@ function sendJson(res, status, payload) {
|
|
|
557
579
|
} catch {}
|
|
558
580
|
}
|
|
559
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
|
+
|
|
560
607
|
/**
|
|
561
608
|
* The startup list the server would hand the next document, read from the live index injection
|
|
562
609
|
* table. The page's own copy describes the page already running: a plugin swapped since then
|
|
@@ -764,27 +811,38 @@ async function serveCurrentBundle(clientModules, req, res) {
|
|
|
764
811
|
}
|
|
765
812
|
}
|
|
766
813
|
|
|
767
|
-
/** The revision the
|
|
768
|
-
function
|
|
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 = ''
|
|
769
817
|
try {
|
|
770
|
-
|
|
771
|
-
return typeof graph?.rev === 'string' && graph.rev !== '' ? graph.rev : null
|
|
818
|
+
path = new URL(String(rawUrl ?? ''), 'http://unlag.invalid').pathname
|
|
772
819
|
} catch {
|
|
773
820
|
return null
|
|
774
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
|
|
775
829
|
}
|
|
776
830
|
|
|
777
831
|
/**
|
|
778
|
-
* Point a
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
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.
|
|
782
839
|
*/
|
|
783
|
-
function retargetRevision(url,
|
|
784
|
-
if (
|
|
840
|
+
function retargetRevision(url, revision) {
|
|
841
|
+
if (revision === null || typeof url !== 'string') return null
|
|
785
842
|
const match = /([?&])rev=([^&#]*)/u.exec(url)
|
|
786
|
-
if (match
|
|
787
|
-
return `${url.slice(0, match.index)}${match[1]}rev=${
|
|
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}`
|
|
788
846
|
}
|
|
789
847
|
|
|
790
848
|
/** The same answer over the request-in/response-out route a newer app registers. */
|
|
@@ -828,10 +886,11 @@ function serveBundlesAtCurrentRevision(ctx) {
|
|
|
828
886
|
const wrapped = async (request) => {
|
|
829
887
|
/**
|
|
830
888
|
* The table's own answer comes first: it still holds the document generation this page booted
|
|
831
|
-
* from. Only when it has nothing is the
|
|
832
|
-
*
|
|
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.
|
|
833
891
|
*/
|
|
834
|
-
const
|
|
892
|
+
const url = String(request?.url ?? '')
|
|
893
|
+
const retargeted = retargetRevision(url, entryRevisionFor(clientModules.graph(), url))
|
|
835
894
|
const method = String(request?.method ?? 'GET').toUpperCase()
|
|
836
895
|
let answer = null
|
|
837
896
|
try {
|
|
@@ -871,7 +930,7 @@ function serveBundlesAtCurrentRevision(ctx) {
|
|
|
871
930
|
*/
|
|
872
931
|
const wrapped = async (req, res) => {
|
|
873
932
|
const originalUrl = String(req?.url ?? '')
|
|
874
|
-
const retargeted = retargetRevision(originalUrl,
|
|
933
|
+
const retargeted = retargetRevision(originalUrl, entryRevisionFor(clientModules.graph(), originalUrl))
|
|
875
934
|
const realWriteHead = res.writeHead
|
|
876
935
|
const realEnd = res.end
|
|
877
936
|
const ask = async (url) => {
|
|
@@ -990,6 +1049,133 @@ function bundleShimStatus(ctx) {
|
|
|
990
1049
|
}
|
|
991
1050
|
}
|
|
992
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
|
+
|
|
993
1179
|
/**
|
|
994
1180
|
* The entry points the header button calls. Each answers with the current switches so the page
|
|
995
1181
|
* never guesses: with both off it does nothing, and a turn still running is reported rather than
|
|
@@ -1017,6 +1203,27 @@ function serveButton(ctx, controller, config) {
|
|
|
1017
1203
|
sendJson(res, 200, { graph: liveBootGraph(ctx) })
|
|
1018
1204
|
},
|
|
1019
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
|
+
],
|
|
1020
1227
|
[
|
|
1021
1228
|
`${BASE_PATH}/report`,
|
|
1022
1229
|
(req, res) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-unlag",
|
|
3
|
-
"version": "1.8.
|
|
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",
|