@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
@@ -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,11 +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';
72
+ import { isControlFlowThrow } from '../action-error.js';
73
+ import { createDevStatus } from './dev-status.js';
74
+ import { devReloadState } from '../dev-reload-state.js';
57
75
 
58
76
  import {
59
- readElideEnabled,
60
77
  readSeedEnabled,
61
78
  readClientRouterEnabled,
62
79
  readHeaderRules,
@@ -70,20 +87,21 @@ import {
70
87
  readDevToolingFromApp,
71
88
  } from './config.js';
72
89
  import {
73
- exists, kebab, resolveRequestId, shouldAccessLog, loadAppEnv, collectRouteModules,
90
+ exists, kebab, resolveRequestId, shouldAccessLog,
74
91
  appTopLevelDirs, locateCoreDir,
75
92
  setReloadIdleMs,
76
93
  resolveReloadIdleMs,
77
94
  } from './helpers.js';
78
95
  import {
79
- handleCore, loadMiddleware, tryServeFrameworkStatic, tryServePublicAsset,
96
+ handleCore, tryServeFrameworkStatic, tryServePublicAsset,
80
97
  } from './serve.js';
81
98
  import { devImport, clearProdImportCache } from '../dev-import.js';
99
+ import { retireFlashOnRedirect } from '../action-flash.js';
82
100
 
83
101
  /**
84
102
  * A short content digest of a single file's bytes for the app-source deploy
85
103
  * signal (#899). Raw `sha256(bytes)`, deliberately independent of `assetHashFor`
86
- * (which folds the elision fingerprint and memoizes for `?v` emission): the
104
+ * (which folds the serve fingerprint and memoizes for `?v` emission): the
87
105
  * signal only needs "did the source bytes change", and server-only files never
88
106
  * enter the asset-hash memo anyway. Returns `''` on a read failure, so a
89
107
  * transient error degrades the id rather than throwing during analysis.
@@ -149,7 +167,8 @@ export async function createRequestHandler(opts) {
149
167
  // scaffolded apps with a committed .env.example + .env would fail
150
168
  // to boot until the user discovered the missing env-load. See
151
169
  // tracker #37.
152
- loadAppEnv(appDir);
170
+ // Next.js's env file order (.env.$mode.local, .env.local, .env.$mode, .env).
171
+ loadEnvFiles(appDir, envMode(!!opts.dev));
153
172
  // Optional boot-time env validation (#236). If <appDir>/env.{js,ts} exists it
154
173
  // default-exports a typed schema or a custom validator function; we run it
155
174
  // against process.env (now populated by loadAppEnv) BEFORE buildActionIndex
@@ -177,7 +196,7 @@ export async function createRequestHandler(opts) {
177
196
  // before traffic. Any error sink it registers via setOnError composes with
178
197
  // the programmatic opts.onError (both fire). Fail-open (a throwing hook is
179
198
  // logged, not fatal).
180
- const { onError: instrumentationOnError } = await runInstrumentation(appDir, { dev, logger });
199
+ const { onError: instrumentationOnError, onRequestError } = await runInstrumentation(appDir, { dev, logger });
181
200
  // APM / Sentry integration point (issue #239). Called whenever the request
182
201
  // pipeline catches an unhandled error: the top-level handle() catch (the
183
202
  // last-resort 500), an unexpected throw inside the produce() funnel, or a
@@ -189,7 +208,56 @@ export async function createRequestHandler(opts) {
189
208
  // Both the programmatic opts.onError AND any sink an instrumentation.{js,ts}
190
209
  // register() installed via setOnError fire (they compose, #848).
191
210
  const onErrorSinks = [opts.onError, instrumentationOnError].filter((f) => typeof f === 'function');
192
- const hasOnError = onErrorSinks.length > 0;
211
+ // What `/__webjs/dev/status` reports (#1664): the errors since the last
212
+ // rebuild and whether a change is still in flight. Dev only.
213
+ const devStatus = dev ? createDevStatus() : null;
214
+ // Each error OBJECT is recorded once, under the first (most specific) kind
215
+ // that reaches it: a route handler's throw is recorded as `route` before the
216
+ // root middleware wrapping it reports the same error as `middleware`, and a
217
+ // render error the overlay already framed is not recorded again from the
218
+ // onError funnel.
219
+ /** @type {WeakSet<object>} Reset on every rebuild, so an error object an
220
+ * unchanged module throws again after a rebuild (a rejected module-scope
221
+ * promise awaited per request) is recorded again. */
222
+ let devRecorded = new WeakSet();
223
+
224
+ /**
225
+ * Record an error in the dev status. A thrown redirect() / notFound() /
226
+ * forbidden() / unauthorized() is control flow the framework answers, not a
227
+ * failure, so it is never recorded.
228
+ * @param {unknown} error
229
+ * @param {Request | null | undefined} request
230
+ * @param {string} kind
231
+ * @param {Record<string, unknown>} [frame] an already-built dev error frame
232
+ */
233
+ function recordDevStatus(error, request, kind, frame) {
234
+ if (!devStatus || isControlFlowThrow(error)) return;
235
+ if (error && typeof error === 'object') {
236
+ if (devRecorded.has(error)) return;
237
+ devRecorded.add(error);
238
+ }
239
+ try {
240
+ let url;
241
+ try { url = request && request.url ? new URL(request.url).pathname : undefined; } catch { /* no url */ }
242
+ const code = error && typeof error === 'object' ? /** @type {any} */ (error).code : undefined;
243
+ if (typeof code === 'string' && code.startsWith('WEBJS_FORM_')) {
244
+ // A framework diagnostic about the page's own markup (a POST form that
245
+ // binds no action, a bound form submitted as GET). Its stack points
246
+ // into the framework, so it is recorded against the page instead.
247
+ devStatus.record({ kind: 'form', message: `${code}: ${/** @type {any} */ (error).message}`, url });
248
+ return;
249
+ }
250
+ devStatus.record(frame || buildDevErrorFrame(error, { kind, appDir, url }));
251
+ } catch { /* best effort */ }
252
+ }
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();
193
261
 
194
262
  /**
195
263
  * Invoke every registered onError sink defensively. The phase is a coarse
@@ -197,9 +265,27 @@ export async function createRequestHandler(opts) {
197
265
  * @param {unknown} error
198
266
  * @param {Request} request
199
267
  * @param {string} phase
268
+ * @param {string} [routePath] the route file path for `onRequestError`
200
269
  */
201
- function reportError(error, request, phase) {
270
+ function reportError(error, request, phase, routePath) {
271
+ // Dev status (#1664). Recorded BEFORE the no-sink return, because an app
272
+ // with no onError sink is exactly the one whose action errors were
273
+ // invisible to an agent. An `ssr` error is a render failure.
274
+ recordDevStatus(error, request, phase === 'ssr' ? 'render' : phase);
202
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
+ }
203
289
  const ctx = { request, requestId: getRequestId(), phase };
204
290
  for (const sink of onErrorSinks) {
205
291
  try {
@@ -283,7 +369,7 @@ export async function createRequestHandler(opts) {
283
369
 
284
370
  // Install the resolver behind the isomorphic `asset()` helper (#1194), the
285
371
  // same provider seam `cspNonce()` uses. An author writes
286
- // `href=${asset('/public/app.css')}` and gets the fingerprinted url on the
372
+ // `href=${asset('/app.css')}` and gets the fingerprinted url on the
287
373
  // server; the browser has no provider and returns the path unchanged.
288
374
  //
289
375
  // The `public/` gate is defensive rather than decorative. `resolveUrlToFile`
@@ -305,7 +391,20 @@ export async function createRequestHandler(opts) {
305
391
  // `webjs.seed` switch. Action identity (#1155) rides the same hook, and it is
306
392
  // what a bound `<form action=${action}>` resolves through, so gating the hook
307
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
+ }
308
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);
309
408
 
310
409
  // Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1` (or
311
410
  // `webjs.dev.sourceLocations: true`) under `webjs dev`
@@ -323,10 +422,17 @@ export async function createRequestHandler(opts) {
323
422
  }
324
423
  // Bun under `bun --hot` serves a replaced file's old source forever (#1575):
325
424
  // load app modules through a plugin that reads them fresh. After the
326
- // source-locations plugin, so that one wins when it is on.
327
- 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) {
328
430
  registerBunAppSource(appDir);
329
- 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 */ }
330
436
  }
331
437
 
332
438
  // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
@@ -338,10 +444,10 @@ export async function createRequestHandler(opts) {
338
444
  // rendered deterministically at runtime). Pinning stays optional; an unpinned
339
445
  // app does no vendor work at boot and publishes its id after the first
340
446
  // successful resolve instead. Either way the EXPENSIVE analysis (graph, scan,
341
- // 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
342
448
  // request, so #143's win is intact; only the cheap committed-file read moves
343
449
  // back to boot, and only when a VALID pin exists. A committed pin file is
344
- // 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
345
451
  // final served map and the published id is authoritative.
346
452
  //
347
453
  // Validate the pin with readPinFile BEFORE treating the app as pinned-at-boot.
@@ -367,13 +473,25 @@ export async function createRequestHandler(opts) {
367
473
  }
368
474
 
369
475
  // Whole-app analysis (module graph, component scan, browser-bound gate,
370
- // 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
371
477
  // computed on the first request via ensureReady() below and memoized, so the
372
478
  // server starts without walking or reading the app's source, executing any
373
479
  // server module, or hitting the network. Only the route table is built
374
480
  // eagerly: it is a cheap directory scan (no code reads), and routing, Early
375
481
  // Hints, and WebSocket lookups need it available before the first request.
376
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
+ }
377
495
  // Auto-linked favicons: tell the head builder which icon metadata routes
378
496
  // exist, so `app/icon.*` is linked when the app declares no metadata.icons.
379
497
  // Bound here rather than threaded through ssrOpts, matching
@@ -445,6 +563,7 @@ export async function createRequestHandler(opts) {
445
563
 
446
564
  const state = {
447
565
  routeTable,
566
+ publicFiles,
448
567
  actionIndex: null,
449
568
  middleware: null,
450
569
  bodyLimits,
@@ -458,21 +577,26 @@ export async function createRequestHandler(opts) {
458
577
  // the core / vendor serving are unchanged, so no server source is exposed.
459
578
  testMode: opts.testMode === true,
460
579
  moduleGraph: null,
461
- elidableComponents: new Set(),
462
- inertRouteModules: new Set(),
463
- 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: [],
464
588
  // `static lazy` component files -> their tags (#1524): a side-effect import
465
589
  // of one is served as a lazy-loader registration, and the preload walks
466
- // skip it. Independent of the elision switch.
590
+ // skip it.
467
591
  lazyComponentFiles: new Map(),
468
- // The browser-bound ENTRY files (pages / layouts / boundaries / components),
592
+ // The browser-bound ENTRY files (components and instrumentation-client),
469
593
  // kept alongside the expanded gate set because the vendor scan roots at the
470
594
  // entries rather than at the closure.
471
595
  browserEntryFiles: new Set(),
472
596
  browserBoundFiles: null,
473
597
  // Dev live-reload classification (#1398). `shippedFiles` is the transitive
474
598
  // closure of what the browser can ACTUALLY load (the gate set minus every
475
- // 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,
476
600
  // and `pageFiles` is the route table's pages. Together they are the whole
477
601
  // input to `classifyChangedPath`, so a watch event can be answered without
478
602
  // reaching into the rest of this state. Dev-only; left empty in prod.
@@ -482,11 +606,28 @@ export async function createRequestHandler(opts) {
482
606
  graphFiles: new Set(),
483
607
  /** @type {Set<string>} */
484
608
  pageFiles: new Set(),
485
- // Transformed-source cache (stripped TS + applied elision). Per-handler,
486
- // NOT module-global: the cached bytes bake in THIS handler's elision
487
- // verdict, so two handlers for the same app with different elision
488
- // settings (a multi-tenant embedder, or the differential elision test)
489
- // 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.
490
631
  tsCache: new Map(),
491
632
  // Dev source locations (#1499), fixed for the handler's lifetime, so the
492
633
  // per-handler tsCache above never mixes annotated and plain bytes.
@@ -495,6 +636,9 @@ export async function createRequestHandler(opts) {
495
636
  // SSE channel for the live overlay and replayed to a freshly-connected tab.
496
637
  // Dev-only (never populated when !dev); cleared on a successful rebuild.
497
638
  lastDevError: null,
639
+ // The dev status (#1664), on `state` so the page-render path can tell it a
640
+ // page rendered again. Null in prod.
641
+ devStatus,
498
642
  // On-request regeneration rules (#967): `webjs.dev.regenerate` from the app's
499
643
  // package.json. Dev-only (prod builds its outputs via `start.before`). Loaded
500
644
  // now and refreshed on each rebuild so a config edit takes effect without a
@@ -509,7 +653,11 @@ export async function createRequestHandler(opts) {
509
653
  // process running several handlers the fallback therefore resolves against
510
654
  // the most recently booted one, which is the same scope the asset-url
511
655
  // provider already has.
512
- 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);
513
661
 
514
662
  /**
515
663
  * Report a dev error (#264): build a frame and push it to the open tab via
@@ -529,6 +677,7 @@ export async function createRequestHandler(opts) {
529
677
  try {
530
678
  const frame = buildDevErrorFrame(error, { ...info, appDir });
531
679
  state.lastDevError = frame;
680
+ recordDevStatus(error, null, frame.kind, frame);
532
681
  opts.onDevError?.(frame);
533
682
  } catch { /* ignore */ }
534
683
  }
@@ -536,7 +685,7 @@ export async function createRequestHandler(opts) {
536
685
  // All whole-app analysis is built lazily on the first request, memoized so
537
686
  // boot does none of it. It runs in two stages. The deterministic analysis
538
687
  // (module graph, component scan + prime, browser-bound gate, action index,
539
- // 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
540
689
  // a rebuild invalidates it; readiness gates on it. Vendor resolution is a
541
690
  // SEPARATE, best-effort stage: a pinned app reads a committed importmap file,
542
691
  // an unpinned app auto-fetches from jspm. It does NOT gate readiness, so an
@@ -558,7 +707,7 @@ export async function createRequestHandler(opts) {
558
707
  let analysisEverDone = false;
559
708
  // A pinned app applied its FULL vendor map and published the build id at boot
560
709
  // (above). The deferred vendor stage still runs once (and after every rebuild)
561
- // 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
562
711
  // the same map an unpinned one does (#197); it does not re-publish the build id
563
712
  // (the boot hash stays the deploy fingerprint). An unpinned app starts false and
564
713
  // resolves live on the first request.
@@ -619,81 +768,117 @@ export async function createRequestHandler(opts) {
619
768
  const now = () => performance.now();
620
769
  try {
621
770
  if (!analysisDone) {
771
+ const layoutProblems = validateAppLayout(appDir, state.routeTable, state.publicFiles);
772
+ if (layoutProblems.length) throw new AppLayoutError(layoutProblems);
622
773
  let m = now();
623
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
+ }
624
787
  t.graph = now() - m; m = now();
625
788
  const components = await scanComponents(appDir);
626
789
  await primeComponentRegistry(appDir, components);
627
790
  t.scan = now() - m; m = now();
628
791
  state.browserEntryFiles = browserEntryFiles(state.routeTable, components);
629
- 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);
630
797
  t.gate = now() - m; m = now();
631
798
  state.actionIndex = await buildActionIndex(appDir, dev);
632
799
  t.actions = now() - m; m = now();
633
- 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
+ }
634
811
  t.middleware = now() - m; m = now();
635
812
  // Client-router opt-out (#629): re-read each pass so toggling
636
813
  // `webjs.clientRouter` takes effect on rebuild without a restart.
637
814
  setClientRouterEnabled(await readClientRouterEnabled(appDir));
638
- // Read the switch ONCE: the dev summary below reports it too, and
639
- // calling readElideEnabled twice per warm re-reads package.json.
640
- const elideOn = await readElideEnabled(appDir);
641
- const r = elideOn
642
- ? await analyzeElision(components, collectRouteModules(state.routeTable),
643
- state.moduleGraph, (f) => readFile(f, 'utf8'), appDir)
644
- : { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map(),
645
- shippedRouteModules: new Map(), componentVerdicts: new Map() };
646
- state.elidableComponents = r.elidableComponents;
647
- state.inertRouteModules = r.inertRouteModules;
648
- state.importOnlyRouteModules = r.importOnlyRouteModules;
649
- 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);
650
845
  // A lazy component some module imports WITH bindings loads eagerly
651
846
  // anyway, so it keeps its preload hint and its imports as written.
652
847
  for (const f of await bindingImportedLazyFiles(state.lazyComponentFiles, state.moduleGraph,
653
848
  (f) => readFile(f, 'utf8'), resolveImport, appDir)) state.lazyComponentFiles.delete(f);
654
849
  // Dev live-reload classification (#1398): derive the three sets the
655
850
  // watch-event classifier reads. `browserBoundFiles` above is the
656
- // AUTHORIZATION gate (it walks from every entry, elided ones
657
- // included, because an elided module's URL must still 404 rather
658
- // than leak), so it is the wrong input here: the question is what
659
- // the browser actually HOLDS. Re-walk from the entries elision
660
- // keeps, and an edit to anything reachable from those is a reload.
661
- // An import-only page's substituted components are themselves
662
- // 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.
663
854
  if (dev) {
664
- const shippedEntries = [...state.browserEntryFiles].filter((f) =>
665
- !state.elidableComponents.has(f)
666
- && !state.inertRouteModules.has(f)
667
- && !state.importOnlyRouteModules.has(f));
855
+ const shippedEntries = [...state.browserEntryFiles].filter((f) => !state.routeModuleFiles.has(f));
668
856
  state.shippedFiles = reachableFromEntries(state.moduleGraph, shippedEntries, appDir);
669
857
  state.graphFiles = seenFilesFor(state.moduleGraph);
670
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);
671
867
  }
672
- // Fold the elision verdict into app-module content hashes (#243): an
673
- // app module's served body is elision-transformed, so a verdict flip
674
- // must bust its `?v` even when its source is byte-identical. A stable
675
- // string of the sorted elidable + inert paths, RELATIVIZED to appDir
676
- // so the fingerprint is a property of the app's STRUCTURE, not its
677
- // filesystem location (two deploys at different absolute paths, or
678
- // two identical apps, produce the same fingerprint). asset-hash
679
- // digests it into each app-module hash; '' when nothing is elidable,
680
- // 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)`.
681
876
  {
682
877
  // `appDir + sep` boundary (matches asset-hash's containment guard)
683
878
  // so a sibling-prefix dir cannot be mis-relativized.
684
879
  const rel = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
685
- const elidedPaths = [
686
- ...state.elidableComponents,
687
- ...state.inertRouteModules,
688
- // An import-only module is dropped from the boot (replaced by its
689
- // component imports), so a flip in / out of that class must bust
690
- // the importer's `?v` too.
691
- ...state.importOnlyRouteModules.keys(),
692
- // A file flipping to or from `static lazy` changes how every
693
- // importer serves its import of it (#1524).
694
- ...[...state.lazyComponentFiles.keys()].map((f) => `lazy:${f}`),
695
- ].map((p) => (p.startsWith('lazy:') ? `lazy:${rel(p.slice(5))}` : rel(p))).sort();
696
- 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') : '');
697
882
  }
698
883
  // App-source fingerprint for the HTML cache key (#318): a
699
884
  // deterministic, location-independent digest of the browser-bound
@@ -701,18 +886,27 @@ export async function createRequestHandler(opts) {
701
886
  // module's bytes (which the importmap-only build id misses) re-keys
702
887
  // the cache instead of serving a body with stale `?v` boot URLs.
703
888
  // PROD only (asset-hash is enabled there); '' in dev, where fs.watch
704
- // handles staleness. Computed AFTER the elision fingerprint, so each
705
- // 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
706
891
  // flip changes the served output, so it moves this fingerprint too).
707
892
  // This eagerly hashes the WHOLE browser-bound set once (not just the
708
893
  // first route's files), but `assetHashFor` is memoized and already
709
894
  // runs for `?v` emission in prod, so it stays a one-time bounded cost
710
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.
711
900
  if (!dev && state.browserBoundFiles) {
712
901
  const relApp = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
713
902
  const lines = [...state.browserBoundFiles]
714
903
  .map((abs) => `${relApp(abs)}:${assetHashFor(abs)}`)
715
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
+ }
716
910
  setAppSourceFingerprint(lines.join('\n'));
717
911
  } else {
718
912
  setAppSourceFingerprint('');
@@ -721,7 +915,7 @@ export async function createRequestHandler(opts) {
721
915
  // fingerprint above: hashes ALL app source (the module-graph `seen`
722
916
  // set, INCLUDING server-only `.server.ts` that `browserBoundFiles`
723
917
  // omits) with a raw per-file byte digest (decoupled from the
724
- // asset-hash memo/elision machinery), plus the installed
918
+ // asset-hash memo and serve fingerprint), plus the installed
725
919
  // `@webjsdev/server` version, so an app-source OR a server-framework
726
920
  // deploy moves it. Sorted + appDir-relative for determinism across
727
921
  // instances. Drives the client's soft cache-evict (not a reload).
@@ -736,45 +930,29 @@ export async function createRequestHandler(opts) {
736
930
  } else {
737
931
  setAppSourceId('');
738
932
  }
739
- t.elision = now() - m;
933
+ t.frontiers = now() - m;
740
934
  if (dev) {
741
935
  // An orphan is EITHER shape `findOrphanComponents` reports: no
742
936
  // registration call at all, or one whose tag is computed. The
743
937
  // scanner matches only a literal tag, so it sees neither, and
744
- // 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
745
939
  // preload hint. The upgrade is the part that differs, and a
746
940
  // computed tag only survives when its importer ships WHOLE (see
747
941
  // findOrphanComponents for why that is the exception). The doctor
748
- // check and `webjs elision` say the same thing.
942
+ // check says the same thing.
749
943
  for (const { className, file } of await findOrphanComponents(appDir)) {
750
944
  logger.warn?.(
751
945
  `[webjs] ${className} extends WebComponent but is never registered with a literal tag in ${file} ` +
752
946
  `(either there is no registration call, or its tag is computed), so the scanner cannot see it: ` +
753
- `no elision verdict, no registry entry, no preload hint. ` +
947
+ `no registry entry, no preload hint, and no boot import. ` +
754
948
  `<${kebab(className)}> tags never upgrade with no registration call, and a computed tag ` +
755
949
  `upgrades only while its importing module ships whole. ` +
756
950
  `Add \`${className}.register('<tag-name>');\` with a literal tag.`,
757
951
  );
758
952
  }
759
- // The elision summary (#1308), one server-console line. NOT a
760
- // browser push: an inert route ships zero application JS, and the
761
- // most useful manual check an author has is opening the network
762
- // tab on that route and seeing nothing, which a dev-only boot
763
- // script would corrupt on exactly the pages this proves. The fact
764
- // is also app-wide while a browser channel is per-tab, and the
765
- // per-page detail already has a home in `webjs elision`.
766
- // Re-emitted after each fs.watch rebuild, because doRebuild sets
767
- // analysisDone = false and this sits inside that stage.
768
- logger.info?.(
769
- elideOn
770
- ? `[webjs] elision: ${r.elidableComponents.size}/${new Set(components.map((c) => c.file)).size} components elided, ` +
771
- `${r.inertRouteModules.size} route modules inert, ${r.importOnlyRouteModules.size} import-only, ` +
772
- `${r.shippedRouteModules.size} ship whole. Run \`webjs elision\` for the per-module verdict.`
773
- : `[webjs] elision: disabled (WEBJS_ELIDE / webjs.elide), every module ships.`,
774
- );
775
953
  }
776
954
  // The analysis stage is the last writer of per-build inputs (the
777
- // elision fingerprint, the import-only and lazy sets, the route
955
+ // serve fingerprint, the frontiers and the lazy set, the route
778
956
  // table), so every epoch-keyed memo is invalidated here once,
779
957
  // unconditionally: a request that rendered in the pre-warm window
780
958
  // hinted against an empty fingerprint and must not keep serving
@@ -826,11 +1004,11 @@ export async function createRequestHandler(opts) {
826
1004
  readyDone = true;
827
1005
  if (ranAnalysis) {
828
1006
  const ms = (x) => Math.round(x || 0);
829
- 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);
830
1008
  logger.info?.(
831
1009
  `[webjs] analysis warm in ${total}ms (graph ${ms(t.graph)}, scan ${ms(t.scan)}, ` +
832
1010
  `gate ${ms(t.gate)}, actions ${ms(t.actions)}, middleware ${ms(t.middleware)}, ` +
833
- `elision ${ms(t.elision)}, vendor ${ms(t.vendor)})`,
1011
+ `frontiers ${ms(t.frontiers)}, vendor ${ms(t.vendor)})`,
834
1012
  );
835
1013
  } else if (ranVendor && vendorResolved) {
836
1014
  logger.info?.(`[webjs] vendor resolved in ${Math.round(t.vendor || 0)}ms`);
@@ -861,16 +1039,15 @@ export async function createRequestHandler(opts) {
861
1039
  vendorResolveInFlight = (async () => {
862
1040
  try {
863
1041
  const scan = async () => {
864
- const skip = new Set([...state.elidableComponents, ...state.inertRouteModules, ...state.importOnlyRouteModules.keys()]);
865
- return reachedBareImports(state.moduleGraph, [...state.browserEntryFiles], appDir, skip);
1042
+ return reachedBareImports(state.moduleGraph, [...state.browserEntryFiles], appDir, state.routeModuleFiles);
866
1043
  };
867
1044
  const v = await resolveVendorImports(appDir, scan);
868
1045
  let { imports, integrity } = v;
869
1046
  if (bootVendorPinned) {
870
1047
  // resolveVendorImports returns a committed pin VERBATIM (it never runs
871
- // 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
872
1049
  // specifiers so a pinned app serves the same map an unpinned one does
873
- // (#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
874
1051
  // path skipped it. This runs on the first warm AND after every rebuild,
875
1052
  // so the pruned map is the single source of truth.
876
1053
  const reachable = await scan();
@@ -928,11 +1105,20 @@ export async function createRequestHandler(opts) {
928
1105
  * classification of the change that triggered this rebuild (#1398). Absent
929
1106
  * (an embedded host calling `rebuild()`, or any caller with no filename to
930
1107
  * classify) means a full reload, which is the fail-safe default.
931
- * @param {{ notify?: boolean }} [rebuildOpts] `notify: false` re-derives
932
- * 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.
933
1112
  */
934
- async function rebuild(verdict, { notify = true } = {}) {
935
- rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict, notify)).catch((e) => {
1113
+ async function rebuild(verdict, { notify = true, changed = [] } = {}) {
1114
+ devStatus?.rebuildStarted();
1115
+ rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict, notify, changed)).then(() => {
1116
+ // The recorded errors described the build this one replaced.
1117
+ devStatus?.rebuildFinished();
1118
+ devRecorded = new WeakSet();
1119
+ }, (e) => {
1120
+ devStatus?.rebuildFinished();
1121
+ devRecorded = new WeakSet();
936
1122
  logger.error?.(`[webjs] rebuild failed:`, e);
937
1123
  // Push the failure to the open tab so the overlay appears live after a
938
1124
  // breaking edit, not only on the next manual navigation (#264).
@@ -941,23 +1127,95 @@ export async function createRequestHandler(opts) {
941
1127
  return rebuildInFlight;
942
1128
  }
943
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
+
944
1167
  /**
945
1168
  * @param {import('../dev-classify.js').ReloadVerdict} [verdict]
946
1169
  * @param {boolean} [notify] tell the open tabs (`onReload`). False for the
947
1170
  * re-derive a `bun --hot` re-run asks for (#1575), whose edit the watcher
948
1171
  * already announced.
1172
+ * @param {string[]} [changed] absolute paths of the files that changed
949
1173
  */
950
- 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();
951
1202
  // The route table is the only eager artifact (cheap directory scan); rebuild
952
1203
  // it so routing reflects added/removed route files immediately.
953
1204
  state.routeTable = await buildRouteTable(appDir);
1205
+ state.publicFiles = buildPublicIndex(appDir);
954
1206
  // Adding or deleting app/icon.* changes whether the head auto-links it.
955
1207
  setMetadataIconRoutes(state.routeTable.metadataRoutes);
956
1208
  // Refresh the generated route types (#258) so adding/removing a route file
957
1209
  // updates `.webjs/routes.d.ts` without a manual `webjs types`. Dev only,
958
1210
  // best-effort (see emitRouteTypes).
959
1211
  if (dev) void emitRouteTypes();
960
- 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();
961
1219
  // A rebuild changes the bare-import set, so a failed resolve's backoff
962
1220
  // window no longer describes anything; the next request may try at once.
963
1221
  vendorBackoff.reset();
@@ -973,23 +1231,11 @@ export async function createRequestHandler(opts) {
973
1231
  // modules too (#1644); the plain `webjs start` path never gets here.
974
1232
  clearProdImportCache();
975
1233
  state.tsCache.clear();
976
- // Invalidate the lazy analysis; the next request rebuilds the graph,
977
- // component scan, gate, action index, middleware, elision, and vendor map.
978
- // Wait out any in-flight build first so it cannot commit stale results
979
- // after the reset. A dependency edit can flip an elision verdict without
980
- // changing an importer's mtime, hence the state.tsCache.clear above.
981
- if (readyInFlight) { try { await readyInFlight; } catch {} }
982
- // Bump the vendor generation so a vendor resolve still in flight from the
983
- // previous build cannot flip vendorResolved against the fresh state.
984
- vendorGen++;
985
- analysisDone = false;
986
- vendorResolved = false;
987
- vendorAttemptedOnce = false;
988
- readyDone = false;
989
- readyError = null;
990
- 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();
991
1237
  // Refresh the on-request regenerate rules (#967) so a `webjs.dev.regenerate`
992
- // config edit takes effect without a restart, mirroring the elide/router
1238
+ // config edit takes effect without a restart, mirroring the router
993
1239
  // re-reads above. Dev-only; a no-op array in prod.
994
1240
  if (dev) state.regenerateRules = await readRegenerateRules(appDir);
995
1241
  // Optimistically clear the dev error (#264): the rebuild itself only
@@ -1012,6 +1258,161 @@ export async function createRequestHandler(opts) {
1012
1258
  // runs on both boundary tiers and swaps in the changed boot script, which
1013
1259
  // executes and registers the new component.
1014
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
+ }
1371
+ }
1372
+
1373
+ /**
1374
+ * The `/__webjs/dev/status` body (#1664). Waits up to `waitMs` for a pending
1375
+ * change to be rebuilt, then drives the lazy analysis so readiness describes
1376
+ * the CURRENT source rather than the build before the edit.
1377
+ * @param {number} waitMs
1378
+ * @returns {Promise<Response>}
1379
+ */
1380
+ async function devStatusResponse(waitMs) {
1381
+ const status = /** @type {NonNullable<typeof devStatus>} */ (devStatus);
1382
+ const deadline = Date.now() + waitMs;
1383
+ while (waitMs && status.changing() && Date.now() < deadline) {
1384
+ await new Promise((r) => setTimeout(r, 50));
1385
+ }
1386
+ const warm = ensureReady().catch(() => {});
1387
+ if (waitMs && !status.changing()) {
1388
+ const left = Math.max(0, deadline - Date.now());
1389
+ await Promise.race([warm, new Promise((r) => setTimeout(r, left))]);
1390
+ }
1391
+ const { rebuilds, errors } = status.snapshot();
1392
+ // The same decision `/__webjs/ready` makes: once warm, a later retry's error
1393
+ // (a transient vendor failure the server tolerates) does not un-ready it.
1394
+ const ready = readyDone ? 'ok' : readyError ? 'error' : 'pending';
1395
+ const settled = !status.changing() && ready !== 'pending';
1396
+ if (ready === 'error') {
1397
+ errors.unshift({
1398
+ kind: 'ready', message: String((readyError && readyError.message) || readyError),
1399
+ file: null, line: null, column: null, codeFrame: null, hint: null, url: null, at: new Date().toISOString(),
1400
+ });
1401
+ }
1402
+ const reload = devReloadState();
1403
+ const body = {
1404
+ status: errors.length ? 'error' : settled ? 'ok' : 'pending',
1405
+ settled,
1406
+ ready,
1407
+ boot: reload ? reload.boot : null,
1408
+ seq: reload ? reload.seq : null,
1409
+ rebuilds,
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 })),
1414
+ };
1415
+ return Response.json(body, { headers: { 'cache-control': 'no-store' } });
1015
1416
  }
1016
1417
 
1017
1418
  /** A routing-convention module under `app/`, which only the router loads. */
@@ -1056,6 +1457,20 @@ export async function createRequestHandler(opts) {
1056
1457
  // request scope, so `readBody` inside a route handler enforces the same
1057
1458
  // cap the framework's own RPC / form-dispatch body reads do.
1058
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
+ }
1059
1474
 
1060
1475
  let pathname = '/';
1061
1476
  try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
@@ -1077,19 +1492,35 @@ export async function createRequestHandler(opts) {
1077
1492
  let res;
1078
1493
  try {
1079
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();
1080
1501
  } catch (e) {
1081
1502
  // A throw escaping produce() is the last-resort 500 (every interior
1082
1503
  // path catches its own errors, but a surprise still must not crash the
1083
1504
  // host). Fire the onError sink (best-effort) and emit a sanitized 500,
1084
1505
  // preserving the prior behavior plus the new APM hook.
1085
- reportError(e, req, 'handle');
1086
- logger.error?.('[webjs] request pipeline threw', {
1087
- requestId: reqId,
1088
- method: req.method,
1089
- path: pathname,
1090
- err: e instanceof Error ? e.stack : String(e),
1091
- });
1092
- 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
+ }
1093
1524
  }
1094
1525
 
1095
1526
  // Merge in the secure-by-default headers plus the per-path config
@@ -1134,6 +1565,13 @@ export async function createRequestHandler(opts) {
1134
1565
  // is the developer saying "this dev server is framed by these origins".
1135
1566
  if (embedOriginsValue.length) allowEmbedFraming(merged.headers, embedOriginsValue);
1136
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
+
1137
1575
  // Server HTML cache write (#241): if the SSR marked this response as a
1138
1576
  // cache candidate (an opted-in `revalidate` page), store the FINAL body
1139
1577
  // now, after segment middleware has added any per-user Set-Cookie (which
@@ -1175,6 +1613,15 @@ export async function createRequestHandler(opts) {
1175
1613
  // Use the STRIPPED path for the suppression check (issue #256) so a
1176
1614
  // framework probe at `<basePath>/__webjs/*` is suppressed just like the
1177
1615
  // root-mounted `/__webjs/*`. The logged `path` stays the RAW client URL.
1616
+ // Dev status (#1664): a GET that answered below 500 supersedes a render
1617
+ // error recorded for that url, whatever answered it. A page fixed in
1618
+ // place renders 200, a deleted or renamed one 404s or redirects, and in
1619
+ // every case its old render error is stale. A speculative prefetch is
1620
+ // not a view of anything, so it clears nothing.
1621
+ if (devStatus && (req.method === 'GET' || req.method === 'HEAD') && conditioned.status < 500
1622
+ && req.headers.get('x-webjs-prefetch') !== '1') {
1623
+ devStatus.renderSucceeded([headerPathname, pathname]);
1624
+ }
1178
1625
  if (shouldAccessLog(headerPathname)) {
1179
1626
  try {
1180
1627
  // #1309: fold the dev-only seeding counters into the ONE access-log
@@ -1268,7 +1715,7 @@ export async function createRequestHandler(opts) {
1268
1715
  // Default `'ignore'` is a no-op. The root `/` and file paths are
1269
1716
  // exempt; `/__webjs/*` is exempt too (defense in depth, the redirects
1270
1717
  // above already skip it). The funnel in handle() still wraps this.
1271
- const canonical = applyTrailingSlash(req, trailingSlashPolicy);
1718
+ const canonical = applyTrailingSlash(req, trailingSlashPolicy, (p) => !!state.publicFiles && state.publicFiles.has(p));
1272
1719
  if (canonical) return canonical;
1273
1720
  }
1274
1721
 
@@ -1335,6 +1782,19 @@ export async function createRequestHandler(opts) {
1335
1782
  return Response.json({ status: 'ok' }, { headers: noStore });
1336
1783
  }
1337
1784
 
1785
+ // Dev status (#1664): has the last edit settled, and what broke since the
1786
+ // last rebuild. Answered before ensureReady like the other probes, and only
1787
+ // in dev, since it carries file paths and stacks, the same exposure as the
1788
+ // error overlay. `?wait=<seconds>` (capped at 60) holds the response until
1789
+ // the change has been rebuilt and the analysis re-run, which is the part
1790
+ // the reload frame cannot tell anyone: it goes out before any re-import.
1791
+ if (probePath === '/__webjs/dev/status') {
1792
+ if (!devStatus) return new Response('Not Found', { status: 404 });
1793
+ let waitParam = 0;
1794
+ try { waitParam = Number(new URL(req.url).searchParams.get('wait')) || 0; } catch { /* no wait */ }
1795
+ return devStatusResponse(Math.min(Math.max(waitParam, 0), 60) * 1000);
1796
+ }
1797
+
1338
1798
  // Framework-internal static assets (the @webjsdev/core runtime, the dev
1339
1799
  // reload client, downloaded vendor bundles) depend on neither the analysis
1340
1800
  // nor the vendor importmap, so serve them BEFORE ensureReady(). Otherwise a
@@ -1348,20 +1808,20 @@ export async function createRequestHandler(opts) {
1348
1808
  const earlyStatic = await tryServeFrameworkStatic(path, req.method.toUpperCase(), { coreDir, appDir, dev, versioned: url.searchParams.has('v') });
1349
1809
  if (earlyStatic) return earlyStatic;
1350
1810
 
1351
- // `/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
1352
1812
  // in DEV serve it here rather than behind the analysis (#1397). A cold
1353
- // `/public/tailwind.css` measured 1907ms at 4a335549, of which 1900ms was
1813
+ // `/tailwind.css` measured 1907ms at 4a335549, of which 1900ms was
1354
1814
  // `ensureReady()`, which is why the stylesheet is the request most exposed
1355
1815
  // to the next `node --watch` restart. DEV ONLY on purpose: in prod
1356
1816
  // `/__webjs/ready` already holds traffic off a cold instance until the
1357
1817
  // analysis and the first vendor attempt have both completed, and hoisting
1358
- // 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
1359
1819
  // protects. The consequence in dev is that root middleware does not run
1360
1820
  // for these paths, the same trade the framework statics above already make
1361
1821
  // in both modes. `state.regenerateRules` is loaded at boot inside
1362
1822
  // createRequestHandler, so the #967 on-request rebuild is available here,
1363
1823
  // ahead of ensureReady.
1364
- if (dev) {
1824
+ if (dev && req.method === 'GET') {
1365
1825
  const publicResp = await tryServePublicAsset(path, {
1366
1826
  appDir, dev, versioned: url.searchParams.has('v'), regenerateRules: state.regenerateRules,
1367
1827
  });
@@ -1379,6 +1839,9 @@ export async function createRequestHandler(opts) {
1379
1839
  dev,
1380
1840
  reportError,
1381
1841
  reportDevError,
1842
+ // Dev only: in production the API dispatch takes its plain path with no
1843
+ // recording wrapper at all.
1844
+ recordDevStatus: dev ? recordDevStatus : undefined,
1382
1845
  hasOnError,
1383
1846
  logger,
1384
1847
  cspEnabled: cspConfig.enabled,
@@ -1431,8 +1894,7 @@ export async function createRequestHandler(opts) {
1431
1894
  // on the first warm request.
1432
1895
  const moduleUrls = hintedBootModules(page.route, {
1433
1896
  appDir,
1434
- inert: state.inertRouteModules,
1435
- importOnly: state.importOnlyRouteModules,
1897
+ frontiers: state.routeFrontiers,
1436
1898
  lazyFiles: state.lazyComponentFiles,
1437
1899
  instrumentationClient: state.routeTable.instrumentationClient,
1438
1900
  }, basePathValue);
@@ -1445,7 +1907,7 @@ export async function createRequestHandler(opts) {
1445
1907
  routeFor,
1446
1908
  /**
1447
1909
  * Proactively run the first-request analysis (module graph, component
1448
- * scan, gate, action index, middleware, elision, vendor map) in the
1910
+ * scan, gate, action index, middleware, frontiers, vendor map) in the
1449
1911
  * background, so a real first request finds it already memoized. Safe to
1450
1912
  * call any number of times and concurrently: the work is single-flighted,
1451
1913
  * so this never duplicates it or races a real request. It is a single
@@ -1464,6 +1926,9 @@ export async function createRequestHandler(opts) {
1464
1926
  * startServer to a freshly-connected SSE client so the overlay shows even
1465
1927
  * after a navigation, not only on the breaking edit. Always null in prod. */
1466
1928
  getLastDevError: () => state.lastDevError,
1929
+ /** Tell the dev status (#1664) a watched app file changed, so a status
1930
+ * wait holds until the rebuild that follows. A no-op in prod. */
1931
+ noteChange: () => devStatus?.noteChange(),
1467
1932
  /** Whether a dev-watcher filename is a `webjs.dev.regenerate` OUTPUT (#967),
1468
1933
  * so `startServer`'s file watcher (a different scope, with no access to
1469
1934
  * `state`) can skip a build product the server itself writes and avoid a
@@ -1492,9 +1957,10 @@ export async function createRequestHandler(opts) {
1492
1957
  * (#1575), the `bun --hot` re-run `startServer` pokes for. A module no
1493
1958
  * other app module imports is only ever loaded through `devImport`, whose
1494
1959
  * specifier names the file's content, so its edit is a new specifier and
1495
- * needs nothing reset. Anything imported by another module, a file the last
1496
- * analysis never saw (a new file may be the target of an import that
1497
- * 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.
1498
1964
  *
1499
1965
  * @param {string} filename relative to `root`
1500
1966
  * @param {string} [root] the watched root, defaulting to the app dir
@@ -1502,6 +1968,10 @@ export async function createRequestHandler(opts) {
1502
1968
  */
1503
1969
  needsRegistryReset: (filename, root) => {
1504
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;
1505
1975
  if (!analysisEverDone) return true;
1506
1976
  // A new routing file (a page, layout, route handler, boundary) is only
1507
1977
  // ever loaded by the router, and nothing can have tried to import it yet.
@@ -1516,6 +1986,13 @@ export async function createRequestHandler(opts) {
1516
1986
  analysisReady: analysisEverDone,
1517
1987
  sep,
1518
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),
1519
1996
  appDir,
1520
1997
  dev,
1521
1998
  logger,