@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
@@ -4,7 +4,7 @@ import { registerBunAppSource } from './bun-app-source.js';
4
4
  import { evictPoisonedBunTranspilerCache } from '../bun-transpiler-cache.js';
5
5
  import { serverRuntime } from '../listener-core.js';
6
6
  import { createHash } from 'node:crypto';
7
- import { join, relative, resolve, sep } from 'node:path';
7
+ import { dirname, join, relative, resolve, sep } from 'node:path';
8
8
 
9
9
  // Server-side `.ts` imports are handled natively by Node 24+'s default
10
10
  // type-stripping (`process.features.typescript === 'strip'`) or by Bun. The
@@ -16,32 +16,47 @@ import { generateRouteTypes } from '../route-types.js';
16
16
  import { setClientRouterEnabled, setMetadataIconRoutes } from '../ssr.js';
17
17
  import {
18
18
  buildActionIndex,
19
+ isServerFile,
20
+ hasUseServerDirective,
19
21
  } from '../actions.js';
20
22
  import { registerActionHooks } from '../action-seed.js';
23
+ import { installEsmHotHooks, installBunModuleVersions, isServedModule, bumpModuleVersions, movedServedModules, diskStamp, isCommonJsModule } from './esm-hot-hooks.js';
24
+ import { createBrowserVersions, shippedImporters } from './specifier-versions.js';
21
25
  import { sourceLocationsRequested } from '../dev-source-locations.js';
22
26
  import { registerSourceLocationHook } from '../dev-source-locations-hook.js';
23
27
  import { readRegenerateRules, isRegenerateOutputPath } from '../dev-regenerate.js';
24
- import { classifyChangedPath } from '../dev-classify.js';
25
- import { ensureStripper } from '../ts-strip.js';
28
+ import { classifyChangedPath, mergeVerdicts } from '../dev-classify.js';
29
+ import { ensureStripper, stripTypeScript } from '../ts-strip.js';
30
+ import { hotSignature, findHotBoundaries, primeHotBaselines } from '../dev-hot-classify.js';
31
+ import { toUrlPath } from '../ssr/preloads.js';
26
32
  import { defaultLogger } from '../logger.js';
27
33
  import { assertNodeVersion } from '../node-version.js';
28
34
  import { applyEnvValidation } from '../env-schema.js';
29
- import { runInstrumentation } from '../instrumentation.js';
30
- import { withRequest, setCspNonce, setBodyLimits, setRequestId, requestId as getRequestId } from '../context.js';
35
+ import { runInstrumentation, buildRequestErrorReport } from '../instrumentation.js';
36
+ import { validateAppLayout, AppLayoutError, cssImportProblems } from '../app-layout.js';
37
+ import { applyPendingCookies } from '../cookie-store.js';
38
+ import { loadRootMiddleware, findRootMiddleware } from '../middleware-load.js';
39
+ import { loadEnvFiles, envMode } from '../env-files.js';
40
+ import { buildPublicIndex } from '../public-files.js';
41
+ import { withRequest, setCspNonce, setBodyLimits, setAllowedOrigins, setRequestId, requestId as getRequestId, currentCookieStore, setComponentErrorSink, sealCookies } from '../context.js';
31
42
  import { buildInfoResponse } from '../build-info.js';
32
43
  import { mintNonce, buildCspHeader, cspHeaderName } from '../csp.js';
33
44
  import { propagateTrustedRemoteIp } from '../rate-limit.js';
34
45
  import { reachedBareImports, resolveVendorImports, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from '../vendor.js';
35
46
  import { browserEntryFiles } from '../browser-entries.js';
36
- import { buildModuleGraph, dynamicEdges, seenFilesFor, appImportsMap, reachableFromEntries, resolveImport } from '../module-graph.js';
47
+ import { buildModuleGraph, dynamicEdges, seenFilesFor, appImportsMap, reachableFromEntries, resolveImport, bareImports, dynamicBareImports } from '../module-graph.js';
48
+ import { foreignImportProblems, registerForeignImportGuard } from '../foreign-imports.js';
37
49
  import { primeComponentRegistry, findOrphanComponents, scanComponents } from '../component-scanner.js';
38
- import { analyzeElision, lazyComponentFiles, bindingImportedLazyFiles } from '../component-elision.js';
50
+ import { lazyComponentFiles, bindingImportedLazyFiles } from '../lazy-components.js';
51
+ import { routeModuleBrowserHints } from '../client-signals.js';
52
+ import { routeFrontiers, collectBootRouteModules, routeTableFiles, leadsToComponent } from '../route-frontier.js';
53
+ import { hasModuleScopeSideEffect } from '../module-side-effect.js';
39
54
 
40
55
  import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from '../importmap.js';
41
56
  import { stripBasePath, withBasePath } from '../base-path.js';
42
57
  import { setAssetUrlProvider, setFormActionResolver } from '@webjsdev/core';
43
58
  import { resolveActionIdentity } from '../form-action-identity.js';
44
- import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, resolveAssetUrl } from '../asset-hash.js';
59
+ import { setAssetRoots, clearAssetHashCache, setServeFingerprint, withAssetHash, assetHashFor, resolveAssetUrl, setDevModuleVersions } from '../asset-hash.js';
45
60
  import { bumpBuildEpoch } from '../build-epoch.js';
46
61
  import { hintedBootModules } from '../ssr/shipped-modules.js';
47
62
  import { createRetryBackoff } from './vendor-retry.js';
@@ -52,14 +67,13 @@ import {
52
67
  applyTrailingSlash,
53
68
  } from '../redirects.js';
54
69
  import { applyConditionalGet } from '../conditional-get.js';
55
- import { commitHtmlCache, setAppSourceFingerprint } from '../html-cache.js';
70
+ import { commitHtmlCache, revalidateAll, setAppSourceFingerprint } from '../html-cache.js';
56
71
  import { buildDevErrorFrame } from '../dev-error.js';
57
72
  import { isControlFlowThrow } from '../action-error.js';
58
73
  import { createDevStatus } from './dev-status.js';
59
74
  import { devReloadState } from '../dev-reload-state.js';
60
75
 
61
76
  import {
62
- readElideEnabled,
63
77
  readSeedEnabled,
64
78
  readClientRouterEnabled,
65
79
  readHeaderRules,
@@ -73,20 +87,21 @@ import {
73
87
  readDevToolingFromApp,
74
88
  } from './config.js';
75
89
  import {
76
- exists, kebab, resolveRequestId, shouldAccessLog, loadAppEnv, collectRouteModules,
90
+ exists, kebab, resolveRequestId, shouldAccessLog,
77
91
  appTopLevelDirs, locateCoreDir,
78
92
  setReloadIdleMs,
79
93
  resolveReloadIdleMs,
80
94
  } from './helpers.js';
81
95
  import {
82
- handleCore, loadMiddleware, tryServeFrameworkStatic, tryServePublicAsset,
96
+ handleCore, tryServeFrameworkStatic, tryServePublicAsset,
83
97
  } from './serve.js';
84
98
  import { devImport, clearProdImportCache } from '../dev-import.js';
99
+ import { retireFlashOnRedirect } from '../action-flash.js';
85
100
 
86
101
  /**
87
102
  * A short content digest of a single file's bytes for the app-source deploy
88
103
  * signal (#899). Raw `sha256(bytes)`, deliberately independent of `assetHashFor`
89
- * (which folds the elision fingerprint and memoizes for `?v` emission): the
104
+ * (which folds the serve fingerprint and memoizes for `?v` emission): the
90
105
  * signal only needs "did the source bytes change", and server-only files never
91
106
  * enter the asset-hash memo anyway. Returns `''` on a read failure, so a
92
107
  * transient error degrades the id rather than throwing during analysis.
@@ -152,7 +167,8 @@ export async function createRequestHandler(opts) {
152
167
  // scaffolded apps with a committed .env.example + .env would fail
153
168
  // to boot until the user discovered the missing env-load. See
154
169
  // tracker #37.
155
- loadAppEnv(appDir);
170
+ // Next.js's env file order (.env.$mode.local, .env.local, .env.$mode, .env).
171
+ loadEnvFiles(appDir, envMode(!!opts.dev));
156
172
  // Optional boot-time env validation (#236). If <appDir>/env.{js,ts} exists it
157
173
  // default-exports a typed schema or a custom validator function; we run it
158
174
  // against process.env (now populated by loadAppEnv) BEFORE buildActionIndex
@@ -180,7 +196,7 @@ export async function createRequestHandler(opts) {
180
196
  // before traffic. Any error sink it registers via setOnError composes with
181
197
  // the programmatic opts.onError (both fire). Fail-open (a throwing hook is
182
198
  // logged, not fatal).
183
- const { onError: instrumentationOnError } = await runInstrumentation(appDir, { dev, logger });
199
+ const { onError: instrumentationOnError, onRequestError } = await runInstrumentation(appDir, { dev, logger });
184
200
  // APM / Sentry integration point (issue #239). Called whenever the request
185
201
  // pipeline catches an unhandled error: the top-level handle() catch (the
186
202
  // last-resort 500), an unexpected throw inside the produce() funnel, or a
@@ -234,7 +250,14 @@ export async function createRequestHandler(opts) {
234
250
  devStatus.record(frame || buildDevErrorFrame(error, { kind, appDir, url }));
235
251
  } catch { /* best effort */ }
