@webjsdev/server 0.8.91 → 0.9.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.
@@ -170,7 +170,7 @@ const STATIC_FIELD_REASONS = {
170
170
  * component with no OTHER client signal renders identical HTML with or
171
171
  * without its JS and is elidable like any display-only component, saving a
172
172
  * module download plus a redundant on-hydration re-fetch. It ships only when
173
- * it ALSO carries an independent signal: an `@event`, a non-`state` reactive
173
+ * it ALSO carries an independent signal: an `@event`, a declared reactive
174
174
  * prop, a reactive import, a lifecycle hook (`renderFallback()` included, via
175
175
  * CLIENT_LIFECYCLE_HOOKS), a `<slot>`, `static shadow = true`, `static
176
176
  * interactive = true`, cross-module observation, or a transitively-reachable
@@ -211,11 +211,11 @@ const escapeForCharClass = (c) => c.replace(/[\\\]^-]/g, '\\$&');
211
211
  * is classified above. (unquoted per invariant 4)
212
212
  */
213
213
  const EVENT_BINDING_RE = new RegExp(
214
- `[${SSR_DROPPED_PREFIXES.map(escapeForCharClass).join('')}][A-Za-z][\\w-]*\\s*=\\s*\\$\\{`,
214
+ `[${SSR_DROPPED_PREFIXES.map(escapeForCharClass).join('')}][A-Za-z][\\w-]*\\s*=\\s*[\"']?\\$\\{`,
215
215
  );
216
216
 
217
217
  /** Match a `.onclick=${...}` (native event-handler property) binding. */
218
- const EVENT_PROP_RE = /\.on[a-z]+\s*=\s*\$\{/;
218
+ const EVENT_PROP_RE = /\.on[a-z]+\s*=\s*["']?\$\{/;
219
219
 
220
220
  /**
221
221
  * Narrow dynamic-slot signals (#1015, retained under #1021's native-parity
@@ -247,7 +247,17 @@ const CLIENT_ROUTER_SUBPATH_RE = /['"]@webjsdev\/core\/client-router['"]/;
247
247
  */
248
248
  export const CLIENT_ROUTER_IMPORTS = ['navigate', 'enableClientRouter', 'disableClientRouter', 'revalidate', 'refreshPage', 'loadFrame'];
249
249
 
250
- /** Identifiers that only exist in a browser; their presence means client work. */
250
+ /**
251
+ * Identifiers that only exist in a browser; their presence means client work.
252
+ * Both this and COMPONENT_CLIENT_GLOBAL_RE are tested against the
253
+ * PLACEHOLDER redaction (`redactToPlaceholders`), never against raw or
254
+ * comment-masked source: a quoted string body or a template literal's TEXT is
255
+ * data, not a reference, so `'lucide-app-window'` must not read as `window`,
256
+ * while code inside a `${...}` hole stays real code and still matches. A global
257
+ * written in template text is SSR output (an inline `<script>` runs from the
258
+ * served HTML whether or not the module ships), the reasoning #623 applied to
259
+ * route modules, now extended to every module.
260
+ */
251
261
  const CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|customElements|matchMedia|addEventListener)\b/;
252
262
 
253
263
  /**
@@ -268,6 +278,14 @@ const INSTANCEOF_RE = /\binstanceof\s+([A-Z][A-Za-z0-9_$]*)/g;
268
278
  * `customElements.define(...)` legitimately uses it and must not force ship). */
269
279
  const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|matchMedia|addEventListener)\b/;
270
280
 
