@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.
- package/index.d.ts +41 -0
- package/index.js +1 -0
- package/package.json +3 -3
- package/src/action-input.js +208 -0
- package/src/actions.js +17 -2
- package/src/check/helpers.js +85 -0
- package/src/check/rules-components.js +33 -2
- package/src/check/rules.js +5 -0
- package/src/check/runner.js +2 -0
- package/src/component-elision.js +64 -88
- package/src/dev/dev-status.js +122 -0
- package/src/dev/handler.js +131 -1
- package/src/dev/serve.js +18 -8
- package/src/dev/server.js +17 -4
- package/src/dev-regenerate.js +320 -76
- package/src/dev-styles.js +64 -6
- package/src/form-dispatch.js +10 -4
- package/src/ssr/document.js +12 -4
- package/src/ssr/head.js +150 -2
- package/src/ssr/render.js +2 -0
- package/src/ssr/shipped-modules.js +30 -0
- package/src/testing.d.ts +1 -1
- package/src/testing.js +7 -2
- package/webjs-config.schema.json +2 -2
package/src/component-elision.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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 (
|
|
767
|
+
if (hasReactiveProperty(body) || hasFactoryProperty(factoryArg)) {
|
|
748
768
|
return {
|
|
749
769
|
interactive: true,
|
|
750
|
-
reason: 'declares a reactive property
|
|
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
|
|
838
|
-
*
|
|
839
|
-
*
|
|
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
|
|
842
|
-
* an unbalanced object literal returns
|
|
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
|
|
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
|
|
854
|
-
//
|
|
855
|
-
//
|
|
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
|
-
|
|
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
|
|
882
|
-
*
|
|
883
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
915
|
-
*
|
|
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}
|
|
907
|
+
* @param {string} obj the body between the outer braces
|
|
927
908
|
* @returns {boolean}
|
|
928
909
|
*/
|
|
929
|
-
function
|
|
930
|
-
|
|
931
|
-
|
|
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
|
|
1210
|
-
//
|
|
1211
|
-
//
|
|
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(
|
|
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
|
+
}
|
package/src/dev/handler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
460
|
-
//
|
|
461
|
-
// dedupe slot
|
|
462
|
-
|
|
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
|
-
|
|
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);
|