@webjsdev/server 0.9.0 → 0.10.0

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 (101) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +427 -109
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +96 -14
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +262 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/dev/bun-app-source.js +95 -5
  33. package/src/dev/config.js +7 -51
  34. package/src/dev/dev-service-worker.js +48 -0
  35. package/src/dev/esm-hot-hooks.js +373 -0
  36. package/src/dev/handler.js +502 -155
  37. package/src/dev/helpers.js +127 -96
  38. package/src/dev/hot-host.js +33 -6
  39. package/src/dev/poke-state.js +110 -0
  40. package/src/dev/serve.js +177 -129
  41. package/src/dev/server.js +92 -33
  42. package/src/dev/specifier-versions.js +125 -0
  43. package/src/dev/watch-ignore.js +49 -4
  44. package/src/dev/watch-poll.js +78 -0
  45. package/src/dev/watch-tree.js +59 -22
  46. package/src/dev-classify.js +59 -26
  47. package/src/dev-error.js +104 -3
  48. package/src/dev-hot-classify.js +291 -0
  49. package/src/dev-hot-client.js +245 -0
  50. package/src/dev-import.js +22 -5
  51. package/src/dev-overlay.js +26 -0
  52. package/src/dev-ready-probe.js +69 -0
  53. package/src/dev-regenerate.js +2 -2
  54. package/src/dev-reload-hold.js +49 -20
  55. package/src/dev-reload-reason.js +61 -0
  56. package/src/dev-reload-worker.js +62 -13
  57. package/src/dev-secret.js +73 -0
  58. package/src/dev-source-locations-hook.js +4 -1
  59. package/src/dev-source-locations.js +33 -2
  60. package/src/dev.js +0 -1
  61. package/src/env-files.js +124 -0
  62. package/src/file-storage.js +2 -2
  63. package/src/foreign-imports.js +228 -0
  64. package/src/form-action-identity.js +52 -25
  65. package/src/form-dispatch.js +140 -65
  66. package/src/html-cache.js +35 -4
  67. package/src/importmap.js +2 -2
  68. package/src/instrumentation.js +59 -3
  69. package/src/js-scan.js +5 -4
  70. package/src/jwe.js +155 -0
  71. package/src/lazy-components.js +166 -0
  72. package/src/listener-core.js +4 -2
  73. package/src/lit-hints.js +172 -0
  74. package/src/middleware-load.js +248 -0
  75. package/src/module-graph.js +73 -47
  76. package/src/module-side-effect.js +284 -0
  77. package/src/public-files.js +73 -0
  78. package/src/redirect-location.js +19 -0
  79. package/src/redirects.js +9 -1
  80. package/src/route-frontier.js +151 -0
  81. package/src/route-report.js +130 -0
  82. package/src/router.js +120 -13
  83. package/src/session.js +32 -10
  84. package/src/ssr/client-router-flag.js +1 -1
  85. package/src/ssr/document.js +37 -13
  86. package/src/ssr/head.js +78 -23
  87. package/src/ssr/preloads.js +31 -45
  88. package/src/ssr/render.js +241 -55
  89. package/src/ssr/responses.js +51 -4
  90. package/src/ssr/shipped-modules.js +22 -16
  91. package/src/testing.d.ts +31 -2
  92. package/src/testing.js +80 -17
  93. package/src/thenable-params.js +26 -0
  94. package/src/vendor/jspm.js +12 -1
  95. package/src/vendor/pins.js +9 -8
  96. package/src/vendor/scanner.js +14 -9
  97. package/src/vendor.js +5 -3
  98. package/webjs-config.schema.json +1 -6
  99. package/src/component-elision.js +0 -1828
  100. package/src/elision-differential.js +0 -101
  101. package/src/elision-report.js +0 -193
package/src/dev/server.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { strongerVerdict } from '../dev-classify.js';
3
- import { createWatchIgnore } from './watch-ignore.js';
2
+ import { mergeVerdicts } from '../dev-classify.js';
3
+ import { createWatchIgnore, editorTempTarget } from './watch-ignore.js';
4
4
  import { watchTree } from './watch-tree.js';
5
+ import { createPokeState } from './poke-state.js';
5
6
  import { relative, resolve } from 'node:path';
6
7
  import {
7
8
  hotRerunCapable, hotHostKey, getHotHost, setHotHost, deleteHotHost,
8
- armHotSentinel, pokeHotSentinel, removeHotSentinel, needsHotReset, announceHotInPlace,
9
+ armHotSentinel, pokeHotSentinel, removeHotSentinel, needsHotReset, announceHotInPlace, requestSupervisorRestart,
9
10
  } from './hot-host.js';
