dsh-unlag 1.8.1 → 1.8.3
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/client.js +69 -1
- package/index.js +94 -7
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -212,10 +212,78 @@ window.__ModuleLoader__.load({
|
|
|
212
212
|
return out;
|
|
213
213
|
}
|
|
214
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
|
+
|
|
215
283
|
function parsesAsClassicScript(body) {
|
|
216
284
|
try {
|
|
217
285
|
// A top-level return parses in a function body but not as a classic script.
|
|
218
|
-
if (
|
|
286
|
+
if (hasTopLevelReturn(body)) return false;
|
|
219
287
|
new Function(body); // eslint-disable-line no-new-func
|
|
220
288
|
return true;
|
|
221
289
|
} catch (error) {
|
package/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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'
|
|
5
6
|
|
|
@@ -44,6 +45,11 @@ const BUTTON_HEADER = 'x-dsh-unlag'
|
|
|
44
45
|
const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs'])
|
|
45
46
|
/** Build output and dependency trees change without the source a developer edits changing. */
|
|
46
47
|
const IGNORED_PATH = /(^|\/)(node_modules|\.git|\.cache|dist|build|coverage)([./]|$)/u
|
|
48
|
+
/**
|
|
49
|
+
* A test edit does not change what a running plugin does, so it is not a reason to restart the
|
|
50
|
+
* plugin set. A development session runs its tests far more often than it changes source.
|
|
51
|
+
*/
|
|
52
|
+
const TEST_PATH = /(^|\/)(test|tests|__tests__|__mocks__)(\/|$)|[.-](test|spec)\.[cm]?js$/u
|
|
47
53
|
const DEFAULT_ROOTS = [join(homedir(), '.dsh', 'plugins')]
|
|
48
54
|
/** A turn that never goes idle must not turn this into an endless poll. */
|
|
49
55
|
const MAX_DEFERRALS = 40
|
|
@@ -119,6 +125,40 @@ function closeAll(closer) {
|
|
|
119
125
|
}
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
/**
|
|
129
|
+
* One line for the source a reload would act on: the same files, the same sizes, the same times.
|
|
130
|
+
* A watch event that arrives without any of that changing (a log beside a plugin, a save that
|
|
131
|
+
* wrote back the same bytes, a burst of events for one edit) is not a reason to swap anything,
|
|
132
|
+
* and swapping anyway is what put eleven applies next to a running turn.
|
|
133
|
+
*/
|
|
134
|
+
function fingerprintOf(roots) {
|
|
135
|
+
const rows = []
|
|
136
|
+
const walk = (dir, depth) => {
|
|
137
|
+
if (depth > 6) return
|
|
138
|
+
let names
|
|
139
|
+
try {
|
|
140
|
+
names = readdirSync(dir, { withFileTypes: true })
|
|
141
|
+
} catch {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
for (const item of names) {
|
|
145
|
+
const path = join(dir, item.name)
|
|
146
|
+
if (item.isDirectory()) {
|
|
147
|
+
walk(path, depth + 1)
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (item.isFile() === false || !worthReloading(path)) continue
|
|
151
|
+
try {
|
|
152
|
+
const stat = statSync(path)
|
|
153
|
+
rows.push(`${path}\u0000${stat.size}\u0000${stat.mtimeMs}`)
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const root of roots) walk(root, 0)
|
|
158
|
+
rows.sort()
|
|
159
|
+
return createHash('sha1').update(rows.join('\u0001')).digest('hex')
|
|
160
|
+
}
|
|
161
|
+
|
|
122
162
|
/** Development roots the profile links by path; a packaged dependency is not watched. */
|
|
123
163
|
function linkedRoots(baseDir) {
|
|
124
164
|
const roots = []
|
|
@@ -142,7 +182,8 @@ function worthReloading(filename) {
|
|
|
142
182
|
const name = typeof filename === 'string' ? filename : ''
|
|
143
183
|
if (name === '') return false
|
|
144
184
|
if (!CODE_EXTENSIONS.has(name.slice(name.lastIndexOf('.')))) return false
|
|
145
|
-
|
|
185
|
+
if (IGNORED_PATH.test(name) || TEST_PATH.test(name)) return false
|
|
186
|
+
return true
|
|
146
187
|
}
|
|
147
188
|
|
|
148
189
|
/**
|
|
@@ -172,6 +213,17 @@ function pluginReloader(ctx, config) {
|
|
|
172
213
|
/** Opt-in: wait for a quiet moment instead of swapping at once. Off by default. */
|
|
173
214
|
const idleOnly = config?.idleOnly === true
|
|
174
215
|
const debounce = Number.isFinite(config?.debounce) ? config.debounce : 300
|
|
216
|
+
/**
|
|
217
|
+
* How long a saved file waits for the rest of its burst. Editors write a file more than once and
|
|
218
|
+
* an agent saves a set of files in a row; a swap per write restarts the plugin set under the
|
|
219
|
+
* user's feet, so the watcher waits for the writes to stop instead of racing them. A press is
|
|
220
|
+
* never delayed by this.
|
|
221
|
+
*/
|
|
222
|
+
/** How close together two applies may land; one apply restarts the whole plugin set. */
|
|
223
|
+
const minInterval = Number.isFinite(config?.minIntervalMs) ? Math.max(0, config.minIntervalMs) : MIN_INTERVAL_MS
|
|
224
|
+
const settle = Number.isFinite(config?.settleMs)
|
|
225
|
+
? Math.max(0, config.settleMs)
|
|
226
|
+
: Math.max(debounce, Math.min(1200, debounce * 4))
|
|
175
227
|
|
|
176
228
|
let timer = null
|
|
177
229
|
let running = false
|
|
@@ -180,6 +232,14 @@ function pluginReloader(ctx, config) {
|
|
|
180
232
|
let again = false
|
|
181
233
|
let deferrals = 0
|
|
182
234
|
let lastRun = 0
|
|
235
|
+
/** The source the last successful reload applied, so an unchanged save costs nothing. */
|
|
236
|
+
let lastStamp = null
|
|
237
|
+
/** The roots the watcher covers; empty when watching never started. */
|
|
238
|
+
let watchRoots = []
|
|
239
|
+
/** Whether the pending flush came from a saved file rather than from the button. */
|
|
240
|
+
let fromWatch = false
|
|
241
|
+
/** The file named by the newest watch event, so a reload can say what caused it. */
|
|
242
|
+
let lastChange = ''
|
|
183
243
|
|
|
184
244
|
const turnsRunning = () => {
|
|
185
245
|
try {
|
|
@@ -285,21 +345,38 @@ function pluginReloader(ctx, config) {
|
|
|
285
345
|
return
|
|
286
346
|
}
|
|
287
347
|
}
|
|
288
|
-
const wait = lastRun +
|
|
348
|
+
const wait = lastRun + minInterval - Date.now()
|
|
289
349
|
if (wait > 0) {
|
|
290
350
|
timer = setTimeout(() => void flush(), wait)
|
|
291
351
|
return
|
|
292
352
|
}
|
|
293
353
|
const hmr = hmrService()
|
|
294
354
|
if (hmr === null) return
|
|
355
|
+
const watched = fromWatch
|
|
356
|
+
fromWatch = false
|
|
357
|
+
/**
|
|
358
|
+
* A saved file that did not change anything a reload would apply is not applied again: the
|
|
359
|
+
* watcher reports more events than edits (one save can deliver several), and each apply
|
|
360
|
+
* restarts the whole plugin set next to whatever the user is doing.
|
|
361
|
+
*/
|
|
362
|
+
const stamp = watchRoots.length === 0 ? null : fingerprintOf(watchRoots)
|
|
363
|
+
if (watched && stamp !== null && stamp === lastStamp) {
|
|
364
|
+
log(`a change to ${lastChange || 'a watched file'} left the plugin set as it is; nothing applied`)
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
if (watched && lastChange !== '') log(`reloading after a change to ${lastChange}`)
|
|
295
368
|
deferrals = 0
|
|
296
369
|
lastRun = Date.now()
|
|
297
370
|
running = true
|
|
298
|
-
|
|
371
|
+
try {
|
|
372
|
+
await reload(hmr)
|
|
373
|
+
if (stamp !== null) lastStamp = stamp
|
|
374
|
+
} finally {
|
|
375
|
+
running = false
|
|
376
|
+
}
|
|
299
377
|
} catch (error) {
|
|
300
378
|
log(`reload did not finish; the plugin set may be partly applied: ${error?.message ?? error}`)
|
|
301
379
|
} finally {
|
|
302
|
-
running = false
|
|
303
380
|
if (again) {
|
|
304
381
|
again = false
|
|
305
382
|
if (timer === null) timer = setTimeout(() => void flush(), debounce)
|
|
@@ -310,8 +387,10 @@ function pluginReloader(ctx, config) {
|
|
|
310
387
|
const schedule = (filename) => {
|
|
311
388
|
try {
|
|
312
389
|
if (!worthReloading(filename)) return
|
|
390
|
+
fromWatch = true
|
|
391
|
+
lastChange = String(filename).split('/').pop() ?? ''
|
|
313
392
|
if (timer !== null) clearTimeout(timer)
|
|
314
|
-
timer = setTimeout(() => void flush(),
|
|
393
|
+
timer = setTimeout(() => void flush(), settle)
|
|
315
394
|
} catch (error) {
|
|
316
395
|
log(`ignored a watch event: ${error?.message ?? error}`)
|
|
317
396
|
}
|
|
@@ -433,6 +512,8 @@ function pluginReloader(ctx, config) {
|
|
|
433
512
|
if (hmr === null) return null
|
|
434
513
|
const configured = Array.isArray(config?.roots) ? config.roots.filter((root) => typeof root === 'string') : []
|
|
435
514
|
const roots = [...new Set([...configured, ...linkedRoots(hmr.baseDir ?? process.cwd()), ...DEFAULT_ROOTS])]
|
|
515
|
+
watchRoots = roots
|
|
516
|
+
lastStamp = fingerprintOf(roots)
|
|
436
517
|
const closers = []
|
|
437
518
|
let watching = 0
|
|
438
519
|
for (const root of roots) {
|
|
@@ -621,7 +702,13 @@ async function regroupedBundleAnswer(clientModules, graph, request) {
|
|
|
621
702
|
parts.push(body)
|
|
622
703
|
}
|
|
623
704
|
if (parts.length === 0) return null
|
|
624
|
-
|
|
705
|
+
/**
|
|
706
|
+
* Each entry body is a script of its own and need not end where another can begin: an entry
|
|
707
|
+
* that ends in `})` followed by one that starts with an identifier is a syntax error, and a
|
|
708
|
+
* document would run neither. A line holding just `;` between them keeps every body its own
|
|
709
|
+
* statement whatever the two sides end and start with, and leaves each body itself untouched.
|
|
710
|
+
*/
|
|
711
|
+
const body = Buffer.concat(parts.flatMap((part, index) => (index === parts.length - 1 ? [part] : [part, Buffer.from('\n;\n')])))
|
|
625
712
|
if (!request.ids.every((id) => body.includes(id))) return null
|
|
626
713
|
return { body, contentType: 'text/javascript; charset=utf-8', ids: request.ids }
|
|
627
714
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-unlag",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.3",
|
|
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",
|