236
252
  }
237
- const hasOnError = onErrorSinks.length > 0;
253
+ const hasOnError = onErrorSinks.length > 0 || typeof onRequestError === 'function';
254
+ /** Each error object is reported once PER REQUEST, under the first (most
255
+ * specific) phase that reaches it: a route handler's throw is reported as
256
+ * `route` before the top-level catch sees the same error again. Keyed by the
257
+ * request id rather than held forever, so an error object a module throws
258
+ * again on every request (a rejected module-scope promise) is reported on
259
+ * each one. @type {WeakMap<object, string | null>} */
260
+ const reportedErrors = new WeakMap();
238
261
 
239
262
  /**
240
263
  * Invoke every registered onError sink defensively. The phase is a coarse
@@ -242,13 +265,27 @@ export async function createRequestHandler(opts) {
242
265
  * @param {unknown} error
243
266
  * @param {Request} request
244
267
  * @param {string} phase
268
+ * @param {string} [routePath] the route file path for `onRequestError`
245
269
  */
246
- function reportError(error, request, phase) {
270
+ function reportError(error, request, phase, routePath) {
247
271
  // Dev status (#1664). Recorded BEFORE the no-sink return, because an app
248
272
  // with no onError sink is exactly the one whose action errors were
249
273
  // invisible to an agent. An `ssr` error is a render failure.
250
274
  recordDevStatus(error, request, phase === 'ssr' ? 'render' : phase);
251
275
  if (!hasOnError) return;
276
+ if (error && typeof error === 'object') {
277
+ const rid = getRequestId();
278
+ if (reportedErrors.has(error) && reportedErrors.get(error) === rid) return;
279
+ reportedErrors.set(error, rid);
280
+ }
281
+ // Next.js's instrumentation `onRequestError(error, request, context)`.
282
+ // Fire-and-forget like Next: an awaited reporter never delays the response,
283
+ // and a throwing or rejecting one is logged, never surfaced.
284
+ if (typeof onRequestError === 'function' && !isControlFlowThrow(error)) {
285
+ buildRequestErrorReport(error, request, phase, routePath)
286
+ .then((args) => onRequestError(...args))
287
+ .catch((e) => logger.error?.('[webjs] onRequestError threw (ignored)', { err: String(e) }));
288
+ }
252
289
  const ctx = { request, requestId: getRequestId(), phase };
253
290
  for (const sink of onErrorSinks) {
254
291
  try {
@@ -332,7 +369,7 @@ export async function createRequestHandler(opts) {
332
369
 
333
370
  // Install the resolver behind the isomorphic `asset()` helper (#1194), the
334
371
  // same provider seam `cspNonce()` uses. An author writes
335
- // `href=${asset('/public/app.css')}` and gets the fingerprinted url on the
372
+ // `href=${asset('/app.css')}` and gets the fingerprinted url on the
336
373
  // server; the browser has no provider and returns the path unchanged.
337
374
  //
338
375
  // The `public/` gate is defensive rather than decorative. `resolveUrlToFile`
@@ -354,7 +391,20 @@ export async function createRequestHandler(opts) {
354
391
  // `webjs.seed` switch. Action identity (#1155) rides the same hook, and it is
355
392
  // what a bound `<form action=${action}>` resolves through, so gating the hook
356
393
  // on seeding would mean `webjs.seed: false` silently broke every no-JS form.
394
+ //
395
+ // Before it, in dev on Node, the resolve hook that versions edited app
396
+ // modules (#1689), so a rebuild re-evaluates an edit and its importers in
397
+ // this process instead of the supervisor restarting it. Same timing rule: it
398
+ // has to be in place before the first app module is imported. Module URLs
399
+ // are realpaths, so a symlinked app dir registers both forms.
400
+ const hotModules = dev && serverRuntime() === 'node' && installEsmHotHooks(appDir);
401
+ if (hotModules) {
402
+ try { const real = realpathSync(appDir); if (real !== appDir) installEsmHotHooks(real); } catch { /* appDir only */ }
403
+ }
357
404
  await registerActionHooks({ seed: await readSeedEnabled(appDir), dev });
405
+ // Refuse `next/*`, `react` and `lit` imported from the app's own files at
406
+ // resolve time (Node), even when the package happens to be installed.
407
+ registerForeignImportGuard(appDir);
358
408
 
359
409
  // Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1` (or
360
410
  // `webjs.dev.sourceLocations: true`) under `webjs dev`
@@ -372,10 +422,17 @@ export async function createRequestHandler(opts) {
372
422
  }
373
423
  // Bun under `bun --hot` serves a replaced file's old source forever (#1575):
374
424
  // load app modules through a plugin that reads them fresh. After the
375
- // source-locations plugin, so that one wins when it is on.
376
- if (dev && serverRuntime() === 'bun') {
425
+ // source-locations plugin, so that one wins when it is on. The plugins also
426
+ // stamp the module versions (#1689), Bun's stand-in for the Node resolve
427
+ // hook, so an edit re-evaluates its chain without a registry reset.
428
+ const bunVersions = dev && serverRuntime() === 'bun';
429
+ if (bunVersions) {
377
430
  registerBunAppSource(appDir);
378
- try { const real = realpathSync(appDir); if (real !== appDir) registerBunAppSource(real); } catch { /* appDir only */ }
431
+ installBunModuleVersions(appDir);
432
+ try {
433
+ const real = realpathSync(appDir);
434
+ if (real !== appDir) { registerBunAppSource(real); installBunModuleVersions(real); }
435
+ } catch { /* appDir only */ }
379
436
  }
380
437
 
381
438
  // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
@@ -387,10 +444,10 @@ export async function createRequestHandler(opts) {
387
444
  // rendered deterministically at runtime). Pinning stays optional; an unpinned
388
445
  // app does no vendor work at boot and publishes its id after the first
389
446
  // successful resolve instead. Either way the EXPENSIVE analysis (graph, scan,
390
- // gate, elision) and the UNPINNED jspm resolve stay deferred to the first
447
+ // gate, frontiers) and the UNPINNED jspm resolve stay deferred to the first
391
448
  // request, so #143's win is intact; only the cheap committed-file read moves
392
449
  // back to boot, and only when a VALID pin exists. A committed pin file is
393
- // served as-is (elision never prunes it), so the boot-resolved map equals the
450
+ // served as-is (nothing prunes it), so the boot-resolved map equals the
394
451
  // final served map and the published id is authoritative.
395
452
  //
396
453
  // Validate the pin with readPinFile BEFORE treating the app as pinned-at-boot.
@@ -416,13 +473,25 @@ export async function createRequestHandler(opts) {
416
473
  }
417
474
 
418
475
  // Whole-app analysis (module graph, component scan, browser-bound gate,
419
- // action index, middleware, elision, vendor) is NOT run at boot. It is
476
+ // action index, middleware, frontiers, vendor) is NOT run at boot. It is
420
477
  // computed on the first request via ensureReady() below and memoized, so the
421
478
  // server starts without walking or reading the app's source, executing any
422
479
  // server module, or hitting the network. Only the route table is built
423
480
  // eagerly: it is a cheap directory scan (no code reads), and routing, Early
424
481
  // Hints, and WebSocket lookups need it available before the first request.
425
482
  const routeTable = await buildRouteTable(appDir);
483
+ // A file arrangement that can never serve correctly (a page and a route.ts
484
+ // at one URL, ...) refuses to boot in production, as Next refuses to build.
485
+ // Dev keeps serving so an edit can fix it: ensureReady re-checks on every
486
+ // rebuild and answers each request with the problem until then.
487
+ // Every file under public/, by the root url it is served at (public-files.js).
488
+ const publicFiles = buildPublicIndex(appDir);
489
+ if (!dev) {
490
+ const problems = validateAppLayout(appDir, routeTable, publicFiles);
491
+ if (problems.length) throw new AppLayoutError(problems);
492
+ // Both a middleware.* and a proxy.* at the root: also refused at boot.
493
+ await findRootMiddleware(appDir);
494
+ }
426
495
  // Auto-linked favicons: tell the head builder which icon metadata routes
427
496
  // exist, so `app/icon.*` is linked when the app declares no metadata.icons.
428
497
  // Bound here rather than threaded through ssrOpts, matching
@@ -494,6 +563,7 @@ export async function createRequestHandler(opts) {
494
563
 
495
564
  const state = {
496
565
  routeTable,
566
+ publicFiles,
497
567
  actionIndex: null,
498
568
  middleware: null,
499
569
  bodyLimits,
@@ -507,21 +577,26 @@ export async function createRequestHandler(opts) {
507
577
  // the core / vendor serving are unchanged, so no server source is exposed.
508
578
  testMode: opts.testMode === true,
509
579
  moduleGraph: null,
510
- elidableComponents: new Set(),
511
- inertRouteModules: new Set(),
512
- importOnlyRouteModules: new Map(),
580
+ // Route module -> the component files the boot imports in its place
581
+ // (#1686). Route modules never ship; see `route-frontier.js`.
582
+ /** @type {Map<string, string[]>} */
583
+ routeFrontiers: new Map(),
584
+ /** @type {Set<string>} */
585
+ routeModuleFiles: new Set(),
586
+ /** @type {Array<{ file: string, message: string }>} dev-only route-module hints (#1686) */
587
+ routeHints: [],
513
588
  // `static lazy` component files -> their tags (#1524): a side-effect import
514
589
  // of one is served as a lazy-loader registration, and the preload walks
515
- // skip it. Independent of the elision switch.
590
+ // skip it.
516
591
  lazyComponentFiles: new Map(),
517
- // The browser-bound ENTRY files (pages / layouts / boundaries / components),
592
+ // The browser-bound ENTRY files (components and instrumentation-client),
518
593
  // kept alongside the expanded gate set because the vendor scan roots at the
519
594
  // entries rather than at the closure.
520
595
  browserEntryFiles: new Set(),
521
596
  browserBoundFiles: null,
522
597
  // Dev live-reload classification (#1398). `shippedFiles` is the transitive
523
598
  // closure of what the browser can ACTUALLY load (the gate set minus every
524
- // module elision drops), `graphFiles` is every app source the graph walked,
599
+ // module the frontier walk skips), `graphFiles` is every app source the graph walked,
525
600
  // and `pageFiles` is the route table's pages. Together they are the whole
526
601
  // input to `classifyChangedPath`, so a watch event can be answered without
527
602
  // reaching into the rest of this state. Dev-only; left empty in prod.
@@ -531,11 +606,28 @@ export async function createRequestHandler(opts) {
531
606
  graphFiles: new Set(),
532
607
  /** @type {Set<string>} */
533
608
  pageFiles: new Set(),
534
- // Transformed-source cache (stripped TS + applied elision). Per-handler,
535
- // NOT module-global: the cached bytes bake in THIS handler's elision
536
- // verdict, so two handlers for the same app with different elision
537
- // settings (a multi-tenant embedder, or the differential elision test)
538
- // must not share it, or the second would serve the first's elided source.
609
+ // Component files from the scan, for the hot-swap boundary walk (#1689).
610
+ /** @type {Set<string>} */
611
+ componentFiles: new Set(),
612
+ // Browser module versions (#1689): a rebuild bumps the edited shipped
613
+ // files and their importers, and the served source of every app module
614
+ // versions its imports of them. Never cleared, so a version only grows.
615
+ browserVersions: createBrowserVersions(),
616
+ // The hot-swap signature (`dev-hot-classify.js`) of each shipped component
617
+ // file as the open tabs hold it (#1689): set from disk at the first
618
+ // analysis, then from every edit's source, since a tab applies every
619
+ // verdict. Never cleared.
620
+ /** @type {Map<string, import('../dev-hot-classify.js').HotSignature | Promise<import('../dev-hot-classify.js').HotSignature | null>>} */
621
+ hotSignatures: new Map(),
622
+ // The disk stamp of every shipped module as the last analysis found it, on
623
+ // Bun only (#1689): a rebuild adds each one that moved since to its changed
624
+ // files, since Bun's `fs.watch` can drop the event (see `missedChanges`).
625
+ /** @type {Map<string, string>} */
626
+ shippedStamps: new Map(),
627
+ // Transformed-source cache (stripped TS + the lazy-import rewrite).
628
+ // Per-handler, NOT module-global: the cached bytes bake in THIS handler's
629
+ // lazy set and source-location setting, so two handlers for the same app
630
+ // must not share it.
539
631
  tsCache: new Map(),
540
632
  // Dev source locations (#1499), fixed for the handler's lifetime, so the
541
633
  // per-handler tsCache above never mixes annotated and plain bytes.
@@ -561,7 +653,11 @@ export async function createRequestHandler(opts) {
561
653
  // process running several handlers the fallback therefore resolves against
562
654
  // the most recently booted one, which is the same scope the asset-url
563
655
  // provider already has.
564
- setFormActionResolver((fn) => (state.actionIndex ? resolveActionIdentity(state.actionIndex, fn) : null));
656
+ setFormActionResolver((fn) => (state.actionIndex ? resolveActionIdentity(state.actionIndex, fn, { dev }) : null));
657
+ // The boot script, the modulepreload hints and the lazy-loader map name a
658
+ // bumped module with the same `?t=` its importers' rewritten specifiers
659
+ // carry (#1689), so a fresh document holds one instance of it, not two.
660
+ setDevModuleVersions(dev ? (abs) => state.browserVersions.versionOf(abs) : null);
565
661
 
566
662
  /**
567
663
  * Report a dev error (#264): build a frame and push it to the open tab via
@@ -589,7 +685,7 @@ export async function createRequestHandler(opts) {
589
685
  // All whole-app analysis is built lazily on the first request, memoized so
590
686
  // boot does none of it. It runs in two stages. The deterministic analysis
591
687
  // (module graph, component scan + prime, browser-bound gate, action index,
592
- // middleware, elision) is network-free and, once built, never re-runs unless
688
+ // middleware, frontiers) is network-free and, once built, never re-runs unless
593
689
  // a rebuild invalidates it; readiness gates on it. Vendor resolution is a
594
690
  // SEPARATE, best-effort stage: a pinned app reads a committed importmap file,
595
691
  // an unpinned app auto-fetches from jspm. It does NOT gate readiness, so an
@@ -611,7 +707,7 @@ export async function createRequestHandler(opts) {
611
707
  let analysisEverDone = false;
612
708
  // A pinned app applied its FULL vendor map and published the build id at boot
613
709
  // (above). The deferred vendor stage still runs once (and after every rebuild)
614
- // to PRUNE that map to the elision-reachable specifiers, so a pinned app serves
710
+ // to PRUNE that map to the browser-reachable specifiers, so a pinned app serves
615
711
  // the same map an unpinned one does (#197); it does not re-publish the build id
616
712
  // (the boot hash stays the deploy fingerprint). An unpinned app starts false and
617
713
  // resolves live on the first request.
@@ -672,81 +768,117 @@ export async function createRequestHandler(opts) {
672
768
  const now = () => performance.now();
673
769
  try {
674
770
  if (!analysisDone) {
771
+ const layoutProblems = validateAppLayout(appDir, state.routeTable, state.publicFiles);
772
+ if (layoutProblems.length) throw new AppLayoutError(layoutProblems);
675
773
  let m = now();
676
774
  state.moduleGraph = await buildModuleGraph(appDir);
775
+ // A foreign-framework import (next/*, react, lit) anywhere in the
776
+ // app is refused before anything renders: the dev overlay shows
777
+ // it, production readiness stays 503 naming it.
778
+ {
779
+ const relApp = (p) => relative(appDir, p).split(sep).join('/');
780
+ const foreign = [
781
+ ...foreignImportProblems(new Map([...bareImports(state.moduleGraph), ...dynamicBareImports(state.moduleGraph)]), relApp),
782
+ // `import './x.css'` too: a bundler feature with no meaning here.
783
+ ...cssImportProblems(state.moduleGraph, relApp),
784
+ ];
785
+ if (foreign.length) throw new AppLayoutError(foreign);
786
+ }
677
787
  t.graph = now() - m; m = now();
678
788
  const components = await scanComponents(appDir);
679
789
  await primeComponentRegistry(appDir, components);
680
790
  t.scan = now() - m; m = now();
681
791
  state.browserEntryFiles = browserEntryFiles(state.routeTable, components);
682
- state.browserBoundFiles = reachableFromEntries(state.moduleGraph, [...state.browserEntryFiles], appDir);
792
+ // Route-table files never load in the browser (#1686): the gate
793
+ // stops at them and the serve branch refuses them by path.
794
+ state.routeModuleFiles = routeTableFiles(state.routeTable);
795
+ state.browserBoundFiles = reachableFromEntries(state.moduleGraph, [...state.browserEntryFiles], appDir,
796
+ state.routeModuleFiles);
683
797
  t.gate = now() - m; m = now();
684
798
  state.actionIndex = await buildActionIndex(appDir, dev);
685
799
  t.actions = now() - m; m = now();
686
- state.middleware = await loadMiddleware(appDir, dev, logger);
800
+ // Fails CLOSED (middleware-load.js): a root middleware or proxy
801
+ // that cannot load stops the app serving rather than letting
802
+ // every request through unguarded.
803
+ try {
804
+ state.middleware = await loadRootMiddleware(appDir, dev, logger);
805
+ } catch (e) {
806
+ if (e && /** @type {any} */ (e).code === 'WEBJS_APP_LAYOUT') throw e;
807
+ const err = new Error(`[webjs] the root middleware failed to load, so no request is served (a guard that cannot load must not let requests through): ${/** @type {Error} */ (e).message}`, { cause: e });
808
+ /** @type {any} */ (err).code = 'WEBJS_APP_LAYOUT';
809
+ throw err;
810
+ }
687
811
  t.middleware = now() - m; m = now();
688
812
  // Client-router opt-out (#629): re-read each pass so toggling
689
813
  // `webjs.clientRouter` takes effect on rebuild without a restart.
690
814
  setClientRouterEnabled(await readClientRouterEnabled(appDir));
691
- // Read the switch ONCE: the dev summary below reports it too, and
692
- // calling readElideEnabled twice per warm re-reads package.json.
693
- const elideOn = await readElideEnabled(appDir);
694
- const r = elideOn
695
- ? await analyzeElision(components, collectRouteModules(state.routeTable),
696
- state.moduleGraph, (f) => readFile(f, 'utf8'), appDir)
697
- : { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map(),
698
- shippedRouteModules: new Map(), componentVerdicts: new Map() };
699
- state.elidableComponents = r.elidableComponents;
700
- state.inertRouteModules = r.inertRouteModules;
701
- state.importOnlyRouteModules = r.importOnlyRouteModules;
702
- state.lazyComponentFiles = lazyComponentFiles(components, state.elidableComponents);
815
+ // Route modules never ship (#1686): each is replaced in the boot by
816
+ // its component frontier. Every component ships, as in Lit; a
817
+ // component in a `.server` file is a stub in the browser, so the walk
818
+ // stops there. Every route-table file is in the skip set, so a route
819
+ // module is never emitted or walked through even when another one
820
+ // imports it.
821
+ {
822
+ const routeFiles = collectBootRouteModules(state.routeTable);
823
+ state.routeFrontiers = routeFrontiers(routeFiles, state.moduleGraph,
824
+ new Set(components.map((c) => c.file)),
825
+ state.routeModuleFiles);
826
+ }
827
+ // Dev only: code in a route module that looks meant for the browser
828
+ // but runs only on the server (#1686), reported by `webjs status` as
829
+ // warnings so an agent sees it in the loop it already runs.
830
+ if (dev) {
831
+ const componentFileSet = new Set(components.map((c) => c.file));
832
+ const carriesComponent = leadsToComponent(state.moduleGraph, componentFileSet);
833
+ /** @type {Array<{ file: string, message: string }>} */
834
+ const hints = [];
835
+ for (const f of collectBootRouteModules(state.routeTable)) {
836
+ let src;
837
+ try { src = await readFile(f, 'utf8'); } catch { continue; }
838
+ for (const message of routeModuleBrowserHints(src, {
839
+ file: f, appDir, isComponent: (abs) => componentFileSet.has(abs), carriesComponent,
840
+ })) hints.push({ file: f, message });
841
+ }
842
+ state.routeHints = hints;
843
+ }
844
+ state.lazyComponentFiles = lazyComponentFiles(components);
703
845
  // A lazy component some module imports WITH bindings loads eagerly
704
846
  // anyway, so it keeps its preload hint and its imports as written.
705
847
  for (const f of await bindingImportedLazyFiles(state.lazyComponentFiles, state.moduleGraph,
706
848
  (f) => readFile(f, 'utf8'), resolveImport, appDir)) state.lazyComponentFiles.delete(f);
707
849
  // Dev live-reload classification (#1398): derive the three sets the
708
850
  // watch-event classifier reads. `browserBoundFiles` above is the
709
- // AUTHORIZATION gate (it walks from every entry, elided ones
710
- // included, because an elided module's URL must still 404 rather
711
- // than leak), so it is the wrong input here: the question is what
712
- // the browser actually HOLDS. Re-walk from the entries elision
713
- // keeps, and an edit to anything reachable from those is a reload.
714
- // An import-only page's substituted components are themselves
715
- // entries, so dropping the page loses none of its reach.
851
+ // AUTHORIZATION gate, so it is the wrong input here: the question is
852
+ // what the browser actually HOLDS. Re-walk from the entries, and an
853
+ // edit to anything reachable from those is a reload.
716
854
  if (dev) {
717
- const shippedEntries = [...state.browserEntryFiles].filter((f) =>
718
- !state.elidableComponents.has(f)
719
- && !state.inertRouteModules.has(f)
720
- && !state.importOnlyRouteModules.has(f));
855
+ const shippedEntries = [...state.browserEntryFiles].filter((f) => !state.routeModuleFiles.has(f));
721
856
  state.shippedFiles = reachableFromEntries(state.moduleGraph, shippedEntries, appDir);
722
857
  state.graphFiles = seenFilesFor(state.moduleGraph);
723
858
  state.pageFiles = new Set((state.routeTable.pages || []).map((p) => p.file).filter(Boolean));
859
+ state.componentFiles = new Set(components.map((c) => c.file));
860
+ if (bunVersions) state.shippedStamps = new Map([...state.shippedFiles].map((f) => [f, diskStamp(f)]));
861
+ // The hot-swap baseline of every shipped component the tabs
862
+ // have not had a signature recorded for yet (#1689), read in
863
+ // the background: only a later edit needs it, so the first
864
+ // request does not wait on it (`hotVerdict` awaits it).
865
+ primeHotBaselines([...state.componentFiles].filter((f) => state.shippedFiles.has(f)),
866
+ state.hotSignatures, readHotSignature);
724
867
  }
725
- // Fold the elision verdict into app-module content hashes (#243): an
726
- // app module's served body is elision-transformed, so a verdict flip
727
- // must bust its `?v` even when its source is byte-identical. A stable
728
- // string of the sorted elidable + inert paths, RELATIVIZED to appDir
729
- // so the fingerprint is a property of the app's STRUCTURE, not its
730
- // filesystem location (two deploys at different absolute paths, or
731
- // two identical apps, produce the same fingerprint). asset-hash
732
- // digests it into each app-module hash; '' when nothing is elidable,
733
- // so a no-elision app's hash stays exactly `sha256(bytes)`.
868
+ // Fold the lazy set into app-module content hashes (#243, #1524): a
869
+ // file flipping to or from `static lazy` changes how every importer
870
+ // serves its import of it, so the flip must bust the importer's `?v`
871
+ // even when its source is byte-identical. A stable string of the
872
+ // sorted lazy paths, RELATIVIZED to appDir so the fingerprint is a
873
+ // property of the app's STRUCTURE, not its filesystem location.
874
+ // asset-hash digests it into each app-module hash; '' when nothing is
875
+ // lazy, so such an app's hash stays exactly `sha256(bytes)`.
734
876
  {
735
877
  // `appDir + sep` boundary (matches asset-hash's containment guard)
736
878
  // so a sibling-prefix dir cannot be mis-relativized.
737
879
  const rel = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
738
- const elidedPaths = [
739
- ...state.elidableComponents,
740
- ...state.inertRouteModules,
741
- // An import-only module is dropped from the boot (replaced by its
742
- // component imports), so a flip in / out of that class must bust
743
- // the importer's `?v` too.
744
- ...state.importOnlyRouteModules.keys(),
745
- // A file flipping to or from `static lazy` changes how every
746
- // importer serves its import of it (#1524).
747
- ...[...state.lazyComponentFiles.keys()].map((f) => `lazy:${f}`),
748
- ].map((p) => (p.startsWith('lazy:') ? `lazy:${rel(p.slice(5))}` : rel(p))).sort();
749
- setElisionFingerprint(elidedPaths.length ? elidedPaths.join('\n') : '');
880
+ const lazyPaths = [...state.lazyComponentFiles.keys()].map((f) => `lazy:${rel(f)}`).sort();
881
+ setServeFingerprint(lazyPaths.length ? lazyPaths.join('\n') : '');
750
882
  }
751
883
  // App-source fingerprint for the HTML cache key (#318): a
752
884
  // deterministic, location-independent digest of the browser-bound
@@ -754,18 +886,27 @@ export async function createRequestHandler(opts) {
754
886
  // module's bytes (which the importmap-only build id misses) re-keys
755
887
  // the cache instead of serving a body with stale `?v` boot URLs.
756
888
  // PROD only (asset-hash is enabled there); '' in dev, where fs.watch
757
- // handles staleness. Computed AFTER the elision fingerprint, so each
758
- // per-file hash already reflects the elision verdict (an elision
889
+ // handles staleness. Computed AFTER the serve fingerprint, so each
890
+ // per-file hash already reflects the lazy set (a lazy
759
891
  // flip changes the served output, so it moves this fingerprint too).
760
892
  // This eagerly hashes the WHOLE browser-bound set once (not just the
761
893
  // first route's files), but `assetHashFor` is memoized and already
762
894
  // runs for `?v` emission in prod, so it stays a one-time bounded cost
763
895
  // inside the lazy analysis (never at boot).
896
+ // Since #1686 a page, layout or boundary is not browser-bound, so an
897
+ // edit to one alone would leave that set unchanged while the HTML it
898
+ // renders moved. The raw digest of every graph file (the #899 set
899
+ // below) is folded in, so a page-only deploy re-keys too.
764
900
  if (!dev && state.browserBoundFiles) {
765
901
  const relApp = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
766
902
  const lines = [...state.browserBoundFiles]
767
903
  .map((abs) => `${relApp(abs)}:${assetHashFor(abs)}`)
768
904
  .sort();
905
+ if (state.moduleGraph) {
906
+ for (const abs of [...seenFilesFor(state.moduleGraph)].sort()) {
907
+ lines.push(`src:${relApp(abs)}:${fileByteHash(abs)}`);
908
+ }
909
+ }
769
910
  setAppSourceFingerprint(lines.join('\n'));
770
911
  } else {
771
912
  setAppSourceFingerprint('');
@@ -774,7 +915,7 @@ export async function createRequestHandler(opts) {
774
915
  // fingerprint above: hashes ALL app source (the module-graph `seen`
775
916
  // set, INCLUDING server-only `.server.ts` that `browserBoundFiles`
776
917
  // omits) with a raw per-file byte digest (decoupled from the
777
- // asset-hash memo/elision machinery), plus the installed
918
+ // asset-hash memo and serve fingerprint), plus the installed
778
919
  // `@webjsdev/server` version, so an app-source OR a server-framework
779
920
  // deploy moves it. Sorted + appDir-relative for determinism across
780
921
  // instances. Drives the client's soft cache-evict (not a reload).
@@ -789,45 +930,29 @@ export async function createRequestHandler(opts) {
789
930
  } else {
790
931
  setAppSourceId('');
791
932
  }
792
- t.elision = now() - m;
933
+ t.frontiers = now() - m;
793
934
  if (dev) {
794
935
  // An orphan is EITHER shape `findOrphanComponents` reports: no
795
936
  // registration call at all, or one whose tag is computed. The
796
937
  // scanner matches only a literal tag, so it sees neither, and
797
- // what is ALWAYS lost is the verdict, the registry entry, and the
938
+ // what is ALWAYS lost is the registry entry, the boot import and the
798
939
  // preload hint. The upgrade is the part that differs, and a
799
940
  // computed tag only survives when its importer ships WHOLE (see
800
941
  // findOrphanComponents for why that is the exception). The doctor
801
- // check and `webjs elision` say the same thing.
942
+ // check says the same thing.
802
943
  for (const { className, file } of await findOrphanComponents(appDir)) {
803
944
  logger.warn?.(
804
945
  `[webjs] ${className} extends WebComponent but is never registered with a literal tag in ${file} ` +
805
946
  `(either there is no registration call, or its tag is computed), so the scanner cannot see it: ` +
806
- `no elision verdict, no registry entry, no preload hint. ` +
947
+ `no registry entry, no preload hint, and no boot import. ` +
807
948
  `<${kebab(className)}> tags never upgrade with no registration call, and a computed tag ` +
808
949
  `upgrades only while its importing module ships whole. ` +
809
950
  `Add \`${className}.register('<tag-name>');\` with a literal tag.`,
810
951
  );
811
952
  }
812
- // The elision summary (#1308), one server-console line. NOT a
813
- // browser push: an inert route ships zero application JS, and the
814
- // most useful manual check an author has is opening the network
815
- // tab on that route and seeing nothing, which a dev-only boot
816
- // script would corrupt on exactly the pages this proves. The fact
817
- // is also app-wide while a browser channel is per-tab, and the
818
- // per-page detail already has a home in `webjs elision`.
819
- // Re-emitted after each fs.watch rebuild, because doRebuild sets
820
- // analysisDone = false and this sits inside that stage.
821
- logger.info?.(
822
- elideOn
823
- ? `[webjs] elision: ${r.elidableComponents.size}/${new Set(components.map((c) => c.file)).size} components elided, ` +
824
- `${r.inertRouteModules.size} route modules inert, ${r.importOnlyRouteModules.size} import-only, ` +
825
- `${r.shippedRouteModules.size} ship whole. Run \`webjs elision\` for the per-module verdict.`
826
- : `[webjs] elision: disabled (WEBJS_ELIDE / webjs.elide), every module ships.`,
827
- );
828
953
  }
829
954
  // The analysis stage is the last writer of per-build inputs (the
830
- // elision fingerprint, the import-only and lazy sets, the route
955
+ // serve fingerprint, the frontiers and the lazy set, the route
831
956
  // table), so every epoch-keyed memo is invalidated here once,
832
957
  // unconditionally: a request that rendered in the pre-warm window
833
958
  // hinted against an empty fingerprint and must not keep serving
@@ -879,11 +1004,11 @@ export async function createRequestHandler(opts) {
879
1004
  readyDone = true;
880
1005
  if (ranAnalysis) {
881
1006
  const ms = (x) => Math.round(x || 0);
882
- const total = ms(t.graph) + ms(t.scan) + ms(t.gate) + ms(t.actions) + ms(t.middleware) + ms(t.elision) + ms(t.vendor);
1007
+ const total = ms(t.graph) + ms(t.scan) + ms(t.gate) + ms(t.actions) + ms(t.middleware) + ms(t.frontiers) + ms(t.vendor);
883
1008
  logger.info?.(
884
1009
  `[webjs] analysis warm in ${total}ms (graph ${ms(t.graph)}, scan ${ms(t.scan)}, ` +
885
1010
  `gate ${ms(t.gate)}, actions ${ms(t.actions)}, middleware ${ms(t.middleware)}, ` +
886
- `elision ${ms(t.elision)}, vendor ${ms(t.vendor)})`,
1011
+ `frontiers ${ms(t.frontiers)}, vendor ${ms(t.vendor)})`,
887
1012
  );
888
1013
  } else if (ranVendor && vendorResolved) {
889
1014
  logger.info?.(`[webjs] vendor resolved in ${Math.round(t.vendor || 0)}ms`);
@@ -914,16 +1039,15 @@ export async function createRequestHandler(opts) {
914
1039
  vendorResolveInFlight = (async () => {
915
1040
  try {
916
1041
  const scan = async () => {
917
- const skip = new Set([...state.elidableComponents, ...state.inertRouteModules, ...state.importOnlyRouteModules.keys()]);
918
- return reachedBareImports(state.moduleGraph, [...state.browserEntryFiles], appDir, skip);
1042
+ return reachedBareImports(state.moduleGraph, [...state.browserEntryFiles], appDir, state.routeModuleFiles);
919
1043
  };
920
1044
  const v = await resolveVendorImports(appDir, scan);
921
1045
  let { imports, integrity } = v;
922
1046
  if (bootVendorPinned) {
923
1047
  // resolveVendorImports returns a committed pin VERBATIM (it never runs
924
- // the scan for a pinned app). Prune it to the elision-reachable
1048
+ // the scan for a pinned app). Prune it to the browser-reachable
925
1049
  // specifiers so a pinned app serves the same map an unpinned one does
926
- // (#197): an elided-only dep like dayjs is dropped. One scan; the pin
1050
+ // (#197): a dep only a page imports is dropped. One scan; the pin
927
1051
  // path skipped it. This runs on the first warm AND after every rebuild,
928
1052
  // so the pruned map is the single source of truth.
929
1053
  const reachable = await scan();
@@ -981,12 +1105,14 @@ export async function createRequestHandler(opts) {
981
1105
  * classification of the change that triggered this rebuild (#1398). Absent
982
1106
  * (an embedded host calling `rebuild()`, or any caller with no filename to
983
1107
  * classify) means a full reload, which is the fail-safe default.
984
- * @param {{ notify?: boolean }} [rebuildOpts] `notify: false` re-derives
985
- * without a reload frame (a `bun --hot` registry reset, #1575).
1108
+ * @param {{ notify?: boolean, changed?: string[] }} [rebuildOpts] `notify: false`
1109
+ * re-derives without a reload frame (a `bun --hot` registry reset, #1575).
1110
+ * `changed` lists the absolute paths the watcher saw change (#1689); empty
1111
+ * (an embedder's bare `rebuild()`) means nothing is known to have changed.
986
1112
  */
987
- async function rebuild(verdict, { notify = true } = {}) {
1113
+ async function rebuild(verdict, { notify = true, changed = [] } = {}) {
988
1114
  devStatus?.rebuildStarted();
989
- rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict, notify)).then(() => {
1115
+ rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict, notify, changed)).then(() => {
990
1116
  // The recorded errors described the build this one replaced.
991
1117
  devStatus?.rebuildFinished();
992
1118
  devRecorded = new WeakSet();
@@ -1001,23 +1127,95 @@ export async function createRequestHandler(opts) {
1001
1127
  return rebuildInFlight;
1002
1128
  }
1003
1129
 
1130
+ /**
1131
+ * The rebuild's verdict and changed files plus every served or shipped module
1132
+ * whose disk stamp moved without the watcher reporting it (Bun only). Each
1133
+ * extra file is classified against the analysis the tabs hold, the same
1134
+ * ladder the watcher applies, and its stamp is updated so it is reported
1135
+ * once. Only modules in the directories of the changed files are checked:
1136
+ * the event Bun drops is a sibling written in the same directory, and a
1137
+ * synchronous stat over every module would block the loop on each save.
1138
+ * @param {import('../dev-classify.js').ReloadVerdict | undefined} verdict
1139
+ * @param {string[]} changed
1140
+ * @returns {{ verdict: import('../dev-classify.js').ReloadVerdict | undefined, changed: string[] }}
1141
+ */
1142
+ function withMissedChanges(verdict, changed) {
1143
+ const seen = new Set(changed);
1144
+ /** @type {Set<string>} */
1145
+ const dirs = new Set();
1146
+ // A reported path may be a directory itself (a folder created or renamed).
1147
+ for (const f of changed) { dirs.add(dirname(f)); dirs.add(f); }
1148
+ /** @type {string[]} */
1149
+ const missed = [];
1150
+ for (const f of movedServedModules(dirs)) if (!seen.has(f)) { seen.add(f); missed.push(f); }
1151
+ for (const [f, stamp] of state.shippedStamps) {
1152
+ if (!dirs.has(dirname(f))) continue;
1153
+ const now = diskStamp(f);
1154
+ if (now === stamp) continue;
1155
+ state.shippedStamps.set(f, now);
1156
+ if (!seen.has(f)) { seen.add(f); missed.push(f); }
1157
+ }
1158
+ for (const f of changed) if (state.shippedStamps.has(f)) state.shippedStamps.set(f, diskStamp(f));
1159
+ if (!missed.length) return { verdict, changed };
1160
+ if (verdict) {
1161
+ const ctx = { appDir, shippedFiles: state.shippedFiles, graphFiles: state.graphFiles, pageFiles: state.pageFiles, analysisReady: analysisEverDone, sep };
1162
+ for (const f of missed) verdict = mergeVerdicts(verdict, classifyChangedPath(f, ctx));
1163
+ }
1164
+ return { verdict, changed: [...changed, ...missed] };
1165
+ }
1166
+
1004
1167
  /**
1005
1168
  * @param {import('../dev-classify.js').ReloadVerdict} [verdict]
1006
1169
  * @param {boolean} [notify] tell the open tabs (`onReload`). False for the
1007
1170
  * re-derive a `bun --hot` re-run asks for (#1575), whose edit the watcher
1008
1171
  * already announced.
1172
+ * @param {string[]} [changed] absolute paths of the files that changed
1009
1173
  */
1010
- async function doRebuild(verdict, notify = true) {
1174
+ async function doRebuild(verdict, notify = true, changed = []) {
1175
+ // Bun's `fs.watch` (1.3.14) reports only the first of two files written
1176
+ // about a millisecond apart in one directory, so the watcher's list can
1177
+ // be short. Add every module that moved on disk without an event, and
1178
+ // classify it into the verdict the tabs get, BEFORE anything below reads
1179
+ // either, so a missed component edit still swaps or reloads (#1689).
1180
+ if (dev && bunVersions && changed.length) ({ verdict, changed } = withMissedChanges(verdict, changed));
1181
+ // Version the edited modules and their importers FIRST (#1689), so every
1182
+ // import from here on, the re-warm included, gets the new instances. A
1183
+ // no-op where the resolve hook is not installed.
1184
+ if (dev && changed.length) bumpModuleVersions(changed);
1185
+ // The browser's versions walk the PREVIOUS analysis, which is what the open
1186
+ // tabs hold. Captured before anything below resets it.
1187
+ const prev = {
1188
+ graph: state.moduleGraph, shipped: state.shippedFiles, components: state.componentFiles,
1189
+ graphFiles: state.graphFiles, pageFiles: state.pageFiles, entries: state.browserEntryFiles,
1190
+ // Walked once here for both the version bump and the hot-swap walk.
1191
+ importers: dev && changed.length && state.moduleGraph ? shippedImporters(state.moduleGraph, state.shippedFiles) : null,
1192
+ };
1193
+ if (prev.importers) state.browserVersions.bump(changed, prev.importers, prev.shipped);
1194
+ // A component edit the tab can apply by re-importing its module (#1689).
1195
+ if (dev && notify) verdict = await hotVerdict(verdict, changed, prev);
1196
+ // Invalidate the analysis BEFORE anything below can throw (#1689). The
1197
+ // route table scan is the first step that can, and a failure there used to
1198
+ // skip the reset further down, so the next request served the pre-edit
1199
+ // analysis until some later edit rebuilt cleanly. It runs again at the end
1200
+ // in case a request re-warmed against the old route table meanwhile.
1201
+ await invalidateAnalysis();
1011
1202
  // The route table is the only eager artifact (cheap directory scan); rebuild
1012
1203
  // it so routing reflects added/removed route files immediately.
1013
1204
  state.routeTable = await buildRouteTable(appDir);
1205
+ state.publicFiles = buildPublicIndex(appDir);
1014
1206
  // Adding or deleting app/icon.* changes whether the head auto-links it.
1015
1207
  setMetadataIconRoutes(state.routeTable.metadataRoutes);
1016
1208
  // Refresh the generated route types (#258) so adding/removing a route file
1017
1209
  // updates `.webjs/routes.d.ts` without a manual `webjs types`. Dev only,
1018
1210
  // best-effort (see emitRouteTypes).
1019
1211
  if (dev) void emitRouteTypes();
1020
- clearVendorCache();
1212
+ // The resolved vendor map and its SRI are keyed by the sorted install set
1213
+ // and by url, so a new bare import already misses them. Only the app's
1214
+ // package.json (a version bump) can make a hit stale (#1689); clearing on
1215
+ // every edit made an unpinned app re-resolve and re-fetch every bundle's
1216
+ // SRI after each save. A caller that names no files (an embedder's bare
1217
+ // `rebuild()`) keeps the old clear-everything behaviour.
1218
+ if (!changed.length || changed.some((f) => resolve(f) === join(appDir, 'package.json'))) clearVendorCache();
1021
1219
  // A rebuild changes the bare-import set, so a failed resolve's backoff
1022
1220
  // window no longer describes anything; the next request may try at once.
1023
1221
  vendorBackoff.reset();
@@ -1033,23 +1231,11 @@ export async function createRequestHandler(opts) {
1033
1231
  // modules too (#1644); the plain `webjs start` path never gets here.
1034
1232
  clearProdImportCache();
1035
1233
  state.tsCache.clear();
1036
- // Invalidate the lazy analysis; the next request rebuilds the graph,
1037
- // component scan, gate, action index, middleware, elision, and vendor map.
1038
- // Wait out any in-flight build first so it cannot commit stale results
1039
- // after the reset. A dependency edit can flip an elision verdict without
1040
- // changing an importer's mtime, hence the state.tsCache.clear above.
1041
- if (readyInFlight) { try { await readyInFlight; } catch {} }
1042
- // Bump the vendor generation so a vendor resolve still in flight from the
1043
- // previous build cannot flip vendorResolved against the fresh state.
1044
- vendorGen++;
1045
- analysisDone = false;
1046
- vendorResolved = false;
1047
- vendorAttemptedOnce = false;
1048
- readyDone = false;
1049
- readyError = null;
1050
- readinessFn = undefined;
1234
+ // A dependency edit can flip a lazy or frontier answer without changing an
1235
+ // importer's mtime, hence the state.tsCache.clear above.
1236
+ await invalidateAnalysis();
1051
1237
  // Refresh the on-request regenerate rules (#967) so a `webjs.dev.regenerate`
1052
- // config edit takes effect without a restart, mirroring the elide/router
1238
+ // config edit takes effect without a restart, mirroring the router
1053
1239
  // re-reads above. Dev-only; a no-op array in prod.
1054
1240
  if (dev) state.regenerateRules = await readRegenerateRules(appDir);
1055
1241
  // Optimistically clear the dev error (#264): the rebuild itself only
@@ -1072,6 +1258,116 @@ export async function createRequestHandler(opts) {
1072
1258
  // runs on both boundary tiers and swaps in the changed boot script, which
1073
1259
  // executes and registers the new component.
1074
1260
  if (notify) opts.onReload?.(verdict || { v: 'reload', by: '', why: 'no-verdict' });
1261
+ // Evict every cached HTML response LAST (#1689), on the silent path too. The
1262
+ // cache key does not change across a rebuild, so a page with
1263
+ // `export const revalidate` otherwise served its pre-edit HTML for the whole
1264
+ // window, and that body carries the old `webjs-dev-reload` meta, which the
1265
+ // reload relay reads as a page that is behind and reloads into again. Last,
1266
+ // so any render that started before it is refused by the commit's
1267
+ // generation check.
1268
+ revalidateAll();
1269
+ }
1270
+
1271
+ /**
1272
+ * Invalidate the lazy analysis; the next request rebuilds the graph,
1273
+ * component scan, gate, action index, middleware, frontiers, and vendor map.
1274
+ * Waits out any in-flight build first so it cannot commit stale results after
1275
+ * the reset, and bumps the vendor generation so a vendor resolve still in
1276
+ * flight from the previous build cannot flip `vendorResolved` against the
1277
+ * fresh state.
1278
+ */
1279
+ async function invalidateAnalysis() {
1280
+ if (readyInFlight) { try { await readyInFlight; } catch {} }
1281
+ vendorGen++;
1282
+ analysisDone = false;
1283
+ vendorResolved = false;
1284
+ vendorAttemptedOnce = false;
1285
+ readyDone = false;
1286
+ readyError = null;
1287
+ readinessFn = undefined;
1288
+ }
1289
+
1290
+ /**
1291
+ * The hot-swap signature of a file on disk, or null when it cannot be read
1292
+ * or stripped.
1293
+ * @param {string} file
1294
+ */
1295
+ async function readHotSignature(file) {
1296
+ try {
1297
+ const src = await readFile(file, 'utf8');
1298
+ return hotSignature(/\.m?tsx?$/.test(file) ? await stripTypeScript(src) : src);
1299
+ } catch {
1300
+ return null;
1301
+ }
1302
+ }
1303
+
1304
+ /**
1305
+ * Turn a reload the edit of shipped browser modules caused into a
1306
+ * `component` hot swap when every edited module reaches a component boundary
1307
+ * the tab can re-import (`dev-hot-classify.js`), or into a reload that names
1308
+ * the precise reason it cannot (#1689). Each changed path is classified again
1309
+ * against the PREVIOUS analysis, because the merged verdict keeps only the
1310
+ * strongest file's reason. Fails safe: any surprise returns the verdict as
1311
+ * it came.
1312
+ * @param {import('../dev-classify.js').ReloadVerdict | undefined} verdict
1313
+ * @param {string[]} changed
1314
+ * @param {{ graph: any, shipped: Set<string>, components: Set<string>, graphFiles: Set<string>, pageFiles: Set<string>, entries: Set<string>, importers: Map<string, Set<string>> | null }} prev
1315
+ * @returns {Promise<import('../dev-classify.js').ReloadVerdict | undefined>}
1316
+ */
1317
+ async function hotVerdict(verdict, changed, prev) {
1318
+ if (!verdict || verdict.v !== 'reload' || !changed.length || !prev.importers || !analysisEverDone) return verdict;
1319
+ const importers = prev.importers;
1320
+ /** @type {Map<string, import('../dev-hot-classify.js').HotSignature | null>} */
1321
+ const sigs = new Map();
1322
+ const signatureOf = async (/** @type {string} */ f) => {
1323
+ if (!sigs.has(f)) sigs.set(f, await readHotSignature(f));
1324
+ return sigs.get(f) || null;
1325
+ };
1326
+ try {
1327
+ const ctx = { appDir, shippedFiles: prev.shipped, graphFiles: prev.graphFiles, pageFiles: prev.pageFiles, analysisReady: true, sep };
1328
+ const perFile = changed.map((f) => classifyChangedPath(f, ctx));
1329
+ const reloads = perFile.filter((v) => v.v === 'reload');
1330
+ if (!reloads.length || reloads.some((v) => v.why !== 'ships-to-browser')) return verdict;
1331
+ const res = await findHotBoundaries(changed.filter((_, i) => perFile[i].v === 'reload'), {
1332
+ shipped: prev.shipped,
1333
+ componentFiles: prev.components,
1334
+ // The browser entries that are not components (`instrumentation-client`;
1335
+ // a route module is never an entry, #1686): an edit reaching one has no
1336
+ // component to re-import.
1337
+ routeFiles: new Set([...prev.entries].filter((f) => !prev.components.has(f))),
1338
+ importersOf: (f) => importers.get(f) || [],
1339
+ signatureOf,
1340
+ baselineOf: (f) => state.hotSignatures.get(f),
1341
+ sideEffectOf: async (f) => {
1342
+ try {
1343
+ // The browser gets an action's RPC stub, never its source.
1344
+ if (isServerFile(f)) return !(await hasUseServerDirective(f));
1345
+ const src = await readFile(f, 'utf8');
1346
+ return hasModuleScopeSideEffect(/\.m?tsx?$/.test(f) ? await stripTypeScript(src) : src);
1347
+ } catch {
1348
+ return true;
1349
+ }
1350
+ },
1351
+ rel: (f) => relative(appDir, f).split(sep).join('/'),
1352
+ });
1353
+ if (!res.ok) return { v: 'reload', by: res.by, why: res.why };
1354
+ const bp = basePath();
1355
+ const modules = res.boundaries.map((b) => withBasePath(toUrlPath(b, appDir), bp) + '?t=' + state.browserVersions.versionOf(b));
1356
+ /** @type {import('../dev-classify.js').ReloadVerdict} */
1357
+ const hot = { v: 'component', by: reloads[0].by, why: 'hot-swap', modules };
1358
+ return /** @type {import('../dev-classify.js').ReloadVerdict} */ (perFile.filter((v) => v.v !== 'reload').reduce((a, b) => mergeVerdicts(a, b), hot));
1359
+ } catch {
1360
+ return verdict;
1361
+ } finally {
1362
+ // The tab applies every verdict, a reload included, so from now on it
1363
+ // holds each changed component's new source.
1364
+ for (const f of changed) {
1365
+ if (!prev.components.has(f)) continue;
1366
+ const sig = await signatureOf(f);
1367
+ if (sig) state.hotSignatures.set(f, sig);
1368
+ else state.hotSignatures.delete(f);
1369
+ }
1370
+ }
1075
1371
  }
1076
1372
 
1077
1373
  /**
@@ -1112,6 +1408,9 @@ export async function createRequestHandler(opts) {
1112
1408
  seq: reload ? reload.seq : null,
1113
1409
  rebuilds,
1114
1410
  errors,
1411
+ // Advisory, never a failure: code in a page, layout or boundary that
1412
+ // looks meant for the browser but runs only on the server (#1686).
1413
+ warnings: state.routeHints.map((h) => ({ kind: 'route-browser-code', file: h.file, message: h.message })),
1115
1414
  };
1116
1415
  return Response.json(body, { headers: { 'cache-control': 'no-store' } });
1117
1416
  }
@@ -1158,6 +1457,20 @@ export async function createRequestHandler(opts) {
1158
1457
  // request scope, so `readBody` inside a route handler enforces the same
1159
1458
  // cap the framework's own RPC / form-dispatch body reads do.
1160
1459
  setBodyLimits(state.bodyLimits);
1460
+ setAllowedOrigins(allowedOriginsValue);
1461
+
1462
+ // A component that throws during SSR renders its error state in place and
1463
+ // the page still answers 200, so in dev it is recorded here, as kind
1464
+ // `component`, for `webjs status` and `/__webjs/dev/status` to report.
1465
+ // A `render` entry would not do: a 200 for the url clears those.
1466
+ if (dev) {
1467
+ setComponentErrorSink((tag, error) => {
1468
+ const message = error && typeof error === 'object' && 'message' in error ? String(/** @type {any} */ (error).message) : String(error);
1469
+ const wrapped = new Error(`<${tag}> failed to render during SSR, so it shows its error state: ${message}`);
1470
+ if (error && typeof error === 'object' && 'stack' in error) wrapped.stack = String(/** @type {any} */ (error).stack);
1471
+ recordDevStatus(wrapped, req, 'component');
1472
+ });
1473
+ }
1161
1474
 
1162
1475
  let pathname = '/';
1163
1476
  try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
@@ -1179,19 +1492,35 @@ export async function createRequestHandler(opts) {
1179
1492
  let res;
1180
1493
  try {
1181
1494
  res = await produce(req);
1495
+ // `cookies().set()` / `.delete()` anywhere in the request (an action,
1496
+ // a route handler, middleware, a render) lands here as Set-Cookie.
1497
+ res = applyPendingCookies(res, currentCookieStore());
1498
+ // The headers are decided now; a streamed body still rendering after
1499
+ // this point must not write a cookie that can never be sent.
1500
+ sealCookies();
1182
1501
  } catch (e) {
1183
1502
  // A throw escaping produce() is the last-resort 500 (every interior
1184
1503
  // path catches its own errors, but a surprise still must not crash the
1185
1504
  // host). Fire the onError sink (best-effort) and emit a sanitized 500,
1186
1505
  // preserving the prior behavior plus the new APM hook.
1187
- reportError(e, req, 'handle');
1188
- logger.error?.('[webjs] request pipeline threw', {
1189
- requestId: reqId,
1190
- method: req.method,
1191
- path: pathname,
1192
- err: e instanceof Error ? e.stack : String(e),
1193
- });
1194
- res = new Response('Server error', { status: 500 });
1506
+ if (dev && e && /** @type {any} */ (e).code === 'WEBJS_APP_LAYOUT') {
1507
+ // A layout problem (see app-layout.js) is the app's to fix, not a
1508
+ // crash: show it on every request and in the overlay until an edit
1509
+ // resolves it.
1510
+ reportDevError(e, { kind: 'rebuild' });
1511
+ res = new Response(`[webjs] ${/** @type {Error} */ (e).message}\n`, {
1512
+ status: 500, headers: { 'content-type': 'text/plain; charset=utf-8' },
1513
+ });
1514
+ } else {
1515
+ reportError(e, req, 'handle');
1516
+ logger.error?.('[webjs] request pipeline threw', {
1517
+ requestId: reqId,
1518
+ method: req.method,
1519
+ path: pathname,
1520
+ err: e instanceof Error ? e.stack : String(e),
1521
+ });
1522
+ res = new Response('Server error', { status: 500 });
1523
+ }
1195
1524
  }
1196
1525
 
1197
1526
  // Merge in the secure-by-default headers plus the per-path config
@@ -1236,6 +1565,13 @@ export async function createRequestHandler(opts) {
1236
1565
  // is the developer saying "this dev server is framed by these origins".
1237
1566
  if (embedOriginsValue.length) allowEmbedFraming(merged.headers, embedOriginsValue);
1238
1567
 
1568
+ // A form action's flash is retired when the page it is bound to
1569
+ // redirects instead of rendering, so no other page ever shows it
1570
+ // (action-flash.js). Best-effort.
1571
+ if (req.method === 'GET' && merged.status >= 300 && merged.status < 400) {
1572
+ try { await retireFlashOnRedirect(req, merged, basePathValue); } catch { /* ignore */ }
1573
+ }
1574
+
1239
1575
  // Server HTML cache write (#241): if the SSR marked this response as a
1240
1576
  // cache candidate (an opted-in `revalidate` page), store the FINAL body
1241
1577
  // now, after segment middleware has added any per-user Set-Cookie (which
@@ -1379,7 +1715,7 @@ export async function createRequestHandler(opts) {
1379
1715
  // Default `'ignore'` is a no-op. The root `/` and file paths are
1380
1716
  // exempt; `/__webjs/*` is exempt too (defense in depth, the redirects
1381
1717
  // above already skip it). The funnel in handle() still wraps this.
1382
- const canonical = applyTrailingSlash(req, trailingSlashPolicy);
1718
+ const canonical = applyTrailingSlash(req, trailingSlashPolicy, (p) => !!state.publicFiles && state.publicFiles.has(p));
1383
1719
  if (canonical) return canonical;
1384
1720
  }
1385
1721
 
@@ -1472,20 +1808,20 @@ export async function createRequestHandler(opts) {
1472
1808
  const earlyStatic = await tryServeFrameworkStatic(path, req.method.toUpperCase(), { coreDir, appDir, dev, versioned: url.searchParams.has('v') });
1473
1809
  if (earlyStatic) return earlyStatic;
1474
1810
 
1475
- // `/public/*` needs neither the module graph nor the vendor importmap, so
1811
+ // A `public/` file (served at the url root) needs neither the module graph nor the vendor importmap, so
1476
1812
  // in DEV serve it here rather than behind the analysis (#1397). A cold
1477
- // `/public/tailwind.css` measured 1907ms at 4a335549, of which 1900ms was
1813
+ // `/tailwind.css` measured 1907ms at 4a335549, of which 1900ms was
1478
1814
  // `ensureReady()`, which is why the stylesheet is the request most exposed
1479
1815
  // to the next `node --watch` restart. DEV ONLY on purpose: in prod
1480
1816
  // `/__webjs/ready` already holds traffic off a cold instance until the
1481
1817
  // analysis and the first vendor attempt have both completed, and hoisting
1482
- // there would silently un-gate a `/public/*` file that an app middleware
1818
+ // there would silently un-gate a public file that an app middleware
1483
1819
  // protects. The consequence in dev is that root middleware does not run
1484
1820
  // for these paths, the same trade the framework statics above already make
1485
1821
  // in both modes. `state.regenerateRules` is loaded at boot inside
1486
1822
  // createRequestHandler, so the #967 on-request rebuild is available here,
1487
1823
  // ahead of ensureReady.
1488
- if (dev) {
1824
+ if (dev && req.method === 'GET') {
1489
1825
  const publicResp = await tryServePublicAsset(path, {
1490
1826
  appDir, dev, versioned: url.searchParams.has('v'), regenerateRules: state.regenerateRules,
1491
1827
  });
@@ -1558,8 +1894,7 @@ export async function createRequestHandler(opts) {
1558
1894
  // on the first warm request.
1559
1895
  const moduleUrls = hintedBootModules(page.route, {
1560
1896
  appDir,
1561
- inert: state.inertRouteModules,
1562
- importOnly: state.importOnlyRouteModules,
1897
+ frontiers: state.routeFrontiers,
1563
1898
  lazyFiles: state.lazyComponentFiles,
1564
1899
  instrumentationClient: state.routeTable.instrumentationClient,
1565
1900
  }, basePathValue);
@@ -1572,7 +1907,7 @@ export async function createRequestHandler(opts) {
1572
1907
  routeFor,
1573
1908
  /**
1574
1909
  * Proactively run the first-request analysis (module graph, component
1575
- * scan, gate, action index, middleware, elision, vendor map) in the
1910
+ * scan, gate, action index, middleware, frontiers, vendor map) in the
1576
1911
  * background, so a real first request finds it already memoized. Safe to
1577
1912
  * call any number of times and concurrently: the work is single-flighted,
1578
1913
  * so this never duplicates it or races a real request. It is a single
@@ -1622,9 +1957,10 @@ export async function createRequestHandler(opts) {
1622
1957
  * (#1575), the `bun --hot` re-run `startServer` pokes for. A module no
1623
1958
  * other app module imports is only ever loaded through `devImport`, whose
1624
1959
  * specifier names the file's content, so its edit is a new specifier and
1625
- * needs nothing reset. Anything imported by another module, a file the last
1626
- * analysis never saw (a new file may be the target of an import that
1627
- * failed), or any edit before the first analysis does.
1960
+ * needs nothing reset, and neither does a module the Bun dev plugins
1961
+ * already served, which the module versions re-evaluate (#1689). Otherwise anything imported by another
1962
+ * module, a file the last analysis never saw (a new file may be the target
1963
+ * of an import that failed), or any edit before the first analysis does.
1628
1964
  *
1629
1965
  * @param {string} filename relative to `root`
1630
1966
  * @param {string} [root] the watched root, defaulting to the app dir
@@ -1632,6 +1968,10 @@ export async function createRequestHandler(opts) {
1632
1968
  */
1633
1969
  needsRegistryReset: (filename, root) => {
1634
1970
  const abs = resolve(root || appDir, filename);
1971
+ // On Bun a module the dev plugins already served is versioned in place
1972
+ // (#1689): the rebuild bumps it and its importers, and the next import
1973
+ // evaluates that chain fresh. A new or deleted file still resets.
1974
+ if (bunVersions && isServedModule(abs) && existsSync(abs)) return false;
1635
1975
  if (!analysisEverDone) return true;
1636
1976
  // A new routing file (a page, layout, route handler, boundary) is only
1637
1977
  // ever loaded by the router, and nothing can have tried to import it yet.
@@ -1646,6 +1986,13 @@ export async function createRequestHandler(opts) {
1646
1986
  analysisReady: analysisEverDone,
1647
1987
  sep,
1648
1988
  }),
1989
+ /** Whether this handler re-evaluates edited server modules in place on
1990
+ * Node (#1689), so `startServer` can tell the dev supervisor it need not
1991
+ * restart the process for an app edit. */
1992
+ hotModules: !!hotModules,
1993
+ // Whether an app file is held by the CommonJS loader, which no module
1994
+ // version reaches, so an edit to it needs a restart (#1689). Node only.
1995
+ isCommonJsModule: (abs) => !!hotModules && isCommonJsModule(abs),
1649
1996
  appDir,
1650
1997
  dev,
1651
1998
  logger,