@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
@@ -1,6 +1,7 @@
1
1
  import { watch, readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { watchRecursive } from './watch-recursive.js';
4
+ import { pollTree, isWatchLimitError } from './watch-poll.js';
4
5
 
5
6
  /**
6
7
  * The dev live-reload watcher over one root (#1521).
@@ -25,58 +26,94 @@ import { watchRecursive } from './watch-recursive.js';
25
26
  * root watcher's events, so `mkdir lib` mid-session is watched like it was
26
27
  * there at boot.
27
28
  *
29
+ * When the OS runs out of watches (ENOSPC once the inotify limit is reached,
30
+ * EMFILE for file descriptors, #1689), the directory that failed is polled
31
+ * instead (`watch-poll.js`) and `onFallback` is told once per directory, so its
32
+ * edits keep arriving, a second slower, instead of never. A root watcher that
33
+ * fails that way polls the root's direct entries.
34
+ *
28
35
  * @param {string} root absolute directory to watch
29
36
  * @param {{
30
37
  * ignore: (relPath: string) => boolean,
31
38
  * onEvent: (relPath: string) => void,
32
39
  * onError: (err: NodeJS.ErrnoException) => void,
33
- * }} opts `relPath` is relative to `root`, with OS separators.
40
+ * onFallback?: (name: string, err: NodeJS.ErrnoException) => void,
41
+ * watchFn?: typeof watch,
42
+ * pollMs?: number,
43
+ * }} opts `relPath` is relative to `root`, with OS separators. `onFallback`
44
+ * gets the top-level directory name (`''` for the root). `watchFn` and
45
+ * `pollMs` are test seams.
34
46
  * @returns {() => void} closes every watcher
35
47
  */
36
- export function watchTree(root, { ignore, onEvent, onError }) {
37
- /** @type {Map<string, import('node:fs').FSWatcher>} */
48
+ export function watchTree(root, { ignore, onEvent, onError, onFallback, watchFn = watch, pollMs = 1000 }) {
49
+ /** @type {Map<string, { close: () => void, polled?: boolean }>} */
38
50
  const dirs = new Map();
39
51
  let closed = false;
40
52
 
41
- const guard = (/** @type {import('node:fs').FSWatcher} */ w) => {
42
- w.on('error', (err) => onError(/** @type {NodeJS.ErrnoException} */ (err)));
43
- return w;
44
- };
45
-
46
53
  const isDir = (name) => {
47
54
  try { return statSync(join(root, name)).isDirectory(); } catch { return false; }
48
55
  };
49
56
 
57
+ /** Replace a top-level directory's watcher with a poller. */
58
+ const pollDir = (name, /** @type {NodeJS.ErrnoException} */ err) => {
59
+ if (closed || dirs.get(name)?.polled) return;
60
+ try { dirs.get(name)?.close(); } catch {}
61
+ const stop = pollTree(join(root, name), {
62
+ ignore: (rel) => ignore(join(name, rel)),
63
+ onEvent: (rel) => onEvent(join(name, rel)),
64
+ intervalMs: pollMs,
65
+ });
66
+ dirs.set(name, { close: stop, polled: true });
67
+ onFallback?.(name, err);
68
+ };
69
+
50
70
  const watchDir = (name) => {
51
71
  if (closed || dirs.has(name) || ignore(name)) return;
72
+ const failed = (/** @type {NodeJS.ErrnoException} */ err) => {
73
+ if (isWatchLimitError(err)) pollDir(name, err);
74
+ else onError(err);
75
+ };
52
76
  try {
53
77
  // `watchRecursive` (#1529): on Linux under Node a per-directory walker,
54
78
  // since Node 24's recursive watcher goes deaf to a file once it is
55
79
  // replaced (`sed -i`, an editor's atomic save).
56
- const w = guard(watchRecursive(join(root, name), (_type, filename) => {
80
+ const w = watchRecursive(join(root, name), (_type, filename) => {
57
81
  const rel = filename ? join(name, String(filename)) : name;
58
82
  if (!ignore(rel)) onEvent(rel);
59
- }, { ignore: (rel) => ignore(join(name, rel)) }));
83
+ }, { ignore: (rel) => ignore(join(name, rel)), watchFn });
84
+ w.on('error', (err) => failed(/** @type {NodeJS.ErrnoException} */ (err)));
60
85
  dirs.set(name, w);
61
86
  } catch (err) {
62
- onError(/** @type {NodeJS.ErrnoException} */ (err));
87
+ failed(/** @type {NodeJS.ErrnoException} */ (err));
63
88
  }
64
89
  };
65
90
 
66
- /** @type {import('node:fs').FSWatcher | null} */
91
+ /** A top-level entry changed: start or stop following it as a directory. */
92
+ const onTop = (/** @type {string} */ name) => {
93
+ if (ignore(name)) return;
94
+ if (isDir(name)) watchDir(name);
95
+ else if (dirs.has(name)) { try { dirs.get(name)?.close(); } catch {} dirs.delete(name); }
96
+ onEvent(name);
97
+ };
98
+
99
+ /** @type {{ close: () => void } | null} */
67
100
  let top = null;
101
+ const pollRoot = (/** @type {NodeJS.ErrnoException} */ err) => {
102
+ if (closed) return;
103
+ try { top?.close(); } catch {}
104
+ top = { close: pollTree(root, { ignore, onEvent: onTop, intervalMs: pollMs, recursive: false }) };
105
+ onFallback?.('', err);
106
+ };
107
+ const rootFailed = (/** @type {NodeJS.ErrnoException} */ err) => {
108
+ if (isWatchLimitError(err)) pollRoot(err);
109
+ else onError(err);
110
+ };
68
111
  try {
69
- top = guard(watch(root, (_type, filename) => {
70
- if (!filename) return;
71
- const name = String(filename);
72
- if (ignore(name)) return;
73
- // A top-level entry changed: start or stop following it as a directory.
74
- if (isDir(name)) watchDir(name);
75
- else if (dirs.has(name)) { try { dirs.get(name)?.close(); } catch {} dirs.delete(name); }
76
- onEvent(name);
77
- }));
112
+ const w = watchFn(root, (_type, filename) => { if (filename) onTop(String(filename)); });
113
+ w.on('error', (err) => rootFailed(/** @type {NodeJS.ErrnoException} */ (err)));
114
+ top = w;
78
115
  } catch (err) {
79
- onError(/** @type {NodeJS.ErrnoException} */ (err));
116
+ rootFailed(/** @type {NodeJS.ErrnoException} */ (err));
80
117
  }
81
118
 
82
119
  let entries = [];
@@ -18,9 +18,8 @@
18
18
  * heuristic ("anything under `app/**` named `page.ts` can morph") is wrong in
19
19
  * both directions and neither failure is exotic:
20
20
  *
21
- * - A page that imports a client-effecting non-component util SHIPS WHOLE (the
22
- * import-only rule in `component-elision.js`, #605 / #963). Editing it changes
23
- * browser-bound JS, so it must reload despite being a page.
21
+ * - A page never ships (#1686), but a helper it imports may also be imported by
22
+ * a component, which makes it browser-bound JS: editing it must reload.
24
23
  * - A helper under `modules/<feature>/utils/` imported by both a page and a
25
24
  * component is a component edit by reachability, and its path says nothing.
26
25
  *
@@ -29,9 +28,9 @@
29
28
  */
30
29
 
31
30
  /**
32
- * The three verdicts, STRONGEST FIRST. The index in this array IS the strength
33
- * order, which is what `strongerVerdict` compares and what lets a batch of
34
- * changes collapse to the strongest verdict it contains rather than the last.
31
+ * The verdicts, STRONGEST FIRST. The index in this array IS the strength order,
32
+ * which is what `mergeVerdicts` compares and what lets a batch of changes
33
+ * collapse to the strongest verdict it contains rather than the last.
35
34
  *
36
35
  * - `reload`: a component module, anything transitively reaching one, or any
37
36
  * path the server cannot place. The browser does `location.reload()`.
@@ -40,21 +39,40 @@
40
39
  * - `page`: a page module and nothing else. The browser re-renders the current
41
40
  * URL and morphs the deepest shared boundary, preserving scroll AND the
42
41
  * hydrated state of components outside the changed region.
42
+ * - `component`: browser modules the tab re-imports and patches onto the live
43
+ * component classes (#1689), listed in the verdict's `modules`. A tab with no
44
+ * hot-swap support applies it as a full reload.
45
+ * - `css`: a stylesheet and nothing else (#1689). The tab re-requests its
46
+ * stylesheets and swaps them in. A tab with no support reloads.
43
47
  *
44
- * @type {readonly ['reload', 'shell', 'page']}
48
+ * @type {readonly ['reload', 'shell', 'page', 'component', 'css']}
45
49
  */
46
- export const RELOAD_VERDICTS = /** @type {const} */ (['reload', 'shell', 'page']);
50
+ export const RELOAD_VERDICTS = /** @type {const} */ (['reload', 'shell', 'page', 'component', 'css']);
47
51
 
48
52
  /**
49
- * @typedef {{ v: 'page' | 'shell' | 'reload', by: string, why: string }} ReloadVerdict
53
+ * A live-reload verdict. `modules` lists the versioned browser URLs a
54
+ * `component` verdict asks the tab to re-import, and `css` says a stylesheet
55
+ * changed too, so the tab refreshes its sheets on top of whatever else it does.
56
+ *
57
+ * @typedef {{
58
+ * v: 'reload' | 'shell' | 'page' | 'component' | 'css',
59
+ * by: string,
60
+ * why: string,
61
+ * modules?: string[],
62
+ * css?: true,
63
+ * }} ReloadVerdict
50
64
  */
51
65
 
52
66
  /**
53
- * The stronger of two verdicts, treating `null` / `undefined` as weakest and an
54
- * unrecognised one as `reload` (fail safe, never fail open).
67
+ * Merge two verdicts into the one a batch applies, treating `null` /
68
+ * `undefined` as nothing and an unrecognised name as `reload` (fail safe, never
69
+ * fail open). `v`, `by` and `why` come from the stronger verdict (the first on
70
+ * a tie). `modules` is the sorted union of both and `css` is set when either
71
+ * sets it, because a weaker change in the batch still needs its own work done
72
+ * alongside the stronger one.
55
73
  *
56
74
  * Used twice for the same reason. The watcher can see several files change
57
- * inside one 80ms debounce window, and the browser relay coalesces a burst of
75
+ * inside one debounce window, and the browser relay coalesces a burst of
58
76
  * signals into ONE emitted reload (#1397). In both places a batch mixing a page
59
77
  * edit and a component edit is a COMPONENT edit, so taking the last verdict
60
78
  * would leave the old component class running against fresh markup.
@@ -63,10 +81,16 @@ export const RELOAD_VERDICTS = /** @type {const} */ (['reload', 'shell', 'page']
63
81
  * @param {ReloadVerdict | null | undefined} b
64
82
  * @returns {ReloadVerdict | null}
65
83
  */
66
- export function strongerVerdict(a, b) {
84
+ export function mergeVerdicts(a, b) {
67
85
  if (!a) return b || null;
68
86
  if (!b) return a;
69
- return verdictRank(a.v) <= verdictRank(b.v) ? a : b;
87
+ const top = verdictRank(a.v) <= verdictRank(b.v) ? a : b;
88
+ /** @type {ReloadVerdict} */
89
+ const out = { v: top.v, by: top.by, why: top.why };
90
+ const modules = [...new Set([...(a.modules || []), ...(b.modules || [])])].sort();
91
+ if (modules.length) out.modules = modules;
92
+ if (a.css || b.css) out.css = true;
93
+ return out;
70
94
  }
71
95
 
72
96
  /**
@@ -96,7 +120,7 @@ function verdictRank(v) {
96
120
  * gap. Reading currency here would therefore turn the feature off for every
97
121
  * edit after the first in a burst. Classifying against the previous build's
98
122
  * graph is the intended behaviour, and it is conservative in the right
99
- * direction: a file the stale graph has never seen falls to rung 6 and
123
+ * direction: a file the stale graph has never seen falls to rung 7 and
100
124
  * reloads.
101
125
  * 2. The path is outside `appDir`. The watcher only fires for `appDir` plus the
102
126
  * opt-in `webjs.dev.watch` roots (#894), and a file outside `appDir` is
@@ -104,25 +128,33 @@ function verdictRank(v) {
104
128
  * change what any layout renders, so it takes the whole-body `shell` swap.
105
129
  * 3. The path is in the SHIPPED closure. This is the transitive answer and the
106
130
  * one that matters: the browser holds this module, so the page must reload.
107
- * 4. The path is a page module. Morphable.
108
- * 5. The path is somewhere else in the module graph (a layout, a server-only
131
+ * A CSS module script (`import s from './x.css' with { type: 'css' }`) is in
132
+ * the closure too, so it stops here and never reaches rung 4.
133
+ * 4. The path is a stylesheet (`.css`) the browser does not hold as a module
134
+ * (#1689): a `public/` sheet, an `@import`ed partial, a
135
+ * `webjs.dev.regenerate` input such as Tailwind's. The tab re-requests every
136
+ * same-origin stylesheet and swaps them in place, with no reload and no
137
+ * re-render. Every sheet rather than the changed one, which is what makes a
138
+ * partial and a regenerate output come out right, since neither is the file
139
+ * the page links.
140
+ * 5. The path is a page module. Morphable.
141
+ * 6. The path is somewhere else in the module graph (a layout, a server-only
109
142
  * util, a `.server.*` file reachable only from a page). Server-only by
110
143
  * elimination, so `shell`.
111
- * 6. Anything else. A brand-new file, a deleted file, and every `public/` asset
112
- * land here. `public/` is not in the module graph at all, so the server
113
- * cannot tell what the file IS or what depends on it, and most of what lives
114
- * there (an image, a font, a service worker, a hand-written script) is
115
- * something no amount of re-rendering picks up. The refresh does re-request
116
- * the page's stylesheets, so a `public/` CSS edit alone would now survive a
117
- * morph, but that is one knowable case inside a rung whose whole point is
118
- * that the file is unknowable.
144
+ * 7. Anything else. A brand-new file, a deleted file, and every other `public/`
145
+ * asset land here. `public/` is not in the module graph at all, so the
146
+ * server cannot tell what the file IS or what depends on it, and most of
147
+ * what lives there (an image, a font, a service worker, a hand-written
148
+ * script) is something no amount of re-rendering picks up. A stylesheet is
149
+ * the one kind it can name from the path alone, which is why rung 4 takes
150
+ * it out first.
119
151
  *
120
152
  * Note what step 3 sweeps in deliberately. A `'use server'` action file
121
153
  * imported by a SHIPPING component is inside the closure (the graph keeps a
122
154
  * `.server.*` node and stops at it), so editing it reloads. That is correct
123
155
  * rather than merely conservative, because adding an export to that file
124
156
  * changes the generated RPC stub the browser holds. The same action imported
125
- * only by a page falls to step 5 and gets `shell`.
157
+ * only by a page falls to step 6 and gets `shell`.
126
158
  *
127
159
  * @param {string} abs absolute path of the changed file
128
160
  * @param {object} ctx
@@ -141,6 +173,7 @@ export function classifyChangedPath(abs, ctx) {
141
173
  if (!ctx.analysisReady) return { v: 'reload', by, why: 'analysis-cold' };
142
174
  if (!isUnder(abs, ctx.appDir, sep)) return { v: 'shell', by, why: 'extra-watch-root' };
143
175
  if (ctx.shippedFiles && ctx.shippedFiles.has(abs)) return { v: 'reload', by, why: 'ships-to-browser' };
176
+ if (abs.endsWith('.css')) return { v: 'css', by, why: 'stylesheet', css: true };
144
177
  if (ctx.pageFiles && ctx.pageFiles.has(abs)) return { v: 'page', by, why: 'page-module' };
145
178
  if (ctx.graphFiles && ctx.graphFiles.has(abs)) return { v: 'shell', by, why: 'server-only-module' };
146
179
  return { v: 'reload', by, why: 'unknown-path' };
package/src/dev-error.js CHANGED
@@ -14,7 +14,10 @@
14
14
  * offending source file for the code frame (a read failure degrades to no
15
15
  * frame, never throws).
16
16
  */
17
- import { readFileSync } from 'node:fs';
17
+ import { readFileSync, readdirSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { findDecorator, decoratorReport, parseMissingExport, isCoreModule, missingCoreNameFix } from './lit-hints.js';
20
+ import { coreSurface, isTypeOnlyAnywhere } from './core-exports.js';
18
21
 
19
22
  /**
20
23
  * Pull the first useful `file:line:column` out of an error stack. Prefers a
@@ -40,7 +43,9 @@ export function parseStackLocation(stack, appDir) {
40
43
  // (the `file:///` URL form, whose path uses forward slashes, still resolves).
41
44
  // That matches the framework's POSIX-first dev posture; it degrades, never
42
45
  // crashes.
43
- const re = /(?:file:\/\/)?(\/[^\s:()?]+)(?:\?[^\s:()]*)?:(\d+):(\d+)/g;
46
+ // The lookbehind keeps a runtime-internal frame (`node:internal/modules/...`)
47
+ // from reading as the path `/modules/...`.
48
+ const re = /(?<![\w.-])(?:file:\/\/)?(\/[^\s:()?]+)(?:\?[^\s:()]*)?:(\d+):(\d+)/g;
44
49
  /** @type {{ file: string, line: number, column: number }[]} */
45
50
  const frames = [];
46
51
  let m;
@@ -114,6 +119,15 @@ export function buildDevErrorFrame(error, opts = {}) {
114
119
  let file = opts.file || null;
115
120
  let line = opts.line != null ? opts.line : null;
116
121
  let column = opts.column != null ? opts.column : null;
122
+ // A module that fails to PARSE (a decorator is the common case) throws a
123
+ // SyntaxError whose stack OPENS with a bare `file:///abs/x.ts:2` header (a
124
+ // line, no column) and continues with node:internal frames only, which the
125
+ // frame pattern would misread as a path. The header names the source, so it
126
+ // wins.
127
+ if (!file && stack) {
128
+ const head = /^(?:file:\/\/)?(\/[^\s:()?]+)(?:\?[^\s:()]*)?:(\d+)[ \t]*\n/.exec(stack);
129
+ if (head) { file = head[1]; line = Number(head[2]); }
130
+ }
117
131
  if (!file && stack) {
118
132
  const loc = parseStackLocation(stack, opts.appDir);
119
133
  if (loc) { file = loc.file; line = loc.line; column = loc.column; }
@@ -125,6 +139,20 @@ export function buildDevErrorFrame(error, opts = {}) {
125
139
  const loc = parseStackLocation(`${err.message}\n${stack || ''}`, opts.appDir);
126
140
  if (loc && loc.file === file) { line = loc.line; column = loc.column; }
127
141
  }
142
+ // A module that fails to PARSE, caught rather than crashing the process,
143
+ // carries no location at all on Node (the `file:line` arrow header is only
144
+ // printed for an uncaught error), so a decorator three imports down leaves
145
+ // the overlay with nothing to point at. The usual cause of an unexpected
146
+ // token in a WebJs app is a decorator, so look for one in the app source.
147
+ let hint = opts.hint || null;
148
+ if (!hint && !file && err instanceof SyntaxError && opts.appDir) {
149
+ const found = findAppDecorator(opts.appDir);
150
+ if (found) {
151
+ file = found.file; line = found.hit.line; column = found.hit.column;
152
+ const { message, fix } = decoratorReport(found.hit);
153
+ hint = `${message} ${fix}`;
154
+ }
155
+ }
128
156
  const codeFrame = file && line ? readCodeFrame(file, line, column || 0) : null;
129
157
  return {
130
158
  kind,
@@ -134,7 +162,80 @@ export function buildDevErrorFrame(error, opts = {}) {
134
162
  line,
135
163
  column,
136
164
  codeFrame,
137
- hint: opts.hint || null,
165
+ hint: hint || inferHint(err, file, opts.appDir) || null,
138
166
  url: typeof opts.url === 'string' && opts.url ? opts.url : null,
139
167
  };
140
168
  }
169
+
170
+ /**
171
+ * Name the WebJs fix for an error whose own message does not, so the overlay
172
+ * says what to write instead of only what broke.
173
+ *
174
+ * A decorator (`@customElement`, `@property`, ...) is the case it exists for.
175
+ * Node fails the module with a bare `SyntaxError: Invalid or unexpected
176
+ * token`, and Bun, which runs legacy decorators, with a `ReferenceError` for
177
+ * the undefined decorator function. Neither names the fix, so when the failing
178
+ * file carries a decorator the hint is the one `webjs check` prints.
179
+ *
180
+ * The other is a name `@webjsdev/core` does not export (`LitElement`, a
181
+ * decorator function, a type imported as a value). Node says `does not provide
182
+ * an export named X` and Bun `Export named 'X' not found`; the hint is the one
183
+ * the `core-imports-exist` check rule prints for the same import.
184
+ *
185
+ * @param {Error} err
186
+ * @param {string | null} file the located source file, when there is one
187
+ * @param {string} [appDir] resolves the core package the app imports
188
+ * @returns {string | null}
189
+ */
190
+ export function inferHint(err, file, appDir) {
191
+ const missing = parseMissingExport(err.message);
192
+ if (missing && isCoreModule(missing.module)) {
193
+ const surface = coreSurface(appDir);
194
+ const typeOnly = !!surface && isTypeOnlyAnywhere(surface, missing.name);
195
+ return `\`${missing.name}\` is not a runtime export of @webjsdev/core. ${missingCoreNameFix(missing.name, { typeOnly })}`;
196
+ }
197
+ if ((err instanceof SyntaxError || err instanceof ReferenceError) && file) {
198
+ let src = null;
199
+ try { src = readFileSync(file, 'utf8'); } catch { src = null; }
200
+ const hit = src ? findDecorator(src) : null;
201
+ if (hit) {
202
+ const { message, fix } = decoratorReport(hit);
203
+ return `${message} ${fix}`;
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+
209
+ /**
210
+ * The first decorator in the app's own source, walking every JS / TS file
211
+ * outside node_modules, `public/`, and dot or underscore folders. Dev-only and
212
+ * reached only for an unlocated SyntaxError, so the synchronous walk costs
213
+ * nothing on the happy path.
214
+ *
215
+ * @param {string} appDir
216
+ * @returns {{ file: string, hit: { name: string, index: number, line: number, column: number } } | null}
217
+ */
218
+ function findAppDecorator(appDir) {
219
+ /** @type {string[]} */
220
+ const stack = [appDir];
221
+ let budget = 5000;
222
+ while (stack.length && budget-- > 0) {
223
+ const dir = /** @type {string} */ (stack.pop());
224
+ let entries;
225
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
226
+ for (const e of entries) {
227
+ if (e.name === 'node_modules' || e.name.startsWith('.') || e.name.startsWith('_')) continue;
228
+ const abs = join(dir, e.name);
229
+ if (e.isDirectory()) {
230
+ if (dir === appDir && e.name === 'public') continue;
231
+ stack.push(abs);
232
+ } else if (/\.m?[jt]s$/.test(e.name)) {
233
+ let src;
234
+ try { src = readFileSync(abs, 'utf8'); } catch { continue; }
235
+ const hit = findDecorator(src);
236
+ if (hit) return { file: abs, hit };
237
+ }
238
+ }
239
+ }
240
+ return null;
241
+ }