281
+ /**
282
+ * The same globals reached through a string key on the global object
283
+ * (`globalThis['matchMedia']`, `self["document"]`). The global scan reads the
284
+ * placeholder redaction, which blanks string literals, and `globalThis` itself
285
+ * exists on the server, so this one form has to be read from the RAW source.
286
+ */
287
+ const COMPONENT_CLIENT_GLOBAL_KEY_RE = /\b(?:globalThis|self)\s*(?:\?\.)?\[\s*(['"`])(?:window|document|navigator|localStorage|sessionStorage|matchMedia|addEventListener)\1\s*\]/;
288
+
271
289
  /**
272
290
  * Constructors that produce inert DATA with no side effect, so a module-scope
273
291
  * `export const X = new Set([...])` (a lookup table, a compiled RegExp, a
@@ -528,7 +546,7 @@ export function hasModuleScopeSideEffect(src, literals) {
528
546
  // statement. Exempt it so a display-only factory-form component stays
529
547
  // elidable; a genuinely interactive one still ships via the per-class and
530
548
  // module-level signal checks below (events, slots, shadow, lifecycle, a
531
- // non-state factory prop, a reactive import). Scoped to the `extends`
549
+ // declared factory prop, a reactive import). Scoped to the `extends`
532
550
  // position, so a top-level `WebComponent(...)` call used anywhere else
533
551
  // still counts as a side effect (#604).
534
552
  if (ident === 'WebComponent' && /\bextends\s+$/.test(frame.slice(0, m.index))) continue;
@@ -699,11 +717,13 @@ export function analyzeComponentSource(src) {
699
717
  // loads, and a browser global (window/document/…, excluding the
700
718
  // registration's customElements) means the module does client work even
701
719
  // if its render is otherwise pure. Eliding such a component would drop
702
- // that effect, so ship. (Mirrors the route-module analysis.)
720
+ // that effect, so ship. (Mirrors the route-module analysis.) The global
721
+ // scan reads the placeholder redaction, so a name inside a string or in
722
+ // template text is not a reference, while one in a `${...}` hole still is.
703
723
  if (importsSideEffectNonCorePackage(src)) {
704
724
  return { interactive: true, reason: 'side-effect imports an npm package' };
705
725
  }
706
- if (COMPONENT_CLIENT_GLOBAL_RE.test(src)) {
726
+ if (COMPONENT_CLIENT_GLOBAL_RE.test(redactToPlaceholders(src).redacted) || COMPONENT_CLIENT_GLOBAL_KEY_RE.test(src)) {
707
727
  return { interactive: true, reason: 'references a browser global at module scope' };
708
728
  }
709
729
  if (hasModuleScopeSideEffect(src)) {
@@ -744,10 +764,10 @@ export function analyzeComponentSource(src) {
744
764
  return { interactive: true, reason: `calls '${call}'` };
745
765
  }
746
766
  }
747
- if (hasNonStateReactiveProperty(body) || hasNonStateFactoryProperty(factoryArg)) {
767
+ if (hasReactiveProperty(body) || hasFactoryProperty(factoryArg)) {
748
768
  return {
749
769
  interactive: true,
750
- reason: 'declares a reactive property that is not { state: true }',
770
+ reason: 'declares a reactive property',
751
771
  };
752
772
  }
753
773
  }
@@ -834,113 +854,62 @@ function importsReactivePrimitive(src, literals) {
834
854
 
835
855
  /**
836
856
  * True if the class body declares a `static properties` block with any
837
- * entry that is NOT marked `{ state: true }`. Non-state reactive
838
- * properties ride an attribute or a `.prop` binding, the channel through
839
- * which a parent pushes updates, which is client-side reactivity.
857
+ * entry. A reactive property rides an attribute or a `.prop` binding, the
858
+ * channel through which a parent pushes updates, which is client-side
859
+ * reactivity. Internal state is a signal, not a declared property (the
860
+ * factory refuses a `state` option, #1677), so every entry counts.
840
861
  *
841
- * Conservative on parse failure: a `static get properties()` accessor or
842
- * an unbalanced object literal returns true (ship).
862
+ * Conservative on parse failure: a `static get properties()` accessor, a
863
+ * non-literal assignment, a spread, or an unbalanced object literal returns
864
+ * true (ship).
843
865
  *
844
866
  * @param {string} classBody
845
867
  * @returns {boolean}
846
868
  */
847
- function hasNonStateReactiveProperty(classBody) {
869
+ function hasReactiveProperty(classBody) {
848
870
  // No reactive properties declared at all: nothing rides an attribute.
849
871
  if (!/\bstatic\s+(?:get\s+)?properties\b/.test(classBody)) return false;
850
- // Properties ARE declared. We can only clear a component as inert when
851
- // the declaration is a brace literal whose every entry is { state: true }.
852
872
  // A getter (`static get properties()`) or a non-literal assignment
853
- // (`= buildProps()`, `= SHARED_PROPS`, `= Object.assign(...)`) cannot be
854
- // parsed for state flags, so ship conservatively. `[^=]*` tolerates a TS
855
- // type annotation between the name and `=` without crossing the `=`.
873
+ // (`= buildProps()`, `= SHARED_PROPS`) cannot be read, so ship
874
+ // conservatively. `[^=]*` tolerates a TS type annotation between the name
875
+ // and `=` without crossing the `=`.
856
876
  const m = /\bstatic\s+properties\b[^=]*=\s*\{/.exec(classBody);
857
877
  if (!m) return true;
858
878
  const objStart = m.index + m[0].length;
859
879
  const objEnd = matchClosingBrace(classBody, objStart);
860
880
  if (objEnd === -1) return true;
861
- const obj = classBody.slice(objStart, objEnd);
862
- // A spread (`...BASE_PROPS`) can inject reactive properties we cannot
863
- // see; ship rather than guess they are all { state: true }.
864
- if (/\.\.\./.test(obj)) return true;
865
- for (const entry of topLevelPropertyValues(obj)) {
866
- // Object-literal descriptor: inert only when it carries `state: true` at
867
- // its top level (descriptorDeclaresState blanks strings and ignores a
868
- // nested state flag, e.g. inside a converter, so it cannot be forged).
869
- if (entry.startsWith('{')) {
870
- if (!descriptorDeclaresState(entry)) return true;
871
- } else {
872
- // Shorthand like `count: Number` rides an attribute, not state.
873
- return true;
874
- }
875
- }
876
- return false;
881
+ return declaresAnyEntry(classBody.slice(objStart, objEnd));
877
882
  }
878
883
 
879
884
  /**
880
885
  * True if the class factory argument (`extends WebComponent({ … })`) declares
881
- * any reactive property that is NOT `{ state: true }`. A non-state property
882
- * rides an HTML attribute or a `.prop` hydration binding, the channel that
883
- * forces the component to ship; a `state: true` property is component-local
884
- * reactive state with no such channel, so a component whose only signal is
885
- * state props stays elidable.
886
- *
887
- * A property value is treated as state ONLY when its descriptor declares
888
- * `state: true` at the TOP LEVEL (see `descriptorDeclaresState`), which covers
889
- * the bare descriptor `{ state: true }` and the `prop()` helper forms
890
- * `prop({ state: true })` / `prop(Type, { state: true })`. Anything else (a bare
891
- * type `Number`, `prop(Number)`, an options object without `state: true`) is
892
- * non-state and ships. Conservative on a spread or an unbalanced brace (cannot
893
- * prove every entry is state, so ship).
886
+ * any reactive property. Each one rides an HTML attribute or a `.prop`
887
+ * hydration binding, the channel that forces the component to ship.
888
+ * Conservative on a spread or an unbalanced brace (ship).
894
889
  *
895
890
  * @param {string} factoryArg
896
891
  * @returns {boolean}
897
892
  */
898
- function hasNonStateFactoryProperty(factoryArg) {
893
+ function hasFactoryProperty(factoryArg) {
899
894
  if (!factoryArg) return false;
900
895
  const objStart = factoryArg.indexOf('{');
901
896
  if (objStart === -1) return false;
902
897
  // matchClosingBrace starts at depth 1, so pass the index AFTER the brace.
903
898
  const objEnd = matchClosingBrace(factoryArg, objStart + 1);
904
899
  if (objEnd === -1) return true;
905
- const obj = factoryArg.slice(objStart + 1, objEnd);
906
- if (/\.\.\./.test(obj)) return true; // spread: cannot prove all entries state
907
- for (const entry of topLevelPropertyValues(obj)) {
908
- if (!descriptorDeclaresState(entry)) return true;
909
- }
910
- return false;
900
+ return declaresAnyEntry(factoryArg.slice(objStart + 1, objEnd));
911
901
  }
912
902
 
913
903
  /**
914
- * True if a reactive-property descriptor declares `state: true` at the TOP
915
- * LEVEL of its options object (brace-depth 1). Restricting to depth 1 is a
916
- * direction-of-safety fix: a `state: true` buried deeper (a converter /
917
- * hasChanged body that happens to return `{ state: true }`) must NOT forge the
918
- * flag, because wrongly treating an attribute-riding property as state would
919
- * ELIDE an interactive component and break the page. Strings / templates are
920
- * blanked first, so `attribute: 'data-state: true'` does not match, and the
921
- * `\b` word boundary keeps a key like `firstate: true` from matching. In every
922
- * legitimate shape (`{ state: true }`, `prop({ state: true })`,
923
- * `prop(Type, { state: true })`) the descriptor object is the only brace group,
924
- * so its `state` key sits at depth 1.
904
+ * True if a properties object body holds at least one entry. A spread
905
+ * (`...BASE_PROPS`) can inject entries we cannot see, so it counts.
925
906
  *
926
- * @param {string} entry the property VALUE text
907
+ * @param {string} obj the body between the outer braces
927
908
  * @returns {boolean}
928
909
  */
929
- function descriptorDeclaresState(entry) {
930
- const code = entry
931
- .replace(/'[^'\n]*'/g, "''")
932
- .replace(/"[^"\n]*"/g, '""')
933
- .replace(/`[^`]*`/g, '``');
934
- // Keep only text at brace-depth <= 1 (the descriptor's own level); blank
935
- // deeper nesting so a nested `state: true` cannot count.
936
- let depth = 0;
937
- let shallow = '';
938
- for (const c of code) {
939
- if (c === '{') { depth++; if (depth <= 1) shallow += c; continue; }
940
- if (c === '}') { if (depth <= 1) shallow += c; if (depth > 0) depth--; continue; }
941
- if (depth <= 1) shallow += c;
942
- }
943
- return /\bstate\s*:\s*true\b/.test(shallow);
910
+ function declaresAnyEntry(obj) {
911
+ if (/\.\.\./.test(obj)) return true;
912
+ return topLevelPropertyValues(obj).length > 0;
944
913
  }
945
914
 
946
915
  /**
@@ -1196,7 +1165,9 @@ function scanFileUncached(file, src, { isRoute, isComponent, appDir, scanStrippe
1196
1165
  // `@event`, a browser global, an `import`, or a `whenDefined` written in a
1197
1166
  // comment must not register as a real signal. String and template content
1198
1167
  // is kept, so a real rendered tag, a real `@click=${}` in an html template,
1199
- // and a real `whenDefined('tag')` (the tag rides a string) still match.
1168
+ // and a real `whenDefined('tag')` (the tag rides a string) still match. The
1169
+ // browser-global scans are the exception: they read `redacted` below, since
1170
+ // a global's name inside a string or template text is data, not a reference.
1200
1171
  // (`importsSideEffectNonCorePackage` / `hasModuleScopeSideEffect` /
1201
1172
  // `analyzeComponentSource` also redact strings/templates internally; running
1202
1173
  // them on the comment-masked source just additionally drops comment prose.)
@@ -1206,15 +1177,20 @@ function scanFileUncached(file, src, { isRoute, isComponent, appDir, scanStrippe
1206
1177
  // A page/layout NEVER hydrates (#605), so its `html` TEMPLATE content is
1207
1178
  // SSR output, not module client work: an inline `<script>`'s browser
1208
1179
  // globals run from the rendered HTML, and a page-template `@event` is
1209
- // dropped at SSR. For a route module, scan those two template-borne signals
1210
- // on the template-redacted source so they do not pin the module, while a
1211
- // genuine module-scope `document.x` OUTSIDE any template still flags (#623).
1180
+ // dropped at SSR. For a route module, scan the `@event` signals on the
1181
+ // template-redacted source so they do not pin the module; a non-route
1182
+ // module keeps its template text, because a helper's `@click=${}` binds when
1183
+ // a shipping component renders it. The browser-global scan reads `redacted`
1184
+ // for EVERY module: a global's name in a string or template text is data
1185
+ // (an inline `<script>` runs from the HTML, a `'lucide-app-window'` class
1186
+ // is a class), while a genuine `document.x` in code, including code in a
1187
+ // `${...}` hole, still flags (#623).
1212
1188
  // The import-based checks stay on `redacted`: a real `import 'pkg'` side
1213
1189
  // effect DOES run when a page/layout module loads in the browser, and
1214
1190
  // `importsSideEffectNonCorePackage` itself skips local `#`-alias imports.
1215
1191
  const templateScan = isRoute ? redacted : masked;
1216
1192
  const clientGlobalOrBare = EVENT_BINDING_RE.test(templateScan) || EVENT_PROP_RE.test(templateScan) ||
1217
- importsSideEffectNonCorePackage(redacted, appDir, literals) || CLIENT_GLOBAL_RE.test(templateScan) ||
1193
+ importsSideEffectNonCorePackage(redacted, appDir, literals) || CLIENT_GLOBAL_RE.test(redacted) ||
1218
1194
  hasModuleScopeSideEffect(redacted, literals);
1219
1195
  let interactive = false;
1220
1196
  /** @type {string | null} */
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The dev server's "has my edit settled, and did it break anything" state
3
+ * (#1664), behind `GET /__webjs/dev/status` and the `webjs status` command.
4
+ *
5
+ * An agent editing a running app could not tell when the reload finished or
6
+ * whether it errored, so it slept a few seconds, fetched a page, and grepped
7
+ * the HTML for "Server error". The reload frame does not answer it either: it
8
+ * is sent before anything is re-imported, so a syntax error only surfaces on
9
+ * the next request.
10
+ *
11
+ * This module holds two things the handler feeds:
12
+ *
13
+ * - A short ring of recent errors: render, TS strip, rebuild, route,
14
+ * action, middleware, metadata, and request-pipeline failures, each as a
15
+ * dev error frame (message, file, line, code frame). The overlay keeps only
16
+ * the newest one, and never saw an action error at all. A rebuild clears
17
+ * every kind EXCEPT `render`: a page broken by an edit stays broken across
18
+ * an unrelated rebuild, so its error stays listed until that page renders
19
+ * again (`renderSucceeded`).
20
+ * - Whether a change is still in flight: a watcher event not yet rebuilt, or
21
+ * a rebuild still running. The handler adds readiness on top, since the
22
+ * lazy analysis is what actually re-imports the app.
23
+ *
24
+ * Dev only. Nothing here runs in production, where the endpoint is a 404.
25
+ *
26
+ * @module
27
+ */
28
+
29
+ /** How many recent errors the ring keeps. */
30
+ export const DEV_STATUS_MAX_ERRORS = 20;
31
+
32
+ /**
33
+ * How long a watcher event may stay pending without a rebuild before it is
34
+ * treated as settled anyway (an event that classified to nothing, a poke that
35
+ * never re-ran), so a status wait can never hang on a lost signal.
36
+ */
37
+ export const PENDING_CHANGE_TTL_MS = 10_000;
38
+
39
+ /**
40
+ * @typedef {{
41
+ * kind: string, message: string, file?: string | null, line?: number | null,
42
+ * column?: number | null, codeFrame?: string | null, hint?: string | null,
43
+ * url?: string | null, at: string,
44
+ * }} DevStatusError
45
+ */
46
+
47
+ /**
48
+ * @param {{ now?: () => number }} [opts] `now` is injectable for tests.
49
+ */
50
+ export function createDevStatus({ now = Date.now } = {}) {
51
+ /** @type {DevStatusError[]} */
52
+ let errors = [];
53
+ let rebuilds = 0;
54
+ let rebuildsInFlight = 0;
55
+ /** @type {number | null} */
56
+ let changeAt = null;
57
+
58
+ return {
59
+ /**
60
+ * Record an error frame (the shape `buildDevErrorFrame` returns).
61
+ * @param {Record<string, unknown>} frame
62
+ */
63
+ record(frame) {
64
+ if (!frame || typeof frame !== 'object') return;
65
+ const entry = /** @type {DevStatusError} */ ({
66
+ kind: String(frame.kind || 'error'),
67
+ message: String(frame.message || ''),
68
+ file: /** @type {any} */ (frame.file) ?? null,
69
+ line: /** @type {any} */ (frame.line) ?? null,
70
+ column: /** @type {any} */ (frame.column) ?? null,
71
+ codeFrame: /** @type {any} */ (frame.codeFrame) ?? null,
72
+ hint: /** @type {any} */ (frame.hint) ?? null,
73
+ url: /** @type {any} */ (frame.url) ?? null,
74
+ at: new Date(now()).toISOString(),
75
+ });
76
+ // The same failure seen again (a broken page requested twice) is one
77
+ // entry; the newer one replaces it, so `at` is the latest sighting.
78
+ errors = errors.filter((e) => !(e.kind === entry.kind && e.message === entry.message
79
+ && e.file === entry.file && e.line === entry.line && e.url === entry.url));
80
+ errors.push(entry);
81
+ if (errors.length > DEV_STATUS_MAX_ERRORS) errors = errors.slice(-DEV_STATUS_MAX_ERRORS);
82
+ },
83
+ /** A watcher saw an app change that a rebuild will follow. */
84
+ noteChange() { changeAt = now(); },
85
+ /**
86
+ * A rebuild started. It builds every change noted so far, so the pending
87
+ * marker clears HERE; a change noted while it runs sets it again and keeps
88
+ * the status unsettled until the rebuild that change schedules.
89
+ */
90
+ rebuildStarted() { rebuildsInFlight++; changeAt = null; },
91
+ /**
92
+ * A rebuild finished.
93
+ * @param {{ cleared?: boolean }} [opts] `cleared` drops the recorded errors
94
+ * that described the build just replaced. Render errors are kept: only a
95
+ * successful render of that page proves it was fixed.
96
+ */
97
+ rebuildFinished({ cleared = true } = {}) {
98
+ rebuildsInFlight = Math.max(0, rebuildsInFlight - 1);
99
+ rebuilds++;
100
+ if (cleared) errors = errors.filter((e) => e.kind === 'render');
101
+ },
102
+ /**
103
+ * A page rendered without a server error, so its recorded render error is
104
+ * stale. Each candidate is matched with its query string ignored.
105
+ * @param {Array<string | null | undefined>} urls the page's path, and its
106
+ * public url (base path included) as the overlay stamps it
107
+ */
108
+ renderSucceeded(urls) {
109
+ const paths = new Set(urls.filter(Boolean).map((u) => String(u).split('?')[0]));
110
+ errors = errors.filter((e) => !(e.kind === 'render' && e.url && paths.has(String(e.url).split('?')[0])));
111
+ },
112
+ /** True while a change is waiting on, or inside, a rebuild. */
113
+ changing() {
114
+ if (rebuildsInFlight > 0) return true;
115
+ if (changeAt === null) return false;
116
+ if (now() - changeAt > PENDING_CHANGE_TTL_MS) { changeAt = null; return false; }
117
+ return true;
118
+ },
119
+ /** @returns {{ rebuilds: number, errors: DevStatusError[] }} */
120
+ snapshot() { return { rebuilds, errors: errors.slice() }; },
121
+ };
122
+ }
@@ -54,6 +54,9 @@ import {
54
54
  import { applyConditionalGet } from '../conditional-get.js';
55
55
  import { commitHtmlCache, setAppSourceFingerprint } from '../html-cache.js';
56
56
  import { buildDevErrorFrame } from '../dev-error.js';
57
+ import { isControlFlowThrow } from '../action-error.js';
58
+ import { createDevStatus } from './dev-status.js';
59
+ import { devReloadState } from '../dev-reload-state.js';
57
60
 
58
61
  import {
59
62
  readElideEnabled,
@@ -189,6 +192,48 @@ export async function createRequestHandler(opts) {
189
192
  // Both the programmatic opts.onError AND any sink an instrumentation.{js,ts}
190
193
  // register() installed via setOnError fire (they compose, #848).
191
194
  const onErrorSinks = [opts.onError, instrumentationOnError].filter((f) => typeof f === 'function');
195
+ // What `/__webjs/dev/status` reports (#1664): the errors since the last
196
+ // rebuild and whether a change is still in flight. Dev only.
197
+ const devStatus = dev ? createDevStatus() : null;
198
+ // Each error OBJECT is recorded once, under the first (most specific) kind
199
+ // that reaches it: a route handler's throw is recorded as `route` before the
200
+ // root middleware wrapping it reports the same error as `middleware`, and a
201
+ // render error the overlay already framed is not recorded again from the
202
+ // onError funnel.
203
+ /** @type {WeakSet<object>} Reset on every rebuild, so an error object an
204
+ * unchanged module throws again after a rebuild (a rejected module-scope
205
+ * promise awaited per request) is recorded again. */
206
+ let devRecorded = new WeakSet();
207
+
208
+ /**
209
+ * Record an error in the dev status. A thrown redirect() / notFound() /
210
+ * forbidden() / unauthorized() is control flow the framework answers, not a
211
+ * failure, so it is never recorded.
212
+ * @param {unknown} error
213
+ * @param {Request | null | undefined} request
214
+ * @param {string} kind
215
+ * @param {Record<string, unknown>} [frame] an already-built dev error frame
216
+ */
217
+ function recordDevStatus(error, request, kind, frame) {
218
+ if (!devStatus || isControlFlowThrow(error)) return;
219
+ if (error && typeof error === 'object') {
220
+ if (devRecorded.has(error)) return;
221
+ devRecorded.add(error);
222
+ }
223
+ try {
224
+ let url;
225
+ try { url = request && request.url ? new URL(request.url).pathname : undefined; } catch { /* no url */ }
226
+ const code = error && typeof error === 'object' ? /** @type {any} */ (error).code : undefined;
227
+ if (typeof code === 'string' && code.startsWith('WEBJS_FORM_')) {
228
+ // A framework diagnostic about the page's own markup (a POST form that
229
+ // binds no action, a bound form submitted as GET). Its stack points
230
+ // into the framework, so it is recorded against the page instead.
231
+ devStatus.record({ kind: 'form', message: `${code}: ${/** @type {any} */ (error).message}`, url });
232
+ return;
233
+ }
234
+ devStatus.record(frame || buildDevErrorFrame(error, { kind, appDir, url }));
235
+ } catch { /* best effort */ }
236
+ }
192
237
  const hasOnError = onErrorSinks.length > 0;
193
238
 
194
239
  /**
@@ -199,6 +244,10 @@ export async function createRequestHandler(opts) {
199
244
  * @param {string} phase
200
245
  */
201
246
  function reportError(error, request, phase) {
247
+ // Dev status (#1664). Recorded BEFORE the no-sink return, because an app
248
+ // with no onError sink is exactly the one whose action errors were
249
+ // invisible to an agent. An `ssr` error is a render failure.
250
+ recordDevStatus(error, request, phase === 'ssr' ? 'render' : phase);
202
251
  if (!hasOnError) return;
203
252
  const ctx = { request, requestId: getRequestId(), phase };
204
253
  for (const sink of onErrorSinks) {
@@ -495,6 +544,9 @@ export async function createRequestHandler(opts) {
495
544
  // SSE channel for the live overlay and replayed to a freshly-connected tab.
496
545
  // Dev-only (never populated when !dev); cleared on a successful rebuild.
497
546
  lastDevError: null,
547
+ // The dev status (#1664), on `state` so the page-render path can tell it a
548
+ // page rendered again. Null in prod.
549
+ devStatus,
498
550
  // On-request regeneration rules (#967): `webjs.dev.regenerate` from the app's
499
551
  // package.json. Dev-only (prod builds its outputs via `start.before`). Loaded
500
552
  // now and refreshed on each rebuild so a config edit takes effect without a
@@ -529,6 +581,7 @@ export async function createRequestHandler(opts) {
529
581
  try {
530
582
  const frame = buildDevErrorFrame(error, { ...info, appDir });
531
583
  state.lastDevError = frame;
584
+ recordDevStatus(error, null, frame.kind, frame);
532
585
  opts.onDevError?.(frame);
533
586
  } catch { /* ignore */ }
534
587
  }
@@ -932,7 +985,14 @@ export async function createRequestHandler(opts) {
932
985
  * without a reload frame (a `bun --hot` registry reset, #1575).
933
986
  */
934
987
  async function rebuild(verdict, { notify = true } = {}) {
935
- rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict, notify)).catch((e) => {
988
+ devStatus?.rebuildStarted();
989
+ rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict, notify)).then(() => {
990
+ // The recorded errors described the build this one replaced.
991
+ devStatus?.rebuildFinished();
992
+ devRecorded = new WeakSet();
993
+ }, (e) => {
994
+ devStatus?.rebuildFinished();
995
+ devRecorded = new WeakSet();
936
996
  logger.error?.(`[webjs] rebuild failed:`, e);
937
997
  // Push the failure to the open tab so the overlay appears live after a
938
998
  // breaking edit, not only on the next manual navigation (#264).
@@ -1014,6 +1074,48 @@ export async function createRequestHandler(opts) {
1014
1074
  if (notify) opts.onReload?.(verdict || { v: 'reload', by: '', why: 'no-verdict' });
1015
1075
  }
1016
1076
 
1077
+ /**
1078
+ * The `/__webjs/dev/status` body (#1664). Waits up to `waitMs` for a pending
1079
+ * change to be rebuilt, then drives the lazy analysis so readiness describes
1080
+ * the CURRENT source rather than the build before the edit.
1081
+ * @param {number} waitMs
1082
+ * @returns {Promise<Response>}
1083
+ */
1084
+ async function devStatusResponse(waitMs) {
1085
+ const status = /** @type {NonNullable<typeof devStatus>} */ (devStatus);
1086
+ const deadline = Date.now() + waitMs;
1087
+ while (waitMs && status.changing() && Date.now() < deadline) {
1088
+ await new Promise((r) => setTimeout(r, 50));
1089
+ }
1090
+ const warm = ensureReady().catch(() => {});
1091
+ if (waitMs && !status.changing()) {
1092
+ const left = Math.max(0, deadline - Date.now());
1093
+ await Promise.race([warm, new Promise((r) => setTimeout(r, left))]);
1094
+ }
1095
+ const { rebuilds, errors } = status.snapshot();
1096
+ // The same decision `/__webjs/ready` makes: once warm, a later retry's error
1097
+ // (a transient vendor failure the server tolerates) does not un-ready it.
1098
+ const ready = readyDone ? 'ok' : readyError ? 'error' : 'pending';
1099
+ const settled = !status.changing() && ready !== 'pending';
1100
+ if (ready === 'error') {
1101
+ errors.unshift({
1102
+ kind: 'ready', message: String((readyError && readyError.message) || readyError),
1103
+ file: null, line: null, column: null, codeFrame: null, hint: null, url: null, at: new Date().toISOString(),
1104
+ });
1105
+ }
1106
+ const reload = devReloadState();
1107
+ const body = {
1108
+ status: errors.length ? 'error' : settled ? 'ok' : 'pending',
1109
+ settled,
1110
+ ready,
1111
+ boot: reload ? reload.boot : null,
1112
+ seq: reload ? reload.seq : null,
1113
+ rebuilds,
1114
+ errors,
1115
+ };
1116
+ return Response.json(body, { headers: { 'cache-control': 'no-store' } });
1117
+ }
1118
+
1017
1119
  /** A routing-convention module under `app/`, which only the router loads. */
1018
1120
  const ROUTE_MODULE_FILE = /^app\/(?:.*\/)?(?:page|layout|route|loading|error|not-found|forbidden|unauthorized|global-error|global-not-found|middleware)\.m?[jt]s$/;
1019
1121
  /** @type {WeakMap<object, Set<string>>} every file some app module imports, per graph */
@@ -1175,6 +1277,15 @@ export async function createRequestHandler(opts) {
1175
1277
  // Use the STRIPPED path for the suppression check (issue #256) so a
1176
1278
  // framework probe at `<basePath>/__webjs/*` is suppressed just like the
1177
1279
  // root-mounted `/__webjs/*`. The logged `path` stays the RAW client URL.
1280
+ // Dev status (#1664): a GET that answered below 500 supersedes a render
1281
+ // error recorded for that url, whatever answered it. A page fixed in
1282
+ // place renders 200, a deleted or renamed one 404s or redirects, and in
1283
+ // every case its old render error is stale. A speculative prefetch is
1284
+ // not a view of anything, so it clears nothing.
1285
+ if (devStatus && (req.method === 'GET' || req.method === 'HEAD') && conditioned.status < 500
1286
+ && req.headers.get('x-webjs-prefetch') !== '1') {
1287
+ devStatus.renderSucceeded([headerPathname, pathname]);
1288
+ }
1178
1289
  if (shouldAccessLog(headerPathname)) {
1179
1290
  try {
1180
1291
  // #1309: fold the dev-only seeding counters into the ONE access-log
@@ -1335,6 +1446,19 @@ export async function createRequestHandler(opts) {
1335
1446
  return Response.json({ status: 'ok' }, { headers: noStore });
1336
1447
  }
1337
1448
 
1449
+ // Dev status (#1664): has the last edit settled, and what broke since the
1450
+ // last rebuild. Answered before ensureReady like the other probes, and only
1451
+ // in dev, since it carries file paths and stacks, the same exposure as the
1452
+ // error overlay. `?wait=<seconds>` (capped at 60) holds the response until
1453
+ // the change has been rebuilt and the analysis re-run, which is the part
1454
+ // the reload frame cannot tell anyone: it goes out before any re-import.
1455
+ if (probePath === '/__webjs/dev/status') {
1456
+ if (!devStatus) return new Response('Not Found', { status: 404 });
1457
+ let waitParam = 0;
1458
+ try { waitParam = Number(new URL(req.url).searchParams.get('wait')) || 0; } catch { /* no wait */ }
1459
+ return devStatusResponse(Math.min(Math.max(waitParam, 0), 60) * 1000);
1460
+ }
1461
+
1338
1462
  // Framework-internal static assets (the @webjsdev/core runtime, the dev
1339
1463
  // reload client, downloaded vendor bundles) depend on neither the analysis
1340
1464
  // nor the vendor importmap, so serve them BEFORE ensureReady(). Otherwise a
@@ -1379,6 +1503,9 @@ export async function createRequestHandler(opts) {
1379
1503
  dev,
1380
1504
  reportError,
1381
1505
  reportDevError,
1506
+ // Dev only: in production the API dispatch takes its plain path with no
1507
+ // recording wrapper at all.
1508
+ recordDevStatus: dev ? recordDevStatus : undefined,
1382
1509
  hasOnError,
1383
1510
  logger,
1384
1511
  cspEnabled: cspConfig.enabled,
@@ -1464,6 +1591,9 @@ export async function createRequestHandler(opts) {
1464
1591
  * startServer to a freshly-connected SSE client so the overlay shows even
1465
1592
  * after a navigation, not only on the breaking edit. Always null in prod. */
1466
1593
  getLastDevError: () => state.lastDevError,
1594
+ /** Tell the dev status (#1664) a watched app file changed, so a status
1595
+ * wait holds until the rebuild that follows. A no-op in prod. */
1596
+ noteChange: () => devStatus?.noteChange(),
1467
1597
  /** Whether a dev-watcher filename is a `webjs.dev.regenerate` OUTPUT (#967),
1468
1598
  * so `startServer`'s file watcher (a different scope, with no access to
1469
1599
  * `state`) can skip a build product the server itself writes and avoid a
package/src/dev/serve.js CHANGED
@@ -201,7 +201,7 @@ export async function tryServePublicAsset(path, ctx) {
201
201
  }
202
202
 
203
203
  export async function handleCore(req, ctx) {
204
- const { state, appDir, coreDir, dev, reportError, reportDevError, hasOnError, logger, cspEnabled, allowedOrigins } = ctx;
204
+ const { state, appDir, coreDir, dev, reportError, reportDevError, recordDevStatus, hasOnError, logger, cspEnabled, allowedOrigins } = ctx;
205
205
  const url = new URL(req.url);
206
206
  // Decode percent-encoded characters so filesystem lookups match real
207
207
  // filenames. Dynamic route segments like `[slug]` and route groups like
@@ -240,7 +240,7 @@ export async function handleCore(req, ctx) {
240
240
  // Pass the onError sink (issue #239): a server action that throws
241
241
  // unexpectedly is reported to the APM hook before the sanitized 500.
242
242
  const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
243
- return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError, allowedOrigins);
243
+ return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError, allowedOrigins, logger);
244
244
  }
245
245
 
246
246
  // Static: /public/*, the #830 root remaps, and /favicon.ico. In dev this
@@ -387,7 +387,14 @@ export async function handleCore(req, ctx) {
387
387
  // API route (route.js handler)
388
388
  const api = matchApi(state.routeTable, path);
389
389
  if (api) {
390
- const handler = () => handleApi(api.route, api.params, req, dev);
390
+ // A handler that throws is recorded in the dev status as a `route` error
391
+ // HERE, before a root middleware wrapping `next()` reports the same error
392
+ // as its own (#1664). The rethrow keeps the response path unchanged.
393
+ const handler = recordDevStatus
394
+ ? async () => {
395
+ try { return await handleApi(api.route, api.params, req, dev); } catch (e) { recordDevStatus(e, req, 'route'); throw e; }
396
+ }
397
+ : () => handleApi(api.route, api.params, req, dev);
391
398
  return runWithSegmentMiddleware(req, api.route.middlewares, handler, dev);
392
399
  }
393
400
 
@@ -456,10 +463,11 @@ export async function handleCore(req, ctx) {
456
463
  // to turn any page into an error.
457
464
  reportFormSubmittedAsGet(
458
465
  url, req,
459
- // Gated on hasOnError, not on `reportError`: that is always a
460
- // function here and no-ops internally, so passing it would spend a
461
- // dedupe slot on a report nobody receives.
462
- hasOnError ? (e) => reportError(e, req, 'action') : undefined,
466
+ // Gated on a sink OR dev, not on `reportError` alone, which is
467
+ // always a function: with neither, nobody receives the report and it
468
+ // would only spend a dedupe slot. In dev the dev status receives it
469
+ // (#1664), the same as the POST variant below.
470
+ hasOnError || dev ? (e) => reportError(e, req, 'action') : undefined,
463
471
  logger, dev, page.route,
464
472
  );
465
473
  // A successful render of URL U supersedes a RETAINED render error for
@@ -490,7 +498,9 @@ export async function handleCore(req, ctx) {
490
498
  const deps = {
491
499
  actionIndex: state.actionIndex,
492
500
  allowedOrigins,
493
- onError: hasOnError ? (e) => reportError(e, req, 'action') : undefined,
501
+ // Also passed in dev with no sink, so a failing form action reaches the
502
+ // dev status error list (#1664); `reportError` records it first.
503
+ onError: hasOnError || dev ? (e) => reportError(e, req, 'action') : undefined,
494
504
  logger,
495
505
  };
496
506
  const handler = () => runFormAction(page.route, page.params, url, req, ssrOpts, deps);