@webjsdev/server 0.8.91 → 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 (105) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +461 -102
  3. package/index.js +9 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-input.js +208 -0
  7. package/src/action-params.js +156 -0
  8. package/src/action-route.js +8 -9
  9. package/src/action-seed.js +44 -5
  10. package/src/actions.js +112 -15
  11. package/src/api.js +67 -10
  12. package/src/app-layout.js +133 -0
  13. package/src/asset-hash.js +82 -49
  14. package/src/auth.js +612 -108
  15. package/src/browser-entries.js +2 -17
  16. package/src/build-epoch.js +1 -1
  17. package/src/cache-tags.js +5 -1
  18. package/src/check/helpers.js +85 -0
  19. package/src/check/rules-actions.js +37 -1
  20. package/src/check/rules-components.js +133 -28
  21. package/src/check/rules-imports.js +175 -4
  22. package/src/check/rules-route-modules.js +200 -0
  23. package/src/check/rules-routes.js +55 -0
  24. package/src/check/rules-routing.js +143 -95
  25. package/src/check/rules-typescript.js +24 -2
  26. package/src/check/rules.js +109 -9
  27. package/src/check/runner-support.js +53 -171
  28. package/src/check/runner.js +24 -6
  29. package/src/client-signals.js +171 -0
  30. package/src/component-scanner.js +22 -16
  31. package/src/context.js +152 -16
  32. package/src/cookie-store.js +262 -0
  33. package/src/core-exports.js +190 -0
  34. package/src/dev/bun-app-source.js +95 -5
  35. package/src/dev/config.js +7 -51
  36. package/src/dev/dev-service-worker.js +48 -0
  37. package/src/dev/dev-status.js +122 -0
  38. package/src/dev/esm-hot-hooks.js +373 -0
  39. package/src/dev/handler.js +632 -155
  40. package/src/dev/helpers.js +127 -96
  41. package/src/dev/hot-host.js +33 -6
  42. package/src/dev/poke-state.js +110 -0
  43. package/src/dev/serve.js +191 -133
  44. package/src/dev/server.js +109 -37
  45. package/src/dev/specifier-versions.js +125 -0
  46. package/src/dev/watch-ignore.js +49 -4
  47. package/src/dev/watch-poll.js +78 -0
  48. package/src/dev/watch-tree.js +59 -22
  49. package/src/dev-classify.js +59 -26
  50. package/src/dev-error.js +104 -3
  51. package/src/dev-hot-classify.js +291 -0
  52. package/src/dev-hot-client.js +245 -0
  53. package/src/dev-import.js +22 -5
  54. package/src/dev-overlay.js +26 -0
  55. package/src/dev-ready-probe.js +69 -0
  56. package/src/dev-regenerate.js +322 -78
  57. package/src/dev-reload-hold.js +49 -20
  58. package/src/dev-reload-reason.js +61 -0
  59. package/src/dev-reload-worker.js +62 -13
  60. package/src/dev-secret.js +73 -0
  61. package/src/dev-source-locations-hook.js +4 -1
  62. package/src/dev-source-locations.js +33 -2
  63. package/src/dev-styles.js +64 -6
  64. package/src/dev.js +0 -1
  65. package/src/env-files.js +124 -0
  66. package/src/file-storage.js +2 -2
  67. package/src/foreign-imports.js +228 -0
  68. package/src/form-action-identity.js +52 -25
  69. package/src/form-dispatch.js +150 -69
  70. package/src/html-cache.js +35 -4
  71. package/src/importmap.js +2 -2
  72. package/src/instrumentation.js +59 -3
  73. package/src/js-scan.js +5 -4
  74. package/src/jwe.js +155 -0
  75. package/src/lazy-components.js +166 -0
  76. package/src/listener-core.js +4 -2
  77. package/src/lit-hints.js +172 -0
  78. package/src/middleware-load.js +248 -0
  79. package/src/module-graph.js +73 -47
  80. package/src/module-side-effect.js +284 -0
  81. package/src/public-files.js +73 -0
  82. package/src/redirect-location.js +19 -0
  83. package/src/redirects.js +9 -1
  84. package/src/route-frontier.js +151 -0
  85. package/src/route-report.js +130 -0
  86. package/src/router.js +120 -13
  87. package/src/session.js +32 -10
  88. package/src/ssr/client-router-flag.js +1 -1
  89. package/src/ssr/document.js +49 -17
  90. package/src/ssr/head.js +228 -25
  91. package/src/ssr/preloads.js +31 -45
  92. package/src/ssr/render.js +243 -55
  93. package/src/ssr/responses.js +51 -4
  94. package/src/ssr/shipped-modules.js +52 -16
  95. package/src/testing.d.ts +31 -2
  96. package/src/testing.js +85 -17
  97. package/src/thenable-params.js +26 -0
  98. package/src/vendor/jspm.js +12 -1
  99. package/src/vendor/pins.js +9 -8
  100. package/src/vendor/scanner.js +14 -9
  101. package/src/vendor.js +5 -3
  102. package/webjs-config.schema.json +3 -8
  103. package/src/component-elision.js +0 -1852
  104. package/src/elision-differential.js +0 -101
  105. 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';
