dsh-unlag 1.8.2 → 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 +327 -39
- 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
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { appendFileSync, existsSync, readFileSync, readdirSync, statSync, watch } from 'node:fs'
|
|
3
4
|
import { homedir } from 'node:os'
|
|
4
5
|
import { join, resolve } from 'node:path'
|
|
6
|
+
import { Script } from 'node:vm'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* One header button, three ways new code reaches the running app:
|
|
@@ -38,12 +40,19 @@ const logTarget = () => process.env.DSH_UNLAG_LOG ?? LOG_PATH
|
|
|
38
40
|
const WATCHER_KEY = Symbol.for('dsh-unlag.watchers')
|
|
39
41
|
/** The same, for the HTTP entry points the header button calls. */
|
|
40
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')
|
|
41
45
|
const BASE_PATH = '/dsh-unlag'
|
|
42
46
|
/** A same-origin page sets this itself; a cross-site form post cannot, and fails the preflight. */
|
|
43
47
|
const BUTTON_HEADER = 'x-dsh-unlag'
|
|
44
48
|
const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs'])
|
|
45
49
|
/** Build output and dependency trees change without the source a developer edits changing. */
|
|
46
50
|
const IGNORED_PATH = /(^|\/)(node_modules|\.git|\.cache|dist|build|coverage)([./]|$)/u
|
|
51
|
+
/**
|
|
52
|
+
* A test edit does not change what a running plugin does, so it is not a reason to restart the
|
|
53
|
+
* plugin set. A development session runs its tests far more often than it changes source.
|
|
54
|
+
*/
|
|
55
|
+
const TEST_PATH = /(^|\/)(test|tests|__tests__|__mocks__)(\/|$)|[.-](test|spec)\.[cm]?js$/u
|
|
47
56
|
const DEFAULT_ROOTS = [join(homedir(), '.dsh', 'plugins')]
|
|
48
57
|
/** A turn that never goes idle must not turn this into an endless poll. */
|
|
49
58
|
const MAX_DEFERRALS = 40
|
|
@@ -119,6 +128,40 @@ function closeAll(closer) {
|
|
|
119
128
|
}
|
|
120
129
|
}
|
|
121
130
|
|
|
131
|
+
/**
|
|
132
|
+
* One line for the source a reload would act on: the same files, the same sizes, the same times.
|
|
133
|
+
* A watch event that arrives without any of that changing (a log beside a plugin, a save that
|
|
134
|
+
* wrote back the same bytes, a burst of events for one edit) is not a reason to swap anything,
|
|
135
|
+
* and swapping anyway is what put eleven applies next to a running turn.
|
|
136
|
+
*/
|
|
137
|
+
function fingerprintOf(roots) {
|
|
138
|
+
const rows = []
|
|
139
|
+
const walk = (dir, depth) => {
|
|
140
|
+
if (depth > 6) return
|
|
141
|
+
let names
|
|
142
|
+
try {
|
|
143
|
+
names = readdirSync(dir, { withFileTypes: true })
|
|
144
|
+
} catch {
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
for (const item of names) {
|
|
148
|
+
const path = join(dir, item.name)
|
|
149
|
+
if (item.isDirectory()) {
|
|
150
|
+
walk(path, depth + 1)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if (item.isFile() === false || !worthReloading(path)) continue
|
|
154
|
+
try {
|
|
155
|
+
const stat = statSync(path)
|
|
156
|
+
rows.push(`${path}\u0000${stat.size}\u0000${stat.mtimeMs}`)
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const root of roots) walk(root, 0)
|
|
161
|
+
rows.sort()
|
|
162
|
+
return createHash('sha1').update(rows.join('\u0001')).digest('hex')
|
|
163
|
+
}
|
|
164
|
+
|
|
122
165
|
/** Development roots the profile links by path; a packaged dependency is not watched. */
|
|
123
166
|
function linkedRoots(baseDir) {
|
|
124
167
|
const roots = []
|
|
@@ -142,7 +185,8 @@ function worthReloading(filename) {
|
|
|
142
185
|
const name = typeof filename === 'string' ? filename : ''
|
|
143
186
|
if (name === '') return false
|
|
144
187
|
if (!CODE_EXTENSIONS.has(name.slice(name.lastIndexOf('.')))) return false
|
|
145
|
-
|
|
188
|
+
if (IGNORED_PATH.test(name) || TEST_PATH.test(name)) return false
|
|
189
|
+
return true
|
|
146
190
|
}
|
|
147
191
|
|
|
148
192
|
/**
|
|
@@ -172,14 +216,38 @@ function pluginReloader(ctx, config) {
|
|
|
172
216
|
/** Opt-in: wait for a quiet moment instead of swapping at once. Off by default. */
|
|
173
217
|
const idleOnly = config?.idleOnly === true
|
|
174
218
|
const debounce = Number.isFinite(config?.debounce) ? config.debounce : 300
|
|
219
|
+
/**
|
|
220
|
+
* How long a saved file waits for the rest of its burst. Editors write a file more than once and
|
|
221
|
+
* an agent saves a set of files in a row; a swap per write restarts the plugin set under the
|
|
222
|
+
* user's feet, so the watcher waits for the writes to stop instead of racing them. A press is
|
|
223
|
+
* never delayed by this.
|
|
224
|
+
*/
|
|
225
|
+
/** How close together two applies may land; one apply restarts the whole plugin set. */
|
|
226
|
+
const minInterval = Number.isFinite(config?.minIntervalMs) ? Math.max(0, config.minIntervalMs) : MIN_INTERVAL_MS
|
|
227
|
+
const settle = Number.isFinite(config?.settleMs)
|
|
228
|
+
? Math.max(0, config.settleMs)
|
|
229
|
+
: Math.max(debounce, Math.min(1200, debounce * 4))
|
|
175
230
|
|
|
176
231
|
let timer = null
|
|
177
232
|
let running = false
|
|
178
|
-
/**
|
|
179
|
-
|
|
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 })
|
|
180
240
|
let again = false
|
|
181
241
|
let deferrals = 0
|
|
182
242
|
let lastRun = 0
|
|
243
|
+
/** The source the last successful reload applied, so an unchanged save costs nothing. */
|
|
244
|
+
let lastStamp = null
|
|
245
|
+
/** The roots the watcher covers; empty when watching never started. */
|
|
246
|
+
let watchRoots = []
|
|
247
|
+
/** Whether the pending flush came from a saved file rather than from the button. */
|
|
248
|
+
let fromWatch = false
|
|
249
|
+
/** The file named by the newest watch event, so a reload can say what caused it. */
|
|
250
|
+
let lastChange = ''
|
|
183
251
|
|
|
184
252
|
const turnsRunning = () => {
|
|
185
253
|
try {
|
|
@@ -224,9 +292,9 @@ function pluginReloader(ctx, config) {
|
|
|
224
292
|
* so a timeout is reported as still applying, never as a failed update that later fires anyway.
|
|
225
293
|
*/
|
|
226
294
|
const tracked = (request) => {
|
|
227
|
-
|
|
295
|
+
workState.current = request
|
|
228
296
|
const settle = () => {
|
|
229
|
-
if (
|
|
297
|
+
if (workState.current === request) workState.current = null
|
|
230
298
|
}
|
|
231
299
|
request.then(settle, settle)
|
|
232
300
|
return request
|
|
@@ -245,7 +313,7 @@ function pluginReloader(ctx, config) {
|
|
|
245
313
|
continue
|
|
246
314
|
}
|
|
247
315
|
if (message.includes('did not finish in time')) {
|
|
248
|
-
error.stillRunning =
|
|
316
|
+
error.stillRunning = workState.current !== null
|
|
249
317
|
throw error
|
|
250
318
|
}
|
|
251
319
|
throw error
|
|
@@ -255,13 +323,13 @@ function pluginReloader(ctx, config) {
|
|
|
255
323
|
const pending = Array.isArray(result?.pending) ? result.pending : []
|
|
256
324
|
const restartReason = typeof result?.restartReason === 'string' ? result.restartReason : null
|
|
257
325
|
/**
|
|
258
|
-
* A reconciliation that returns without throwing can still have left
|
|
259
|
-
*
|
|
260
|
-
*
|
|
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.
|
|
261
331
|
*/
|
|
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))
|
|
332
|
+
const failedEntries = warnings.map((warning) => (typeof warning === 'string' ? warning : String(warning?.message ?? warning)))
|
|
265
333
|
log(
|
|
266
334
|
`plugins reloaded (warnings=${warnings.length} pending=${pending.length}${restartReason === null ? '' : ` restart=${restartReason}`})`,
|
|
267
335
|
)
|
|
@@ -270,7 +338,9 @@ function pluginReloader(ctx, config) {
|
|
|
270
338
|
|
|
271
339
|
const flush = async () => {
|
|
272
340
|
timer = null
|
|
273
|
-
|
|
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) {
|
|
274
344
|
again = true
|
|
275
345
|
return
|
|
276
346
|
}
|
|
@@ -285,21 +355,50 @@ function pluginReloader(ctx, config) {
|
|
|
285
355
|
return
|
|
286
356
|
}
|
|
287
357
|
}
|
|
288
|
-
const wait = lastRun +
|
|
358
|
+
const wait = lastRun + minInterval - Date.now()
|
|
289
359
|
if (wait > 0) {
|
|
290
360
|
timer = setTimeout(() => void flush(), wait)
|
|
291
361
|
return
|
|
292
362
|
}
|
|
293
363
|
const hmr = hmrService()
|
|
294
364
|
if (hmr === null) return
|
|
365
|
+
const watched = fromWatch
|
|
366
|
+
fromWatch = false
|
|
367
|
+
/**
|
|
368
|
+
* A saved file that did not change anything a reload would apply is not applied again: the
|
|
369
|
+
* watcher reports more events than edits (one save can deliver several), and each apply
|
|
370
|
+
* restarts the whole plugin set next to whatever the user is doing.
|
|
371
|
+
*/
|
|
372
|
+
const stamp = watchRoots.length === 0 ? null : fingerprintOf(watchRoots)
|
|
373
|
+
if (watched && stamp !== null && stamp === lastStamp) {
|
|
374
|
+
log(`a change to ${lastChange || 'a watched file'} left the plugin set as it is; nothing applied`)
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
if (watched && lastChange !== '') log(`reloading after a change to ${lastChange}`)
|
|
295
378
|
deferrals = 0
|
|
296
379
|
lastRun = Date.now()
|
|
297
380
|
running = true
|
|
298
|
-
|
|
381
|
+
try {
|
|
382
|
+
await reload(hmr)
|
|
383
|
+
if (stamp !== null) lastStamp = stamp
|
|
384
|
+
} finally {
|
|
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
|
+
}
|
|
398
|
+
}
|
|
299
399
|
} catch (error) {
|
|
300
400
|
log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
|
|
301
401
|
} finally {
|
|
302
|
-
running = false
|
|
303
402
|
if (again) {
|
|
304
403
|
again = false
|
|
305
404
|
if (timer === null) timer = setTimeout(() => void flush(), debounce)
|
|
@@ -310,8 +409,10 @@ function pluginReloader(ctx, config) {
|
|
|
310
409
|
const schedule = (filename) => {
|
|
311
410
|
try {
|
|
312
411
|
if (!worthReloading(filename)) return
|
|
412
|
+
fromWatch = true
|
|
413
|
+
lastChange = String(filename).split('/').pop() ?? ''
|
|
313
414
|
if (timer !== null) clearTimeout(timer)
|
|
314
|
-
timer = setTimeout(() => void flush(),
|
|
415
|
+
timer = setTimeout(() => void flush(), settle)
|
|
315
416
|
} catch (error) {
|
|
316
417
|
log(`ignored a watch event: ${error?.message ?? error}`)
|
|
317
418
|
}
|
|
@@ -353,7 +454,7 @@ function pluginReloader(ctx, config) {
|
|
|
353
454
|
})
|
|
354
455
|
|
|
355
456
|
/** A press made while an update is still running joins that update instead of queueing another. */
|
|
356
|
-
const busyAnswer = () => (
|
|
457
|
+
const busyAnswer = () => (workState.current !== null ? { status: 'applying', warnings: [], pending: [], restartReason: null, failedEntries: [] } : queuedAnswer(queueReload()))
|
|
357
458
|
|
|
358
459
|
/**
|
|
359
460
|
* The button path: run now and report what happened, so the page can tell the user why the
|
|
@@ -364,10 +465,10 @@ function pluginReloader(ctx, config) {
|
|
|
364
465
|
const hmr = hmrService()
|
|
365
466
|
if (hmr === null) return { status: 'unavailable', warnings: [], pending: [], restartReason: null, failedEntries: [] }
|
|
366
467
|
if (turnsRunning()) log('reloading while turns are still running; they keep running')
|
|
367
|
-
if (running || (force === false && busy())) return busyAnswer()
|
|
468
|
+
if (running || workState.current !== null || (force === false && busy())) return busyAnswer()
|
|
368
469
|
const wait = lastRun + MIN_INTERVAL_MS - Date.now()
|
|
369
470
|
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(wait, 2000)))
|
|
370
|
-
if (running || (force === false && busy())) return busyAnswer()
|
|
471
|
+
if (running || workState.current !== null || (force === false && busy())) return busyAnswer()
|
|
371
472
|
if (timer !== null) {
|
|
372
473
|
clearTimeout(timer)
|
|
373
474
|
timer = null
|
|
@@ -410,7 +511,7 @@ function pluginReloader(ctx, config) {
|
|
|
410
511
|
log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
|
|
411
512
|
return { status: 'failed', warnings: [], pending: [], restartReason: null, failedEntries: [] }
|
|
412
513
|
} finally {
|
|
413
|
-
if (
|
|
514
|
+
if (workState.current === null) {
|
|
414
515
|
running = false
|
|
415
516
|
again = false
|
|
416
517
|
} else {
|
|
@@ -422,7 +523,7 @@ function pluginReloader(ctx, config) {
|
|
|
422
523
|
if (timer === null) timer = setTimeout(() => void flush(), debounce)
|
|
423
524
|
}
|
|
424
525
|
}
|
|
425
|
-
|
|
526
|
+
workState.current.then(release, release)
|
|
426
527
|
}
|
|
427
528
|
}
|
|
428
529
|
}
|
|
@@ -433,6 +534,8 @@ function pluginReloader(ctx, config) {
|
|
|
433
534
|
if (hmr === null) return null
|
|
434
535
|
const configured = Array.isArray(config?.roots) ? config.roots.filter((root) => typeof root === 'string') : []
|
|
435
536
|
const roots = [...new Set([...configured, ...linkedRoots(hmr.baseDir ?? process.cwd()), ...DEFAULT_ROOTS])]
|
|
537
|
+
watchRoots = roots
|
|
538
|
+
lastStamp = fingerprintOf(roots)
|
|
436
539
|
const closers = []
|
|
437
540
|
let watching = 0
|
|
438
541
|
for (const root of roots) {
|
|
@@ -476,6 +579,31 @@ function sendJson(res, status, payload) {
|
|
|
476
579
|
} catch {}
|
|
477
580
|
}
|
|
478
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
|
+
|
|
479
607
|
/**
|
|
480
608
|
* The startup list the server would hand the next document, read from the live index injection
|
|
481
609
|
* table. The page's own copy describes the page already running: a plugin swapped since then
|
|
@@ -683,27 +811,38 @@ async function serveCurrentBundle(clientModules, req, res) {
|
|
|
683
811
|
}
|
|
684
812
|
}
|
|
685
813
|
|
|
686
|
-
/** The revision the
|
|
687
|
-
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 = ''
|
|
688
817
|
try {
|
|
689
|
-
|
|
690
|
-
return typeof graph?.rev === 'string' && graph.rev !== '' ? graph.rev : null
|
|
818
|
+
path = new URL(String(rawUrl ?? ''), 'http://unlag.invalid').pathname
|
|
691
819
|
} catch {
|
|
692
820
|
return null
|
|
693
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
|
|
694
829
|
}
|
|
695
830
|
|
|
696
831
|
/**
|
|
697
|
-
* Point a
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
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.
|
|
701
839
|
*/
|
|
702
|
-
function retargetRevision(url,
|
|
703
|
-
if (
|
|
840
|
+
function retargetRevision(url, revision) {
|
|
841
|
+
if (revision === null || typeof url !== 'string') return null
|
|
704
842
|
const match = /([?&])rev=([^&#]*)/u.exec(url)
|
|
705
|
-
if (match
|
|
706
|
-
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}`
|
|
707
846
|
}
|
|
708
847
|
|
|
709
848
|
/** The same answer over the request-in/response-out route a newer app registers. */
|
|
@@ -747,10 +886,11 @@ function serveBundlesAtCurrentRevision(ctx) {
|
|
|
747
886
|
const wrapped = async (request) => {
|
|
748
887
|
/**
|
|
749
888
|
* The table's own answer comes first: it still holds the document generation this page booted
|
|
750
|
-
* from. Only when it has nothing is the
|
|
751
|
-
*
|
|
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.
|
|
752
891
|
*/
|
|
753
|
-
const
|
|
892
|
+
const url = String(request?.url ?? '')
|
|
893
|
+
const retargeted = retargetRevision(url, entryRevisionFor(clientModules.graph(), url))
|
|
754
894
|
const method = String(request?.method ?? 'GET').toUpperCase()
|
|
755
895
|
let answer = null
|
|
756
896
|
try {
|
|
@@ -790,7 +930,7 @@ function serveBundlesAtCurrentRevision(ctx) {
|
|
|
790
930
|
*/
|
|
791
931
|
const wrapped = async (req, res) => {
|
|
792
932
|
const originalUrl = String(req?.url ?? '')
|
|
793
|
-
const retargeted = retargetRevision(originalUrl,
|
|
933
|
+
const retargeted = retargetRevision(originalUrl, entryRevisionFor(clientModules.graph(), originalUrl))
|
|
794
934
|
const realWriteHead = res.writeHead
|
|
795
935
|
const realEnd = res.end
|
|
796
936
|
const ask = async (url) => {
|
|
@@ -909,6 +1049,133 @@ function bundleShimStatus(ctx) {
|
|
|
909
1049
|
}
|
|
910
1050
|
}
|
|
911
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
|
+
|
|
912
1179
|
/**
|
|
913
1180
|
* The entry points the header button calls. Each answers with the current switches so the page
|
|
914
1181
|
* never guesses: with both off it does nothing, and a turn still running is reported rather than
|
|
@@ -936,6 +1203,27 @@ function serveButton(ctx, controller, config) {
|
|
|
936
1203
|
sendJson(res, 200, { graph: liveBootGraph(ctx) })
|
|
937
1204
|
},
|
|
938
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
|
+
],
|
|
939
1227
|
[
|
|
940
1228
|
`${BASE_PATH}/report`,
|
|
941
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",
|