dsh-unlag 1.8.2 → 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/index.js +87 -6
- package/package.json +1 -1
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) {
|
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",
|