@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
@@ -203,8 +203,8 @@ export const MIME = {
203
203
  * The transformed bytes are cached per request handler in `state.tsCache`
204
204
  * (a `Map<string, { mtimeMs, code, map }>`), bounded to `TS_CACHE_MAX`
205
205
  * entries. The cache is per-handler rather than module-global because the
206
- * cached code bakes in that handler's elision verdict, so two handlers for
207
- * the same app with different elision settings must not share it.
206
+ * cached code bakes in that handler's lazy-component set, so two handlers for
207
+ * the same app at different moments must not share it.
208
208
  */
209
209
  export const TS_CACHE_MAX = 500;
210
210
 
@@ -245,42 +245,6 @@ export function shouldAccessLog(pathname) {
245
245
  return !pathname.startsWith('/__webjs/');
246
246
  }
247
247
 
248
- /**
249
- * Auto-load `<appDir>/.env` into `process.env` once at boot. Mirrors
250
- * what Rails / Next / Astro do out of the box: a scaffolded app with
251
- * a committed `.env.example` and a developer-copied `.env` should
252
- * "just work" without the user having to add a dotenv import or set
253
- * the file path on the CLI.
254
- *
255
- * Uses Node 24+'s built-in `process.loadEnvFile`, which is dotenv-
256
- * compatible and DOES NOT override pre-existing `process.env` values.
257
- * Calls that hit a missing file or parse error are silenced; the
258
- * server should still come up cleanly when there's no `.env`.
259
- *
260
- * Idempotent: re-running is a no-op for any env var the user already
261
- * exported (e.g. via the host shell or a process manager). That
262
- * keeps the "shell-set wins over file" precedence Rails users
263
- * expect.
264
- *
265
- * Must run before any server-only module is loaded by
266
- * buildActionIndex, since module-init code in `lib/*.server.ts`
267
- * (e.g. `createAuth({ secret: process.env.AUTH_SECRET })`) reads
268
- * process.env at import time. createRequestHandler is the
269
- * single entry point where this is guaranteed.
270
- *
271
- * @param {string} appDir
272
- */
273
- export function loadAppEnv(appDir) {
274
- try {
275
- if (typeof process.loadEnvFile === 'function') {
276
- process.loadEnvFile(join(appDir, '.env'));
277
- }
278
- } catch {
279
- // No .env file, malformed file, or Node version without
280
- // loadEnvFile. Either way, fall through silently: the user
281
- // may not need any env vars, or they may set them via shell.
282
- }
283
- }
284
248
 
285
249
  /**
286
250
  * Walk the route table + component scanner to collect every file the
@@ -317,25 +281,6 @@ export function loadAppEnv(appDir) {
317
281
  * @param {string} appDir
318
282
  * @returns {Set<string>}
319
283
  */
320
- /**
321
- * Collect every page + layout file across the route table. These are the
322
- * modules the client boot script imports, and thus the candidates for
323
- * inert-route elision (dropping a module that does no client work).
324
- * `route.{js,ts}` / middleware / metadata are excluded: they never ship.
325
- *
326
- * @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
327
- * @returns {string[]}
328
- */
329
- export function collectRouteModules(routeTable) {
330
- /** @type {Set<string>} */
331
- const mods = new Set();
332
- for (const page of routeTable.pages || []) {
333
- if (page.file) mods.add(page.file);
334
- for (const f of page.layouts || []) mods.add(f);
335
- }
336
- return [...mods];
337
- }
338
-
339
284
  /**
340
285
  * List the app's top-level source directory names, for expanding a `#*`
341
286
  * catch-all import alias into one browser importmap prefix scope per dir (#555).
@@ -421,6 +366,21 @@ const RELOAD_WORKER_SRC = readFileSync(new URL('../dev-reload-worker.js', import
421
366
  const DEV_STYLES_SRC = readFileSync(new URL('../dev-styles.js', import.meta.url), 'utf8')
422
367
  .replace(/^export /gm, '');
423
368
 
369
+ // The component hot swap (#1689), same shared-source rule. It installs the
370
+ // template interner and the class patcher core reads, so the reload client
371
+ // must evaluate before the page's boot script (`ssr/head.js` orders them).
372
+ const DEV_HOT_CLIENT_SRC = readFileSync(new URL('../dev-hot-client.js', import.meta.url), 'utf8')
373
+ .replace(/^export /gm, '');
374
+
375
+ // The readiness probe every reload waits on (#893, #1689), same shared-source
376
+ // rule.
377
+ const DEV_READY_PROBE_SRC = readFileSync(new URL('../dev-ready-probe.js', import.meta.url), 'utf8')
378
+ .replace(/^export /gm, '');
379
+
380
+ // Why a full reload happened, printed after it (#1689), same shared-source rule.
381
+ const DEV_RELOAD_REASON_SRC = readFileSync(new URL('../dev-reload-reason.js', import.meta.url), 'utf8')
382
+ .replace(/^export /gm, '');
383
+
424
384
  // The embed host's reload hold (#1532), same shared-source rule.
425
385
  const DEV_RELOAD_HOLD_SRC = readFileSync(new URL('../dev-reload-hold.js', import.meta.url), 'utf8')
426
386
  .replace(/^export /gm, '');
@@ -481,10 +441,20 @@ export function reloadClientJs(bp) {
481
441
  const workerUrl = JSON.stringify(withBasePath('/__webjs/reload-worker.js', bp));
482
442
  const versionUrl = JSON.stringify(withBasePath('/__webjs/version', bp));
483
443
  return `// webjs dev reload client
444
+ ${DEV_HOT_CLIENT_SRC}
445
+ var __webjsHot = installHotClient(globalThis);
484
446
  ${DEV_OVERLAY_SRC}
485
447
  ${RELOAD_WORKER_SRC}
486
448
  ${DEV_STYLES_SRC}
487
449
  ${DEV_RELOAD_HOLD_SRC}
450
+ ${DEV_READY_PROBE_SRC}
451
+ ${DEV_RELOAD_REASON_SRC}
452
+ // Every full reload goes through here, so the reloaded page can say why
453
+ // (#1689); the reason a previous page stored is printed once, now.
454
+ function __webjsStore() { try { return globalThis.sessionStorage || null; } catch (_) { return null; } }
455
+ function __webjsInfo(msg) { console.info(msg); }
456
+ function __webjsReload(verdict) { reloadWithReason(verdict, location, __webjsStore(), __webjsInfo); }
457
+ printPendingReason(__webjsStore(), __webjsInfo);
488
458
  // A framing host holds reloads while its agent builds (#1532): the embed bridge
489
459
  // sets __webjsEmbedHold, every signal below is kept (strongest verdict wins),
490
460
  // and releasing the hold applies that one reload, or none if nothing changed.
@@ -499,54 +469,66 @@ function __webjsApplyError(data) {
499
469
  // frame that arrived before the URL advanced goes up once it does. The gate
500
470
  // itself lives inside renderDevOverlay, so __webjsApplyError needs no change.
501
471
  installDevOverlayNavSync();
502
- // Never reload INTO a server that is still restarting (Node's node --watch
503
- // briefly kills the process on an edit), which would paint a style-less,
504
- // half-rendered page (#893). Probe the lightweight /__webjs/version endpoint
505
- // until it answers, THEN reload. Under an in-process reload the server is up,
506
- // so the first probe passes and the reload is instant; under a restart the
507
- // probes fail until the fresh server is listening, so the old page stays put
508
- // until the new one is ready. Bounded, so a genuinely-dead server still
509
- // reloads (and shows the browser's own error) rather than hanging forever.
510
- function __webjsWhenReady(then) {
511
- var tries = 0;
512
- function attempt() {
513
- fetch(${versionUrl}, { cache: 'no-store' }).then(function (r) {
514
- if (r && r.ok) then(); else again();
515
- }).catch(again);
516
- }
517
- function again() { if (++tries > 100) location.reload(); else setTimeout(attempt, 100); }
518
- attempt();
472
+ // Never reload INTO a server that is still restarting (a restart file's edit,
473
+ // a crash), which would paint a style-less, half-rendered page (#893). Probe
474
+ // the lightweight /__webjs/version endpoint until it answers, THEN apply.
475
+ // Under an in-process reload the server is up, so the first probe passes and
476
+ // the apply is instant; under a restart the old page stays put until the new
477
+ // server is ready, however long that takes (#1689: it used to give up after
478
+ // about 10 seconds and reload into the browser's error page). One probe loop
479
+ // at a time, so a burst of signals during a restart joins it.
480
+ var __webjsWhenReady = createReadyProbe(function (u, o) { return fetch(u, o); }, ${versionUrl});
481
+ // A relay message's verdict as an object (#1689). A SharedWorker another tab
482
+ // started before an upgrade can still be alive and posts the bare verdict name,
483
+ // so a string becomes { v }, and anything unreadable is a full reload.
484
+ function __webjsVerdictOf(v) {
485
+ if (typeof v === 'string') return { v: v, by: '', why: '', modules: [], css: false };
486
+ return v && typeof v === 'object' ? v : { v: 'reload', by: '', why: 'unreadable-frame', modules: [], css: false };
519
487
  }
520
488
  // Apply a reload signal at the lightest correct weight (#1398). A page or
521
489
  // layout edit never changes browser-bound JS, so the server's re-render is the
522
490
  // complete truth and the client router can swap it in place: scroll and (for a
523
491
  // page edit) the hydrated state of components outside the changed region
524
- // survive. Anything else, including every signal the server could not classify,
525
- // is the full reload this always was.
492
+ // survive. A \`component\` verdict (#1689) re-imports the versioned component
493
+ // modules it lists, which patch the registered classes in place, so the live
494
+ // elements keep their state. A \`css\` verdict re-requests the stylesheets and
495
+ // swaps them in, with no re-render at all. Anything else, including every signal
496
+ // the server could not classify, is the full reload this always was.
526
497
  function __webjsApplyReload(verdict) {
527
498
  if (__webjsHold.offer(verdict)) return;
528
499
  __webjsWhenReady(function () {
500
+ var mods = verdict.modules || [];
501
+ if (verdict.v !== 'reload' && mods.length) { __webjsApplyHot(verdict, mods); return; }
502
+ if (verdict.v === 'css') {
503
+ // Every same-origin sheet, not just the edited file, so an @import-ed
504
+ // partial and a webjs.dev.regenerate output (Tailwind) come out right.
505
+ // The clone-swap keeps the working sheet when a re-request fails.
506
+ preloadStyles().then(function (styles) {
507
+ styles.commit();
508
+ console.info('[webjs] stylesheets refreshed' + (verdict.by ? ' (' + verdict.by + ')' : ''));
509
+ }).then(null, function () { __webjsReload({ why: 'styles-failed', by: verdict.by }); });
510
+ return;
511
+ }
529
512
  // Runtime feature detection, never an assumption. The refresh entry is
530
513
  // published by enableClientRouter and removed by disableClientRouter, so its
531
514
  // ABSENCE covers both no-router cases at once: an app that opted out with
532
515
  // \`webjs.clientRouter: false\`, and a page that ships no component at all so
533
516
  // @webjsdev/core never loads in the browser and the module never runs.
534
517
  var refresh = globalThis.__webjsRefreshPage;
535
- if ((verdict === 'page' || verdict === 'shell') && typeof refresh === 'function') {
536
- // Take any error overlay down BEFORE re-rendering. DEFENSIVE, and the
537
- // reasoning is worth recording because the obvious scenario for it does
538
- // NOT hold. The overlay's own teardown is keyed on the URL CHANGING,
539
- // which a same-url refresh never does, so a refresh has no exit for a
540
- // live overlay the way a full reload did (it replaced the document).
518
+ var mode = verdict.v;
519
+ if ((mode === 'page' || mode === 'shell') && typeof refresh === 'function') {
520
+ // Take any error overlay down BEFORE re-rendering. The overlay's own
521
+ // teardown is keyed on the URL CHANGING, which a same-url refresh never
522
+ // does, so a refresh has no exit for a live overlay the way a full reload
523
+ // did (it replaced the document).
541
524
  //
542
- // The scenario that looks like it needs this, a page-render error, is not
543
- // reachable: the dev 500 page carries no children boundaries and no
544
- // layout, so applying it shares no boundary with the live page and
545
- // degrades to a hard navigation, in BOTH directions. Breaking a page
546
- // reloads, and so does fixing it, and the reload clears the overlay. What
547
- // is left is the narrow case of an UNSCOPED frame (a \`rebuild\` or
548
- // \`ts-strip\` failure), which the nav sync deliberately never clears and
549
- // which can coexist with a page that still renders and so still refreshes.
525
+ // A page-render error is the main case (#1689). Its response is marked
526
+ // \`x-webjs-dev-error\`, so the refresh that hits it leaves the last good
527
+ // page on screen under the overlay instead of applying the error page,
528
+ // and the edit that FIXES it refreshes in place, which is when this
529
+ // takes the overlay down. An UNSCOPED frame (a \`rebuild\` or
530
+ // \`ts-strip\` failure), which the nav sync deliberately never clears, is
531
+ // the other.
550
532
  //
551
533
  // Before rather than after, so the ordering is self-correcting: if this
552
534
  // render also fails, the server pushes a fresh frame DURING it and the
@@ -558,16 +540,65 @@ function __webjsApplyReload(verdict) {
558
540
  // seconds, and markup swapped in before it lands paints a new class with
559
541
  // no rule. The old sheets stay applied until the swap, then go.
560
542
  preloadStyles().then(function (styles) {
561
- return refresh(verdict).then(function (ok) {
562
- if (!ok) { location.reload(); return; }
543
+ return refresh(mode).then(function (outcome) {
544
+ if (!outcome || outcome === 'failed') { __webjsReload({ why: 'refresh-failed', by: verdict.by }); return; }
563
545
  styles.commit();
546
+ __webjsLogRefresh(outcome, mode, verdict.by);
564
547
  });
565
- }).then(null, function () { location.reload(); });
548
+ }).then(null, function () { __webjsReload({ why: 'refresh-failed', by: verdict.by }); });
566
549
  return;
567
550
  }
568
- location.reload();
551
+ // The reloaded page says why (#1689): the server's reason (a component
552
+ // edit the hot swap refused names its guard, such as constructor-changed),
553
+ // or that this page has no client router to apply a refresh with.
554
+ __webjsReload(mode === 'page' || mode === 'shell' ? { why: 'no-client-router', by: verdict.by } : verdict);
569
555
  });
570
556
  }
557
+ // Apply a verdict that carries component modules (#1689): load the rebuilt
558
+ // stylesheets, re-import the modules (each re-define patches its registered
559
+ // class and re-renders the live elements), then re-render the page for a
560
+ // \`page\` / \`shell\` verdict in the same batch, then swap the sheets. A
561
+ // refused swap (a guard the hot client checks, a module that fails to load)
562
+ // reloads and says why.
563
+ function __webjsApplyHot(verdict, mods) {
564
+ var refresh = globalThis.__webjsRefreshPage;
565
+ var mode = verdict.v;
566
+ var by = verdict.by ? ' (' + verdict.by + ')' : '';
567
+ function reload(why) { __webjsReload({ why: why, by: verdict.by }); }
568
+ if ((mode === 'page' || mode === 'shell') && typeof refresh !== 'function') { reload('no-client-router'); return; }
569
+ // A page / shell refresh re-raises an error that still stands (see the
570
+ // refresh path above), so it may dismiss first. A component-only swap
571
+ // re-renders nothing on the server, so it takes down only an overlay the
572
+ // swap settled: the edited file, a re-imported module, or a rebuild error.
573
+ if (mode === 'page' || mode === 'shell') dismissDevOverlay();
574
+ else {
575
+ var bp = ${JSON.stringify(bp || '')};
576
+ dismissDevOverlayCoveredBy(mods.map(function (u) {
577
+ var p = String(u).split('?')[0];
578
+ return bp && p.indexOf(bp + '/') === 0 ? p.slice(bp.length) : p;
579
+ }).concat(verdict.by ? ['/' + verdict.by] : []));
580
+ }
581
+ preloadStyles().then(function (styles) {
582
+ return __webjsHot.applyModules(mods).then(function (r) {
583
+ if (!r.ok) { reload(r.why); return; }
584
+ var after = mode === 'page' || mode === 'shell' ? refresh(mode) : Promise.resolve('applied');
585
+ return after.then(function (outcome) {
586
+ if (!outcome || outcome === 'failed') { reload('refresh-failed'); return; }
587
+ styles.commit();
588
+ console.info('[webjs] hot-swapped ' + (r.tags.length ? r.tags.map(function (t) { return '<' + t + '>'; }).join(', ') : 'nothing') + by);
589
+ if (outcome === 'dev-error') __webjsLogRefresh(outcome, mode, verdict.by);
590
+ });
591
+ });
592
+ }).then(null, function (e) { reload(String((e && e.message) || e)); });
593
+ }
594
+ // The one console line an in-place refresh leaves (#1689). A refresh the dev
595
+ // server answered with a render error applied nothing, so it must not claim the
596
+ // page was updated: the last good render is still on screen under the overlay.
597
+ function __webjsLogRefresh(outcome, mode, by) {
598
+ var file = by ? ' (' + by + ')' : '';
599
+ if (outcome === 'dev-error') { console.info('[webjs] the page did not render, so the last good version stays under the error overlay' + file); return; }
600
+ console.info('[webjs] ' + (mode === 'shell' ? 'layout' : 'page') + ' updated in place' + file);
601
+ }
571
602
  function __webjsDirectEvents() {
572
603
  // No SharedWorker (Chrome for Android has none) or its construction threw (a
573
604
  // strict dev CSP with no worker-src). Run the SAME relay here in the tab over
@@ -594,7 +625,7 @@ function __webjsDirectEvents() {
594
625
  // dev-overlay bug leaves no trace, which would be a new behaviour rather
595
626
  // than a restored one.
596
627
  try {
597
- if (m.type === 'reload') __webjsApplyReload(m.verdict);
628
+ if (m.type === 'reload') __webjsApplyReload(__webjsVerdictOf(m.verdict));
598
629
  else if (m.type === 'webjs-error') __webjsApplyError(m.data);
599
630
  } catch (_) {
600
631
  console.error('[webjs] dev reload handler threw', _);
@@ -657,7 +688,7 @@ try {
657
688
  const w = new SharedWorker(${workerUrl});
658
689
  w.port.onmessage = (e) => {
659
690
  const m = e.data || {};
660
- if (m.type === 'reload') __webjsApplyReload(m.verdict);
691
+ if (m.type === 'reload') __webjsApplyReload(__webjsVerdictOf(m.verdict));
661
692
  else if (m.type === 'webjs-error') __webjsApplyError(m.data);
662
693
  };
663
694
  // The worker's SCRIPT can fail to load where the page itself loaded: a
@@ -36,12 +36,37 @@ import { pathToFileURL } from 'node:url';
36
36
  const HOSTS = Symbol.for('webjs.dev.hotHosts');
37
37
 
38
38
  /**
39
- * Sent to the `webjs dev` supervisor over IPC once the hot host is up, so it
40
- * stops restarting the process for plugin-served edits this server now reloads
41
- * in place. Mirrors `HOT_IN_PLACE_MESSAGE` in `@webjsdev/cli`'s `dev-reload.js`.
39
+ * Sent to the `webjs dev` supervisor over IPC once the server is up and able
40
+ * to reload app edits in place: the Bun hot host (plugin-served edits, #1575),
41
+ * or a Node server with the module resolve hook (`esm-hot-hooks.js`, #1689).
42
+ * The supervisor then restarts only for its restart files. Mirrors
43
+ * `HOT_IN_PLACE_MESSAGE` in `@webjsdev/cli`'s `dev-reload.js`.
42
44
  */
43
45
  export const HOT_IN_PLACE_MESSAGE = 'hot-reload-in-place';
44
46
 
47
+ /**
48
+ * Sent to the `webjs dev` supervisor to restart this server for an edit it
49
+ * cannot apply in place (#1689): a module the CommonJS loader holds, which no
50
+ * version reaches. The restart is the reload. Mirrors `HOT_RESTART_MESSAGE` in
51
+ * `@webjsdev/cli`'s `dev-reload.js`.
52
+ */
53
+ export const HOT_RESTART_MESSAGE = 'hot-reload-restart';
54
+
55
+ /**
56
+ * Ask the supervisor (when there is one) to restart for `path`, app-relative.
57
+ * @param {string} path
58
+ * @returns {boolean} whether the request was sent
59
+ */
60
+ export function requestSupervisorRestart(path) {
61
+ try {
62
+ if (typeof process.send === 'function' && process.connected) {
63
+ process.send({ webjs: HOT_RESTART_MESSAGE, path });
64
+ return true;
65
+ }
66
+ } catch { /* no supervisor, or it is gone */ }
67
+ return false;
68
+ }
69
+
45
70
  /** Tell the supervisor (when there is one) that edits reload in place. */
46
71
  export function announceHotInPlace() {
47
72
  try {
@@ -157,9 +182,11 @@ const INERT_ASSET = /\.(?:css|md|mdx|txt|log|map|png|jpe?g|gif|webp|avif|ico|svg
157
182
  * plugin, the rest through `dev/bun-app-source.js` or the source-locations
158
183
  * plugin), which `bun --hot` never watches.
159
184
  *
160
- * `isRouteOnly` answers whether a module path is one only the router loads
161
- * (nothing imports it; see `needsRegistryReset` on the request handler): its
162
- * dev re-import is keyed by content, so it needs no reset. Anything the
185
+ * `isRouteOnly` answers whether a module path needs no reset (see
186
+ * `needsRegistryReset` on the request handler): a module the dev plugins
187
+ * already served, which the module versions re-evaluate in place with its
188
+ * importers (#1689), or a module only the router loads, whose dev re-import is
189
+ * keyed by content. A new or deleted module still resets. Anything the
163
190
  * dev server cannot place, such as the extensionless temp file `sed -i`
164
191
  * renames over the real one, gets the reset, because the event may name the
165
192
  * temp file rather than the module it replaced.
@@ -0,0 +1,110 @@
1
+ import { mergeVerdicts } from '../dev-classify.js';
2
+
3
+ /**
4
+ * The edits a `bun --hot` registry reset has yet to apply (#1575, #1689).
5
+ *
6
+ * Under `bun --hot` an edit to a module some other module imports cannot be
7
+ * rebuilt straight away: the module registry has to be reset first, or the next
8
+ * request would still import the stale module. So the watcher POKES the re-run
9
+ * sentinel, keeps the batch here, and the re-run rebuilds with it. If the
10
+ * re-run does not arrive within `fallbackMs`, the fallback rebuilds anyway, so
11
+ * an edit is never lost to a sentinel `bun --hot` did not see.
12
+ *
13
+ * The race this owns: a large edit can make the re-run arrive AFTER the
14
+ * fallback. The fallback rebuilt and notified against the PRE-reset registry,
15
+ * so the browser applied the edit against stale modules, and the late re-run
16
+ * found nothing pending and rebuilt silently, so the tab never learned the
17
+ * fresh modules were there. The fallback's batch is therefore kept as `fired`,
18
+ * and a re-run with nothing pending rebuilds and notifies again with it.
19
+ *
20
+ * `fired` lives only `firedMs` (twice the fallback by default) past the
21
+ * fallback. A re-run that late is not the one the batch was waiting for: the
22
+ * reset was genuinely lost, and replaying the batch into a later, unrelated
23
+ * edit would merge its old verdict in (an old `reload` turning a page morph
24
+ * into a full reload) and bump its files' versions a second time, dropping
25
+ * server module state for a chain nobody touched.
26
+ *
27
+ * @param {{
28
+ * setTimeout?: (fn: () => void, ms: number) => unknown,
29
+ * clearTimeout?: (t: unknown) => void,
30
+ * fallbackMs?: number,
31
+ * firedMs?: number,
32
+ * }} [opts]
33
+ */
34
+ export function createPokeState(opts = {}) {
35
+ const set = opts.setTimeout || ((fn, ms) => setTimeout(fn, ms));
36
+ const clear = opts.clearTimeout || ((t) => clearTimeout(/** @type {any} */ (t)));
37
+ const fallbackMs = opts.fallbackMs ?? 1500;
38
+ const firedMs = opts.firedMs ?? fallbackMs * 2;
39
+ /** @type {import('../dev-classify.js').ReloadVerdict | null} */
40
+ let verdict = null;
41
+ /** @type {Set<string>} */
42
+ let changed = new Set();
43
+ /** @type {{ verdict: import('../dev-classify.js').ReloadVerdict | null, changed: string[] } | null} */
44
+ let fired = null;
45
+ /** @type {unknown} */
46
+ let timer = null;
47
+ /** @type {unknown} */
48
+ let firedTimer = null;
49
+ const dropFired = () => {
50
+ clear(firedTimer);
51
+ firedTimer = null;
52
+ fired = null;
53
+ };
54
+
55
+ return {
56
+ /**
57
+ * Keep a batch for the coming re-run and (re)arm the fallback.
58
+ * @param {import('../dev-classify.js').ReloadVerdict | null | undefined} v
59
+ * @param {Iterable<string>} files
60
+ * @param {(verdict: import('../dev-classify.js').ReloadVerdict | null, changed: string[]) => void} onFallback
61
+ */
62
+ poke: function (v, files, onFallback) {
63
+ verdict = mergeVerdicts(verdict, v || null);
64
+ for (const f of files) changed.add(f);
65
+ // A fresh poke inside the fired batch's window supersedes it: the
66
+ // coming re-run carries both, so the browser hears once, at the end.
67
+ if (fired) {
68
+ verdict = mergeVerdicts(fired.verdict, verdict);
69
+ for (const f of fired.changed) changed.add(f);
70
+ dropFired();
71
+ }
72
+ clear(timer);
73
+ timer = set(() => {
74
+ timer = null;
75
+ const batch = { verdict, changed: [...changed] };
76
+ verdict = null;
77
+ changed = new Set();
78
+ dropFired();
79
+ fired = batch;
80
+ firedTimer = set(dropFired, firedMs);
81
+ onFallback(batch.verdict, batch.changed);
82
+ }, fallbackMs);
83
+ },
84
+
85
+ /**
86
+ * What a registry-reset re-run should rebuild with: the pending batch when
87
+ * there is one, else the batch the fallback already applied against the old
88
+ * registry (notify again so the tab picks up the fresh modules), else
89
+ * nothing, a silent re-derive.
90
+ * @returns {{ verdict: import('../dev-classify.js').ReloadVerdict | null, changed: string[], notify: boolean }}
91
+ */
92
+ takeForRerun: function () {
93
+ clear(timer);
94
+ timer = null;
95
+ if (verdict || changed.size) {
96
+ const out = { verdict, changed: [...changed], notify: !!verdict };
97
+ verdict = null;
98
+ changed = new Set();
99
+ dropFired();
100
+ return out;
101
+ }
102
+ if (fired) {
103
+ const out = { verdict: fired.verdict, changed: fired.changed, notify: !!fired.verdict };
104
+ dropFired();
105
+ return out;
106
+ }
107
+ return { verdict: null, changed: [], notify: false };
108
+ },
109
+ };
110
+ }