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.
Files changed (3) hide show
  1. package/client.js +69 -1
  2. package/index.js +94 -7
  3. 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 (/(?:^|[;{}])\s*return\b/u.test(codeOnly(body))) return false;
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 { appendFileSync, existsSync, readFileSync, statSync, watch } from 'node:fs'
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
- return !IGNORED_PATH.test(name)
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 + MIN_INTERVAL_MS - Date.now()
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
- await reload(hmr)
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(), debounce)
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
- const body = Buffer.concat(parts)
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.1",
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",