@@ -18,6 +19,7 @@ import { stripBasePath } from '../base-path.js';
18
19
  import { basePath } from '../importmap.js';
19
20
  import { attachWebSocket } from '../websocket.js';
20
21
  import { toWebRequest, sendWebResponse } from './helpers.js';
22
+ import { earlyHintLinks } from '../ssr/shipped-modules.js';
21
23
  import { isAddrInUse, portInUseError, reusePortRequested } from '../port-in-use.js';
22
24
 
23
25
  /** This copy of `@webjsdev/server`'s version, so a hot re-run can tell the framework itself changed. */
@@ -32,6 +34,29 @@ const SERVER_VERSION = (() => {
32
34
  */
33
35
  const POKE_FALLBACK_MS = 1500;
34
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
+
35
60
  function debounce(fn, ms) {
36
61
  let t;
37
62
  return (...args) => {
@@ -58,14 +83,21 @@ function debounce(fn, ms) {
58
83
  * (drizzle-kit output) churn during db:migrate. The `db/dev.db` branch is
59
84
  * prefix-only (no trailing separator) so the `-journal` / `-wal` sidecars match
60
85
  * too, while staying anchored to `db/dev.db` so a SOURCE file like
61
- * `db/schema.server.ts` still triggers a reload. The others stay
86
+ * `db/schema.server.ts` still triggers a reload. Beyond that, under the app's
87
+ * root `db/` only JS / TS source counts (#1664): a database file anywhere
88
+ * below it, whatever its name or sidecar (a `DATABASE_URL` pointing at
89
+ * `db/data/app.db`, libSQL's `-info` / `-client_wal_index` files), changes on
90
+ * every write and is skipped. Rails' reloader takes the same approach,
91
+ * watching a directory for source extensions only, rather than listing data
92
+ * files. The CLI's `shouldIgnoreRestartPath` carries the same pattern, and a
93
+ * test asserts the two agree over a corpus. The others stay
62
94
  * separator-anchored so an unrelated name like `node_modules.bak/foo` does not.
63
95
  *
64
96
  * @param {string} filename relative path from an fs.watch `event.filename`
65
97
  * @returns {boolean} true when the change should be ignored
66
98
  */
67
99
  export function shouldIgnoreWatchPath(filename) {
68
- return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])db[\\/](?:dev\.db|migrations)/.test(filename || '');
100
+ return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])db[\\/](?:dev\.db|migrations)|^db[\\/](?!.*\.[cm]?[jt]s$)/.test(filename || '');
69
101
  }
70
102
 
71
103
  /**
@@ -149,7 +181,10 @@ async function startNodeListener(ctx) {
149
181
  // 103 Early Hints: before running SSR, send preload hints for the
150
182
  // page's module URLs so the browser can begin fetching them while
151
183
  // the server is still computing the body. Skipped in dev (file churn
152
- // would send stale URLs after rebuilds) and for non-GET/HEAD.
184
+ // would send stale URLs after rebuilds) and for non-GET/HEAD. Plain
185
+ // preloads, never modulepreload: a module hint arrives before the
186
+ // page's import map, and Firefox then refuses the map (see
187
+ // earlyHintLinks in ../ssr/shipped-modules.js).
153
188
  if (
154
189
  !dev &&
155
190
  (req.method === 'GET' || req.method === 'HEAD') &&
@@ -159,7 +194,7 @@ async function startNodeListener(ctx) {
159
194
  if (match && match.moduleUrls.length) {
160
195
  try {
161
196
  res.writeEarlyHints({
162
- link: match.moduleUrls.map((u) => `<${u}>; rel=modulepreload`),
197
+ link: earlyHintLinks(match.moduleUrls),
163
198
  });
164
199
  } catch (e) {
165
200
  logger.warn('writeEarlyHints failed', { err: String(e) });
@@ -275,7 +310,9 @@ export async function startServer(opts) {
275
310
  // identically on both). Built before the handler so its onReload / onDevError
276
311
  // callbacks can fan out through it.
277
312
  // A Bun hot host, or a server outside the dev supervisor (`--no-hot`, an
278
- // 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.
279
316
  const hub = new SseHub({ inPlace: dev && (!!hotKey || process.env.__WEBJS_DEV_CHILD !== '1') });
280
317
  // Pages render the stream's state into a meta tag (#1516), so the relay can
281
318
  // re-check a page against the server on every reconnect.
@@ -295,13 +332,14 @@ export async function startServer(opts) {
295
332
  hub.closeAll();
296
333
  throw e;
297
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;
298
338
 
299
339
  /** @type {AbortController | null} */
300
340
  let watcherAbort = null;
301
- /** The strongest verdict of the edits a poked re-run has yet to apply. */
302
- /** @type {import('../dev-classify.js').ReloadVerdict | null} */
303
- let pokedVerdict = null;
304
- /** @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 });
305
343
  if (dev) {
306
344
  // Watch the app tree with Node's built-in `fs.watch` (no external dep):
307
345
  // `watchTree` (#1521) watches the root plus each non-ignored top-level dir,
@@ -312,34 +350,32 @@ export async function startServer(opts) {
312
350
  // (module-level, exported for tests) skips node_modules, .git, .webjs/, and
313
351
  // the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
314
352
  // Live-reload classification (#1398). Several files can change inside one
315
- // 50ms debounce window (80ms before #1575), so hold the STRONGEST verdict of the window and
316
- // hand it to the rebuild. Same rule as the browser relay's cross-batch
317
- // accumulation, for the same reason: a window mixing a page edit and a
318
- // component edit is a component edit, and taking the last one would morph
319
- // fresh markup onto the old component class.
320
- /** @type {import('../dev-classify.js').ReloadVerdict | null} */
321
- 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() };
322
362
  // Under `bun --hot`, a window that changed a module a `Bun.plugin` serves
323
363
  // pokes the re-run sentinel instead of rebuilding (#1575): the rebuild has
324
364
  // to wait for the registry reset, or the next request would still import
325
365
  // the stale module. The re-run (`hotRerun` below) rebuilds with the verdict.
326
366
  let pendingPoke = false;
327
367
  const rebuild = debounce(() => {
328
- const v = pendingVerdict;
329
- pendingVerdict = null;
368
+ const v = pending.verdict;
369
+ const files = [...pending.changed];
370
+ pending.verdict = null;
371
+ pending.changed.clear();
330
372
  if (pendingPoke) {
331
373
  pendingPoke = false;
332
- pokedVerdict = strongerVerdict(pokedVerdict, v);
374
+ poked.poke(v, files, (pv, pf) => { if (pv) app.rebuild(pv, { changed: pf }); });
333
375
  pokeHotSentinel(app.appDir);
334
- clearTimeout(pokeFallback);
335
- pokeFallback = setTimeout(() => {
336
- const pv = pokedVerdict;
337
- pokedVerdict = null;
338
- if (pv) app.rebuild(pv);
339
- }, POKE_FALLBACK_MS);
340
376
  return;
341
377
  }
342
- app.rebuild(v || undefined);
378
+ app.rebuild(v || undefined, { changed: files });
343
379
  }, 50);
344
380
  watcherAbort = new AbortController();
345
381
  // One warning per unwatchable path (#1521): an EACCES / ENOENT on a single
@@ -352,25 +388,55 @@ export async function startServer(opts) {
352
388
  warned.add(key);
353
389
  logger.warn(`file watcher skipped ${err && err.path ? err.path : 'a path'} (${err && err.code ? err.code : String(err)})`);
354
390
  };
391
+ let pollFallbackWarned = false;
392
+ const restartFile = supervisorRestartFile();
355
393
  const watchRoot = (root) => {
356
394
  // Output nothing serves (`*.log`, coverage/, ...) and the root's
357
395
  // `.gitignore` never reload the page: `npm run dev > dev.log` made every
358
396
  // log line a reload (watch-ignore.js).
359
397
  const unserved = createWatchIgnore(root);
360
398
  const close = watchTree(root, {
361
- 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)),
362
403
  onEvent: (filename) => {
404
+ const target = editorTempTarget(filename);
405
+ if (target) {
406
+ if (unserved(target) || !existsSync(resolve(root, target))) return;
407
+ filename = target;
408
+ }
363
409
  // A regenerate output (#967) is a build product the server itself
364
410
  // writes on request; ignoring it stops a spurious rebuild + reload
365
411
  // (and a reload -> refetch -> recompile -> reload cycle), same as the
366
412
  // db/dev.db carve-out above. `app` exposes the check because `state`
367
413
  // lives in createRequestHandler's scope, not here.
368
414
  if (app.isRegenerateOutput(filename)) return;
369
- 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));
370
425
  if (hotKey && needsHotReset(filename, (f) => !app.needsRegistryReset(f, root))) pendingPoke = true;
426
+ // A status wait (#1664) holds until the rebuild this event schedules.
427
+ app.noteChange?.();
371
428
  rebuild();
372
429
  },
373
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
+ },
374
440
  });
375
441
  watcherAbort.signal.addEventListener('abort', close, { once: true });
376
442
  };
@@ -423,21 +489,27 @@ export async function startServer(opts) {
423
489
  * then re-derive the analysis so it re-imports the app's modules (the
424
490
  * middleware, the components it primes) from that registry. A re-run a
425
491
  * poke asked for carries the verdict of the edits behind it and tells the
426
- * browser; any other is silent, since the watcher already sent that
427
- * 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.
428
496
  */
429
497
  rerun: async () => {
430
498
  await armHotSentinel(app.appDir);
431
- const pv = pokedVerdict;
432
- pokedVerdict = null;
433
- clearTimeout(pokeFallback);
434
- 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 });
435
502
  return owned;
436
503
  },
437
504
  poke: () => pokeHotSentinel(app.appDir),
438
505
  });
439
506
  announceHotInPlace();
440
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();
441
513
  return handle;
442
514
  } catch (e) {
443
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
+ }