10
11
  import { createServer as createHttp1Server } from 'node:http';
11
12
  import { createRequestHandler } from './handler.js';
@@ -33,6 +34,29 @@ const SERVER_VERSION = (() => {
33
34
  */
34
35
  const POKE_FALLBACK_MS = 1500;
35
36
 
37
+ /**
38
+ * The root files the `webjs dev` supervisor restarts this child for (#1689),
39
+ * as the pattern the supervisor hands over in `__WEBJS_DEV_RESTART_FILES`, or
40
+ * null when no supervisor will (`--no-hot`, an embedder, an older CLI). This
41
+ * watcher sees the same files, and a reload frame it sent for one would reload
42
+ * the tab a second time on top of the restart, which already reloads it.
43
+ * @returns {RegExp | null}
44
+ */
45
+ export function supervisorRestartFile(env = process.env) {
46
+ if (env.__WEBJS_DEV_CHILD !== '1' || !env.__WEBJS_DEV_RESTART_FILES) return null;
47
+ try { return new RegExp(env.__WEBJS_DEV_RESTART_FILES); } catch { return null; }
48
+ }
49
+
50
+ /**
51
+ * An internal test seam (like `__WEBJS_DEV_CHILD`): how long a `bun --hot`
52
+ * re-run waits before taking its batch, so `test/bun/dev-late-rerun.mjs` can
53
+ * land it after the poke fallback every time, the race #1689 fixed. Unset is 0.
54
+ */
55
+ function rerunDelayMs() {
56
+ const n = Number(process.env.__WEBJS_RERUN_DELAY_MS || 0);
57
+ return Number.isFinite(n) && n > 0 ? n : 0;
58
+ }
59
+
36
60
  function debounce(fn, ms) {
37
61
  let t;
38
62
  return (...args) => {
@@ -286,7 +310,9 @@ export async function startServer(opts) {
286
310
  // identically on both). Built before the handler so its onReload / onDevError
287
311
  // callbacks can fan out through it.
288
312
  // A Bun hot host, or a server outside the dev supervisor (`--no-hot`, an
289
- // embedder), applies an edit in place; a supervised Node child is restarted.
313
+ // embedder), applies an edit in place. A supervised Node child does too once
314
+ // its handler has the module resolve hook (#1689), which is set below; only a
315
+ // Node without `module.registerHooks` is still restarted.
290
316
  const hub = new SseHub({ inPlace: dev && (!!hotKey || process.env.__WEBJS_DEV_CHILD !== '1') });
291
317
  // Pages render the stream's state into a meta tag (#1516), so the relay can
292
318
  // re-check a page against the server on every reconnect.
@@ -306,13 +332,14 @@ export async function startServer(opts) {
306
332
  hub.closeAll();
307
333
  throw e;
308
334
  }
335
+ // Node re-evaluates edited app modules in place (#1689), so its frames say so
336
+ // and the relay waits the short in-place window rather than a restart's.
337
+ if (app.hotModules) hub.inPlace = true;
309
338
 
310
339
  /** @type {AbortController | null} */
311
340
  let watcherAbort = null;
312
- /** The strongest verdict of the edits a poked re-run has yet to apply. */
313
- /** @type {import('../dev-classify.js').ReloadVerdict | null} */
314
- let pokedVerdict = null;
315
- /** @type {any} */ let pokeFallback = null;
341
+ /** The edits a poked re-run has yet to apply, and the fallback's late re-run (#1689). */
342
+ const poked = createPokeState({ fallbackMs: POKE_FALLBACK_MS });
316
343
  if (dev) {
317
344
  // Watch the app tree with Node's built-in `fs.watch` (no external dep):
318
345
  // `watchTree` (#1521) watches the root plus each non-ignored top-level dir,
@@ -323,34 +350,32 @@ export async function startServer(opts) {
323
350
  // (module-level, exported for tests) skips node_modules, .git, .webjs/, and
324
351
  // the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
325
352
  // Live-reload classification (#1398). Several files can change inside one
326
- // 50ms debounce window (80ms before #1575), so hold the STRONGEST verdict of the window and
327
- // hand it to the rebuild. Same rule as the browser relay's cross-batch
328
- // accumulation, for the same reason: a window mixing a page edit and a
329
- // component edit is a component edit, and taking the last one would morph
330
- // fresh markup onto the old component class.
331
- /** @type {import('../dev-classify.js').ReloadVerdict | null} */
332
- let pendingVerdict = null;
353
+ // 50ms debounce window (80ms before #1575), so merge the verdicts of the
354
+ // window and hand the result to the rebuild. Same rule as the browser
355
+ // relay's cross-batch accumulation, for the same reason: a window mixing a
356
+ // page edit and a component edit is a component edit, and taking the last
357
+ // one would morph fresh markup onto the old component class. The window's
358
+ // changed files ride along (#1689), so the rebuild can version exactly the
359
+ // modules the edit touched.
360
+ /** @type {{ verdict: import('../dev-classify.js').ReloadVerdict | null, changed: Set<string> }} */
361
+ const pending = { verdict: null, changed: new Set() };
333
362
  // Under `bun --hot`, a window that changed a module a `Bun.plugin` serves
334
363
  // pokes the re-run sentinel instead of rebuilding (#1575): the rebuild has
335
364
  // to wait for the registry reset, or the next request would still import
336
365
  // the stale module. The re-run (`hotRerun` below) rebuilds with the verdict.
337
366
  let pendingPoke = false;
338
367
  const rebuild = debounce(() => {
339
- const v = pendingVerdict;
340
- pendingVerdict = null;
368
+ const v = pending.verdict;
369
+ const files = [...pending.changed];
370
+ pending.verdict = null;
371
+ pending.changed.clear();
341
372
  if (pendingPoke) {
342
373
  pendingPoke = false;
343
- pokedVerdict = strongerVerdict(pokedVerdict, v);
374
+ poked.poke(v, files, (pv, pf) => { if (pv) app.rebuild(pv, { changed: pf }); });
344
375
  pokeHotSentinel(app.appDir);
345
- clearTimeout(pokeFallback);
346
- pokeFallback = setTimeout(() => {
347
- const pv = pokedVerdict;
348
- pokedVerdict = null;
349
- if (pv) app.rebuild(pv);
350
- }, POKE_FALLBACK_MS);
351
376
  return;
352
377
  }
353
- app.rebuild(v || undefined);
378
+ app.rebuild(v || undefined, { changed: files });
354
379
  }, 50);
355
380
  watcherAbort = new AbortController();
356
381
  // One warning per unwatchable path (#1521): an EACCES / ENOENT on a single
@@ -363,27 +388,55 @@ export async function startServer(opts) {
363
388
  warned.add(key);
364
389
  logger.warn(`file watcher skipped ${err && err.path ? err.path : 'a path'} (${err && err.code ? err.code : String(err)})`);
365
390
  };
391
+ let pollFallbackWarned = false;
392
+ const restartFile = supervisorRestartFile();
366
393
  const watchRoot = (root) => {
367
394
  // Output nothing serves (`*.log`, coverage/, ...) and the root's
368
395
  // `.gitignore` never reload the page: `npm run dev > dev.log` made every
369
396
  // log line a reload (watch-ignore.js).
370
397
  const unserved = createWatchIgnore(root);
371
398
  const close = watchTree(root, {
372
- ignore: (rel) => shouldIgnoreWatchPath(rel) || unserved(rel),
399
+ // An editor temp file renamed over a real file is let through, and
400
+ // reported below as the file it saved (#1689): Bun's fs.watch names
401
+ // only the temp file for such a save.
402
+ ignore: (rel) => shouldIgnoreWatchPath(rel) || (unserved(rel) && !editorTempTarget(rel)),
373
403
  onEvent: (filename) => {
404
+ const target = editorTempTarget(filename);
405
+ if (target) {
406
+ if (unserved(target) || !existsSync(resolve(root, target))) return;
407
+ filename = target;
408
+ }
374
409
  // A regenerate output (#967) is a build product the server itself
375
410
  // writes on request; ignoring it stops a spurious rebuild + reload
376
411
  // (and a reload -> refetch -> recompile -> reload cycle), same as the
377
412
  // db/dev.db carve-out above. `app` exposes the check because `state`
378
413
  // lives in createRequestHandler's scope, not here.
379
414
  if (app.isRegenerateOutput(filename)) return;
380
- pendingVerdict = strongerVerdict(pendingVerdict, app.classifyWatchPath(filename, root));
415
+ // The supervisor restarts the process for this one, and the restart
416
+ // is the reload; a frame from here would be a second one (#1689).
417
+ if (restartFile && root === app.appDir && restartFile.test(filename)) return;
418
+ // A module the CommonJS loader holds cannot be versioned, so the
419
+ // edit needs a fresh process (#1689): the supervisor restarts, and
420
+ // that restart is the reload, so no frame goes out from here.
421
+ if (restartFile && app.isCommonJsModule(resolve(root, filename))
422
+ && requestSupervisorRestart(relative(app.appDir, resolve(root, filename)))) return;
423
+ pending.verdict = mergeVerdicts(pending.verdict, app.classifyWatchPath(filename, root));
424
+ pending.changed.add(resolve(root, filename));
381
425
  if (hotKey && needsHotReset(filename, (f) => !app.needsRegistryReset(f, root))) pendingPoke = true;
382
426
  // A status wait (#1664) holds until the rebuild this event schedules.
383
427
  app.noteChange?.();
384
428
  rebuild();
385
429
  },
386
430
  onError: onWatchError,
431
+ // Out of inotify watches (#1689): the directory is polled instead, and
432
+ // the fix is printed once per process rather than per directory.
433
+ onFallback: (name, err) => {
434
+ if (pollFallbackWarned) return;
435
+ pollFallbackWarned = true;
436
+ const code = (err && err.code) || 'ENOSPC';
437
+ const what = name ? `${name}/` : 'the app root';
438
+ logger.warn(`[webjs] the file watch limit is reached (${code}), so ${what} is watched by polling every 1s. Raise it: sudo sysctl fs.inotify.max_user_watches=524288 (persist it in /etc/sysctl.d/).`);
439
+ },
387
440
  });
388
441
  watcherAbort.signal.addEventListener('abort', close, { once: true });
389
442
  };
@@ -436,21 +489,27 @@ export async function startServer(opts) {
436
489
  * then re-derive the analysis so it re-imports the app's modules (the
437
490
  * middleware, the components it primes) from that registry. A re-run a
438
491
  * poke asked for carries the verdict of the edits behind it and tells the
439
- * browser; any other is silent, since the watcher already sent that
440
- * edit's own reload frame.
492
+ * browser, and so does one that arrives after the poke's fallback
493
+ * already rebuilt against the old registry (#1689), so the tab picks up
494
+ * the fresh modules; any other is silent, since the watcher already
495
+ * sent that edit's own reload frame.
441
496
  */
442
497
  rerun: async () => {
443
498
  await armHotSentinel(app.appDir);
444
- const pv = pokedVerdict;
445
- pokedVerdict = null;
446
- clearTimeout(pokeFallback);
447
- await app.rebuild(pv || undefined, { notify: !!pv });
499
+ if (rerunDelayMs()) await new Promise((r) => setTimeout(r, rerunDelayMs()));
500
+ const { verdict: pv, changed: pf, notify } = poked.takeForRerun();
501
+ await app.rebuild(pv || undefined, { notify, changed: pf });
448
502
  return owned;
449
503
  },
450
504
  poke: () => pokeHotSentinel(app.appDir),
451
505
  });
452
506
  announceHotInPlace();
453
507
  }
508
+ // A Node child with the module resolve hook (#1689) tells the supervisor
509
+ // the same thing, so an app edit no longer restarts it; only the restart
510
+ // files (`instrumentation.*`, `env.*`, `.env`, `package.json`, a lockfile)
511
+ // still do.
512
+ if (app.hotModules) announceHotInPlace();
454
513
  return handle;
455
514
  } catch (e) {
456
515
  // The listener never came up (a taken port, #1527): stop what was started
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Browser module versions for `webjs dev` (#1689), runtime-neutral.
3
+ *
4
+ * A browser keeps one module instance per URL for the life of the page, so a
5
+ * re-imported module whose own URL is unchanged comes back as the old instance,
6
+ * and so does every module it imports. To hand a tab the edited code without a
7
+ * reload, a rebuild bumps each changed file the browser holds AND every module
8
+ * that imports it (transitively, through the shipped graph), and the server
9
+ * rewrites the import specifiers of every served app module so an import of a
10
+ * bumped file carries `?t=<n>` (`&t=<n>` when the specifier already has a
11
+ * query), the technique the Remix asset server uses. A re-import of a bumped
12
+ * boundary then walks a fresh URL chain down to the edited file, while every
13
+ * module nothing changed under keeps its bare URL and therefore its original
14
+ * instance, which is what keeps `@webjsdev/core` (a bare specifier, never
15
+ * versioned) and every module-scope signal one instance.
16
+ *
17
+ * The map is separate from the server's (`esm-hot-hooks.js`) because Bun has no
18
+ * resolve hook and the browser's module map is separate anyway. It lives on the
19
+ * handler's state and is never cleared on a rebuild.
20
+ */
21
+
22
+ import { dynamicEdges, expandImportAlias, importSpecifierRanges, resolveImport } from '../module-graph.js';
23
+
24
+ /**
25
+ * @typedef {{
26
+ * bump: (changed: Iterable<string>, importers: Map<string, Set<string>> | null, shipped: Set<string>) => Set<string>,
27
+ * versionOf: (abs: string) => number,
28
+ * clear: () => void,
29
+ * }} BrowserVersions
30
+ */
31
+
32
+ /**
33
+ * Who imports whom among the modules the browser holds: for each module, the
34
+ * shipped modules that import it statically or through a string-literal
35
+ * dynamic import. One walk of the graph per rebuild, shared by the version
36
+ * bump and the hot-swap boundary walk (`hotVerdict` in `handler.js`).
37
+ * @param {Map<string, Set<string>>} graph
38
+ * @param {Set<string>} shipped
39
+ * @returns {Map<string, Set<string>>}
40
+ */
41
+ export function shippedImporters(graph, shipped) {
42
+ /** @type {Map<string, Set<string>>} */
43
+ const importers = new Map();
44
+ for (const edges of [graph, dynamicEdges(graph)]) {
45
+ for (const [from, tos] of edges) {
46
+ if (!shipped.has(from)) continue;
47
+ for (const to of tos) {
48
+ let set = importers.get(to);
49
+ if (!set) importers.set(to, (set = new Set()));
50
+ set.add(from);
51
+ }
52
+ }
53
+ }
54
+ return importers;
55
+ }
56
+
57
+ /** @returns {BrowserVersions} */
58
+ export function createBrowserVersions() {
59
+ /** @type {Map<string, number>} */
60
+ const versions = new Map();
61
+ let next = 0;
62
+ return {
63
+ // Give each changed file the browser holds and every shipped module that
64
+ // imports it (`shippedImporters`) ONE new version. A changed file outside
65
+ // `shipped` is skipped, since the browser holds no instance of it. Returns
66
+ // the files that moved.
67
+ bump(changed, importers, shipped) {
68
+ /** @type {Set<string>} */
69
+ const reached = new Set();
70
+ if (!importers) return reached;
71
+ const stack = [];
72
+ for (const f of changed) if (shipped.has(f)) stack.push(f);
73
+ while (stack.length) {
74
+ const f = /** @type {string} */ (stack.pop());
75
+ if (reached.has(f)) continue;
76
+ reached.add(f);
77
+ for (const imp of importers.get(f) || []) if (!reached.has(imp)) stack.push(imp);
78
+ }
79
+ if (reached.size) {
80
+ next++;
81
+ for (const f of reached) versions.set(f, next);
82
+ }
83
+ return reached;
84
+ },
85
+ versionOf(abs) {
86
+ return versions.get(abs) || 0;
87
+ },
88
+ clear() {
89
+ versions.clear();
90
+ },
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Append `t=<version>` to every import specifier in `code` that resolves to an
96
+ * app file with a version above 0. Static imports, `export … from` and
97
+ * string-literal dynamic imports are rewritten; a type-only clause, a
98
+ * specifier written inside a string, template or comment, and a bare specifier
99
+ * are left alone. Returns `code` itself when nothing applies.
100
+ *
101
+ * @param {string} code a served module's source (after type stripping)
102
+ * @param {string} importerAbs the module's absolute path
103
+ * @param {string} appDir
104
+ * @param {(abs: string) => number} versionOf
105
+ * @returns {string}
106
+ */
107
+ export function versionImportSpecifiers(code, importerAbs, appDir, versionOf) {
108
+ /** @type {Array<{ end: number, add: string }>} */
109
+ const edits = [];
110
+ const seen = new Set();
111
+ for (const { start, end, spec } of importSpecifierRanges(code)) {
112
+ if (seen.has(start)) continue;
113
+ seen.add(start);
114
+ if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) continue;
115
+ const resolved = resolveImport(spec, importerAbs, appDir);
116
+ if (!resolved) continue;
117
+ const n = versionOf(resolved);
118
+ if (n > 0) edits.push({ end, add: (spec.includes('?') ? '&' : '?') + 't=' + n });
119
+ }
120
+ if (!edits.length) return code;
121
+ edits.sort((a, b) => b.end - a.end);
122
+ let out = code;
123
+ for (const { end, add } of edits) out = out.slice(0, end) + add + out.slice(end);
124
+ return out;
125
+ }
@@ -24,15 +24,60 @@
24
24
  import { readFileSync, statSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
26
 
27
- const OUTPUT = /(?:^|[\\/])(?:coverage|\.cache|\.nyc_output|test-results|playwright-report|\.turbo)(?:[\\/]|$)|\.log$|(?:^|[\\/])\.DS_Store$|\.swp$|~$/;
27
+ const OUTPUT = /(?:^|[\\/])(?:coverage|\.cache|\.nyc_output|test-results|playwright-report|\.turbo)(?:[\\/]|$)|\.log$|(?:^|[\\/])\.DS_Store$|(?:^|[\\/])(?:\.#[^\\/]*|#[^\\/]*#|\.goutputstream-[^\\/]*|~\$[^\\/]*)$|\.(?:sw[a-p]|tmp|crswap|kate-swp)$|___jb_(?:tmp|old)___$|~$/;
28
+
29
+ /**
30
+ * Vim's write probe, an all-digit FILE (`4913`, then `5036` and up when that
31
+ * one exists) it creates and deletes in the directory it saves into. Only a
32
+ * file is a probe: a directory named by digits (`app/blog/2024/`, `app/404/`)
33
+ * is app source, and the walkers ask about directories by the same path test.
34
+ */
35
+ const DIGIT_LEAF = /(?:^|[\\/])\d+$/;
36
+
37
+ /**
38
+ * Whether `rel` is vim's write probe: an all-digit name that is not a
39
+ * directory under `root` (a probe is usually already deleted when its event
40
+ * arrives, and a missing path is not a directory either).
41
+ * @param {string} rel
42
+ * @param {string} [root] the app root; without one an all-digit name is taken as a file
43
+ * @returns {boolean}
44
+ */
45
+ export function isVimProbe(rel, root) {
46
+ if (!DIGIT_LEAF.test(rel || '')) return false;
47
+ if (!root) return true;
48
+ try { return !statSync(join(root, rel)).isDirectory(); } catch { return true; }
49
+ }
50
+
51
+ /**
52
+ * Editor temp files that are RENAMED over the file they save (#1689): `x.tmp`
53
+ * (`sed`-style and many atomic writers), JetBrains' `x___jb_tmp___` /
54
+ * `x___jb_old___`, and Chrome's `x.crswap`. Bun's `fs.watch` reports such a
55
+ * save ONLY under the temp name, so ignoring it outright would lose the edit
56
+ * there; the watcher reports the target instead.
57
+ */
58
+ const RENAMED_TEMP = /(?:\.tmp|\.crswap|___jb_(?:tmp|old)___)$/;
59
+
60
+ /**
61
+ * The file an editor temp file is renamed over, or null when the name says
62
+ * nothing about it (vim's `4913`, an emacs lock, a gedit stream).
63
+ * @param {string} rel
64
+ * @returns {string | null}
65
+ */
66
+ export function editorTempTarget(rel) {
67
+ const p = String(rel || '');
68
+ const m = RENAMED_TEMP.exec(p);
69
+ if (!m || m.index === 0 || /[\\/]$/.test(p.slice(0, m.index))) return null;
70
+ return p.slice(0, m.index);
71
+ }
28
72
 
29
73
  /**
30
74
  * Tool output that is never served, whatever `.gitignore` says.
31
75
  * @param {string} rel path relative to the app root
76
+ * @param {string} [root] the app root, which tells vim's all-digit probe file from a directory
32
77
  * @returns {boolean}
33
78
  */
34
- export function isUnservedOutput(rel) {
35
- return OUTPUT.test(rel || '');
79
+ export function isUnservedOutput(rel, root) {
80
+ return OUTPUT.test(rel || '') || isVimProbe(rel, root);
36
81
  }
37
82
 
38
83
  /**
@@ -115,7 +160,7 @@ export function createWatchIgnore(root) {
115
160
  try { rules = parseGitignore(readFileSync(join(root, '.gitignore'), 'utf8')); } catch { /* no .gitignore */ }
116
161
  return (rel) => {
117
162
  const p = String(rel || '').replace(/\\/g, '/');
118
- if (isUnservedOutput(p)) return true;
163
+ if (isUnservedOutput(p, root)) return true;
119
164
  if (/(?:^|\/)\.env(?:\.[^/]*)?$/.test(p)) return false;
120
165
  let dir = false;
121
166
  if (rules.length) { try { dir = statSync(join(root, p)).isDirectory(); } catch { /* gone or unreadable */ } }
@@ -0,0 +1,78 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Poll a directory for changes, the dev watcher's fallback when the OS has no
6
+ * watch left to give (#1689). Linux caps inotify watches per user
7
+ * (`fs.inotify.max_user_watches`, often 8192 on older distributions and lower
8
+ * in containers), and a large repo or a second dev server reaches it: `fs.watch`
9
+ * then fails with ENOSPC (EMFILE for open files), and the directory used to be
10
+ * dropped for the rest of the session, so its edits never reloaded anything.
11
+ * Polling is slower and costs a directory walk per tick, but it never stops.
12
+ *
13
+ * Each tick takes a snapshot `Map<rel, 'mtimeMs:size'>` of every file under
14
+ * `dir` that `ignore` admits (directories are walked, not reported) and diffs it
15
+ * against the previous one, so an added, a changed and a removed file are each
16
+ * reported once. The first snapshot is the baseline and reports nothing. The
17
+ * timer is `unref()`ed, so a forgotten poller never holds the process open.
18
+ *
19
+ * @param {string} dir absolute directory to poll
20
+ * @param {{
21
+ * ignore?: (relPath: string) => boolean,
22
+ * onEvent: (relPath: string) => void,
23
+ * intervalMs?: number,
24
+ * recursive?: boolean,
25
+ * }} opts `relPath` is relative to `dir`, with OS separators. `recursive:
26
+ * false` reports the direct entries only (directories included), the shape
27
+ * the root of a watched tree needs.
28
+ * @returns {() => void} stops polling
29
+ */
30
+ export function pollTree(dir, { ignore = () => false, onEvent, intervalMs = 1000, recursive = true }) {
31
+ /** @returns {Map<string, string>} */
32
+ const snapshot = () => {
33
+ /** @type {Map<string, string>} */
34
+ const out = new Map();
35
+ /** @param {string} rel */
36
+ const walk = (rel) => {
37
+ let entries;
38
+ try { entries = readdirSync(rel ? join(dir, rel) : dir, { withFileTypes: true }); } catch { return; }
39
+ for (const e of entries) {
40
+ const child = rel ? join(rel, e.name) : e.name;
41
+ if (ignore(child)) continue;
42
+ let st;
43
+ try { st = statSync(join(dir, child)); } catch { continue; }
44
+ if (st.isDirectory()) {
45
+ if (recursive) walk(child);
46
+ else out.set(child, 'dir');
47
+ } else {
48
+ out.set(child, `${st.mtimeMs}:${st.size}`);
49
+ }
50
+ }
51
+ };
52
+ walk('');
53
+ return out;
54
+ };
55
+
56
+ let prev = snapshot();
57
+ const timer = setInterval(() => {
58
+ const next = snapshot();
59
+ for (const [rel, sig] of next) if (prev.get(rel) !== sig) onEvent(rel);
60
+ for (const rel of prev.keys()) if (!next.has(rel)) onEvent(rel);
61
+ prev = next;
62
+ }, intervalMs);
63
+ timer.unref?.();
64
+ return () => clearInterval(timer);
65
+ }
66
+
67
+ /**
68
+ * Whether a watch failure means the OS is out of watches (or file
69
+ * descriptors), the two failures polling can stand in for. Any other watch
70
+ * error (EACCES, ENOENT on a vanished path) is about one path and stays a
71
+ * warning.
72
+ * @param {unknown} err
73
+ * @returns {boolean}
74
+ */
75
+ export function isWatchLimitError(err) {
76
+ const code = err && typeof err === 'object' ? /** @type {any} */ (err).code : undefined;
77
+ return code === 'ENOSPC' || code === 'EMFILE';
78
+ }
@@ -1,6 +1,7 @@
1
1
  import { watch, readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { watchRecursive } from './watch-recursive.js';
4
+ import { pollTree, isWatchLimitError } from './watch-poll.js';
4
5
 
5
6
  /**
6
7
  * The dev live-reload watcher over one root (#1521).
@@ -25,58 +26,94 @@ import { watchRecursive } from './watch-recursive.js';
25
26
  * root watcher's events, so `mkdir lib` mid-session is watched like it was
26
27
  * there at boot.
27
28
  *
29
+ * When the OS runs out of watches (ENOSPC once the inotify limit is reached,
30
+ * EMFILE for file descriptors, #1689), the directory that failed is polled
31
+ * instead (`watch-poll.js`) and `onFallback` is told once per directory, so its
32
+ * edits keep arriving, a second slower, instead of never. A root watcher that
33
+ * fails that way polls the root's direct entries.
34
+ *
28
35
  * @param {string} root absolute directory to watch
29
36
  * @param {{
30
37
  * ignore: (relPath: string) => boolean,
31
38
  * onEvent: (relPath: string) => void,
32
39
  * onError: (err: NodeJS.ErrnoException) => void,
33
- * }} opts `relPath` is relative to `root`, with OS separators.
40
+ * onFallback?: (name: string, err: NodeJS.ErrnoException) => void,
41
+ * watchFn?: typeof watch,
42
+ * pollMs?: number,
43
+ * }} opts `relPath` is relative to `root`, with OS separators. `onFallback`
44
+ * gets the top-level directory name (`''` for the root). `watchFn` and
45
+ * `pollMs` are test seams.
34
46
  * @returns {() => void} closes every watcher
35
47
  */
36
- export function watchTree(root, { ignore, onEvent, onError }) {
37
- /** @type {Map<string, import('node:fs').FSWatcher>} */
48
+ export function watchTree(root, { ignore, onEvent, onError, onFallback, watchFn = watch, pollMs = 1000 }) {
49
+ /** @type {Map<string, { close: () => void, polled?: boolean }>} */
38
50
  const dirs = new Map();
39
51
  let closed = false;
40
52
 
41
- const guard = (/** @type {import('node:fs').FSWatcher} */ w) => {
42
- w.on('error', (err) => onError(/** @type {NodeJS.ErrnoException} */ (err)));
43
- return w;
44
- };
45
-
46
53
  const isDir = (name) => {
47
54
  try { return statSync(join(root, name)).isDirectory(); } catch { return false; }
48
55
  };
49
56
 
57
+ /** Replace a top-level directory's watcher with a poller. */
58
+ const pollDir = (name, /** @type {NodeJS.ErrnoException} */ err) => {
59
+ if (closed || dirs.get(name)?.polled) return;
60
+ try { dirs.get(name)?.close(); } catch {}
61
+ const stop = pollTree(join(root, name), {
62
+ ignore: (rel) => ignore(join(name, rel)),
63
+ onEvent: (rel) => onEvent(join(name, rel)),
64
+ intervalMs: pollMs,
65
+ });
66
+ dirs.set(name, { close: stop, polled: true });
67
+ onFallback?.(name, err);
68
+ };
69
+
50
70
  const watchDir = (name) => {
51
71
  if (closed || dirs.has(name) || ignore(name)) return;
72
+ const failed = (/** @type {NodeJS.ErrnoException} */ err) => {
73
+ if (isWatchLimitError(err)) pollDir(name, err);
74
+ else onError(err);
75
+ };
52
76
  try {
53
77
  // `watchRecursive` (#1529): on Linux under Node a per-directory walker,
54
78
  // since Node 24's recursive watcher goes deaf to a file once it is
55
79
  // replaced (`sed -i`, an editor's atomic save).
56
- const w = guard(watchRecursive(join(root, name), (_type, filename) => {
80
+ const w = watchRecursive(join(root, name), (_type, filename) => {
57
81
  const rel = filename ? join(name, String(filename)) : name;
58
82
  if (!ignore(rel)) onEvent(rel);
59
- }, { ignore: (rel) => ignore(join(name, rel)) }));
83
+ }, { ignore: (rel) => ignore(join(name, rel)), watchFn });
84
+ w.on('error', (err) => failed(/** @type {NodeJS.ErrnoException} */ (err)));
60
85
  dirs.set(name, w);
61
86
  } catch (err) {
62
- onError(/** @type {NodeJS.ErrnoException} */ (err));
87
+ failed(/** @type {NodeJS.ErrnoException} */ (err));
63
88
  }
64
89
  };
65
90
 
66
- /** @type {import('node:fs').FSWatcher | null} */
91
+ /** A top-level entry changed: start or stop following it as a directory. */
92
+ const onTop = (/** @type {string} */ name) => {
93
+ if (ignore(name)) return;
94
+ if (isDir(name)) watchDir(name);
95
+ else if (dirs.has(name)) { try { dirs.get(name)?.close(); } catch {} dirs.delete(name); }
96
+ onEvent(name);
97
+ };
98
+
99
+ /** @type {{ close: () => void } | null} */
67
100
  let top = null;
101
+ const pollRoot = (/** @type {NodeJS.ErrnoException} */ err) => {
102
+ if (closed) return;
103
+ try { top?.close(); } catch {}
104
+ top = { close: pollTree(root, { ignore, onEvent: onTop, intervalMs: pollMs, recursive: false }) };
105
+ onFallback?.('', err);
106
+ };
107
+ const rootFailed = (/** @type {NodeJS.ErrnoException} */ err) => {
108
+ if (isWatchLimitError(err)) pollRoot(err);
109
+ else onError(err);
110
+ };
68
111
  try {
69
- top = guard(watch(root, (_type, filename) => {
70
- if (!filename) return;
71
- const name = String(filename);
72
- if (ignore(name)) return;
73
- // A top-level entry changed: start or stop following it as a directory.
74
- if (isDir(name)) watchDir(name);
75
- else if (dirs.has(name)) { try { dirs.get(name)?.close(); } catch {} dirs.delete(name); }
76
- onEvent(name);
77
- }));
112
+ const w = watchFn(root, (_type, filename) => { if (filename) onTop(String(filename)); });
113
+ w.on('error', (err) => rootFailed(/** @type {NodeJS.ErrnoException} */ (err)));
114
+ top = w;
78
115
  } catch (err) {
79
- onError(/** @type {NodeJS.ErrnoException} */ (err));
116
+ rootFailed(/** @type {NodeJS.ErrnoException} */ (err));
80
117
  }
81
118
 
82
119
  let entries = [];