@webjsdev/server 0.9.0 → 0.10.1

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 (102) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +793 -125
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +97 -15
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +269 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/csrf.js +21 -3
  33. package/src/dev/bun-app-source.js +95 -5
  34. package/src/dev/config.js +7 -51
  35. package/src/dev/dev-service-worker.js +48 -0
  36. package/src/dev/esm-hot-hooks.js +373 -0
  37. package/src/dev/handler.js +502 -155
  38. package/src/dev/helpers.js +127 -96
  39. package/src/dev/hot-host.js +33 -6
  40. package/src/dev/poke-state.js +110 -0
  41. package/src/dev/serve.js +177 -129
  42. package/src/dev/server.js +92 -33
  43. package/src/dev/specifier-versions.js +125 -0
  44. package/src/dev/watch-ignore.js +49 -4
  45. package/src/dev/watch-poll.js +78 -0
  46. package/src/dev/watch-tree.js +59 -22
  47. package/src/dev-classify.js +59 -26
  48. package/src/dev-error.js +104 -3
  49. package/src/dev-hot-classify.js +291 -0
  50. package/src/dev-hot-client.js +245 -0
  51. package/src/dev-import.js +22 -5
  52. package/src/dev-overlay.js +26 -0
  53. package/src/dev-ready-probe.js +69 -0
  54. package/src/dev-regenerate.js +2 -2
  55. package/src/dev-reload-hold.js +49 -20
  56. package/src/dev-reload-reason.js +61 -0
  57. package/src/dev-reload-worker.js +62 -13
  58. package/src/dev-secret.js +73 -0
  59. package/src/dev-source-locations-hook.js +4 -1
  60. package/src/dev-source-locations.js +33 -2
  61. package/src/dev.js +0 -1
  62. package/src/env-files.js +124 -0
  63. package/src/file-storage.js +2 -2
  64. package/src/foreign-imports.js +228 -0
  65. package/src/form-action-identity.js +52 -25
  66. package/src/form-dispatch.js +142 -66
  67. package/src/html-cache.js +35 -4
  68. package/src/importmap.js +2 -2
  69. package/src/instrumentation.js +59 -3
  70. package/src/js-scan.js +5 -4
  71. package/src/jwe.js +155 -0
  72. package/src/lazy-components.js +166 -0
  73. package/src/listener-core.js +4 -2
  74. package/src/lit-hints.js +172 -0
  75. package/src/middleware-load.js +248 -0
  76. package/src/module-graph.js +73 -47
  77. package/src/module-side-effect.js +284 -0
  78. package/src/public-files.js +73 -0
  79. package/src/redirect-location.js +19 -0
  80. package/src/redirects.js +9 -1
  81. package/src/route-frontier.js +151 -0
  82. package/src/route-report.js +130 -0
  83. package/src/router.js +120 -13
  84. package/src/session.js +32 -10
  85. package/src/ssr/client-router-flag.js +1 -1
  86. package/src/ssr/document.js +37 -13
  87. package/src/ssr/head.js +78 -23
  88. package/src/ssr/preloads.js +31 -45
  89. package/src/ssr/render.js +241 -55
  90. package/src/ssr/responses.js +51 -4
  91. package/src/ssr/shipped-modules.js +22 -16
  92. package/src/testing.d.ts +71 -2
  93. package/src/testing.js +103 -21
  94. package/src/thenable-params.js +26 -0
  95. package/src/vendor/jspm.js +12 -1
  96. package/src/vendor/pins.js +9 -8
  97. package/src/vendor/scanner.js +14 -9
  98. package/src/vendor.js +5 -3
  99. package/webjs-config.schema.json +1 -6
  100. package/src/component-elision.js +0 -1828
  101. package/src/elision-differential.js +0 -101
  102. package/src/elision-report.js +0 -193
@@ -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
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Which dev edits can hot-swap a live component class instead of reloading the
3
+ * page (#1689). Pure and state-free like `dev-classify.js`, so every reason is
4
+ * unit-testable without booting a server; the handler feeds it the previous
5
+ * analysis and the files on disk.
6
+ *
7
+ * The browser half (`dev-hot-client.js`) patches the REGISTERED class in place,
8
+ * because `customElements.define` is once per tag and the native registry keeps
9
+ * the constructor and the lifecycle callbacks it read at define time. Some edits
10
+ * cannot be applied that way, and they are refused here, from source, before
11
+ * any frame is sent:
12
+ *
13
+ * - a changed `constructor`: the registry keeps constructing with the OLD one,
14
+ * so a new default could never apply to a new element either;
15
+ * - a changed instance field initializer: it runs per instance at
16
+ * construction, so a live element keeps the old value (an arrow handler
17
+ * field would keep the old handler);
18
+ * - any `#private` member: a patched method carries a new private brand the
19
+ * live instances lack, a `TypeError` on first use;
20
+ * - a changed `static shadow`: the render root is chosen once per element.
21
+ *
22
+ * The rest of the guards (a changed property shape, `observedAttributes`, an
23
+ * overridden native lifecycle callback) need the evaluated class and run in the
24
+ * browser at swap time.
25
+ */
26
+
27
+ import { matchClosingBrace, matchClosingParenthesis, redactStringsAndTemplates } from './js-scan.js';
28
+
29
+ /**
30
+ * @typedef {{ name: string, ctor: string, fields: string, priv: boolean, shadow: string }} HotClassSignature
31
+ * @typedef {{ classes: HotClassSignature[], componentOnlyExports: boolean }} HotSignature
32
+ */
33
+
34
+ /** Collapse whitespace so a re-indent or a reflow is not read as a change. */
35
+ const squash = (/** @type {string} */ s) => s.replace(/\s+/g, ' ').trim();
36
+
37
+ /**
38
+ * Every named class declaration in `masked` (a position-preserving mask) with
39
+ * the offsets of its body, whatever it extends: a component that extends
40
+ * another component carries constructor and field state like any other.
41
+ * @param {string} masked
42
+ * @returns {Array<{ name: string, start: number, end: number }>}
43
+ */
44
+ function classBodies(masked) {
45
+ const out = [];
46
+ const re = /\bclass\s+([\w$]+)(?:\s*<[^{}();]*?>)?\s*/g;
47
+ let m;
48
+ while ((m = re.exec(masked)) !== null) {
49
+ let i = m.index + m[0].length;
50
+ if (masked.startsWith('extends', i)) {
51
+ i += 'extends'.length;
52
+ while (i < masked.length && /\s/.test(masked[i])) i++;
53
+ const id = /^[\w$.]+/.exec(masked.slice(i));
54
+ if (id) i += id[0].length;
55
+ while (i < masked.length && /\s/.test(masked[i])) i++;
56
+ if (masked[i] === '(') {
57
+ const close = matchClosingParenthesis(masked, i + 1);
58
+ if (close === -1) continue;
59
+ i = close + 1;
60
+ while (i < masked.length && /\s/.test(masked[i])) i++;
61
+ }
62
+ }
63
+ if (masked[i] !== '{') continue;
64
+ const end = matchClosingBrace(masked, i + 1);
65
+ if (end === -1) continue;
66
+ out.push({ name: m[1], start: i + 1, end });
67
+ re.lastIndex = end;
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * An initializer that ends this way cannot stop at the newline: it has no
74
+ * value yet, or it ends on an operator (`label =`, `a ||`, `cond ?`).
75
+ */
76
+ const INIT_CONTINUES_RE = /(?:^|[=?:,.+\-*/%&|^!~<>])\s*$/;
77
+
78
+ /**
79
+ * A line starting this way continues the initializer above it, as JavaScript
80
+ * reads it: no class member starts with an operator, and a `(`, `[` or a
81
+ * template after a field with no `;` is a call, an index or a tag of it.
82
+ */
83
+ const LINE_CONTINUES_RE = /^\s*(?:[?:.+\-*/%&|^,=<>([`]|in\b|instanceof\b)/;
84
+
85
+ /**
86
+ * The depth-0 members of a class body: the constructor's raw text, every
87
+ * instance field initializer's raw text, and a `static shadow` initializer.
88
+ * Reads positions from `maskedBody` (strings, templates and comments blanked)
89
+ * and text from `rawBody`, which shares its offsets.
90
+ * @param {string} maskedBody
91
+ * @param {string} rawBody
92
+ */
93
+ function members(maskedBody, rawBody) {
94
+ let ctor = '';
95
+ let shadow = '';
96
+ /** @type {string[]} */
97
+ const fields = [];
98
+ let depth = 0;
99
+ let atStart = true;
100
+ let tpl = false;
101
+ for (let i = 0; i < maskedBody.length; i++) {
102
+ const c = maskedBody[i];
103
+ // A template's body is blanked but keeps its line breaks; skip it whole.
104
+ if (c === '`') { tpl = !tpl; atStart = false; continue; }
105
+ if (tpl) continue;
106
+ if (c === '{' || c === '(' || c === '[') { depth++; atStart = false; continue; }
107
+ if (c === '}' || c === ')' || c === ']') {
108
+ depth--;
109
+ // A method body closing at depth 0 ends the member.
110
+ if (depth === 0 && c === '}') atStart = true;
111
+ continue;
112
+ }
113
+ if (depth !== 0) continue;
114
+ if (c === ';' || c === '\n') { atStart = true; continue; }
115
+ if (!atStart || /\s/.test(c)) continue;
116
+ atStart = false;
117
+ const rest = maskedBody.slice(i);
118
+ const ctorM = /^constructor\s*\(/.exec(rest);
119
+ if (ctorM) {
120
+ const open = i + ctorM[0].length;
121
+ const close = matchClosingParenthesis(maskedBody, open);
122
+ if (close === -1) continue;
123
+ const brace = maskedBody.indexOf('{', close);
124
+ const end = brace === -1 ? -1 : matchClosingBrace(maskedBody, brace + 1);
125
+ if (end === -1) continue;
126
+ ctor = squash(rawBody.slice(i, end + 1));
127
+ i = end;
128
+ atStart = true;
129
+ continue;
130
+ }
131
+ const staticShadow = /^static\s+shadow\s*=/.exec(rest);
132
+ const field = staticShadow ? null : /^(?:(?:readonly|declare|accessor)\s+)*(#?[\w$]+)\s*=(?!=)/.exec(rest);
133
+ const assign = staticShadow || field;
134
+ if (!assign || /^static\b/.test(rest) && !staticShadow) continue;
135
+ // The initializer runs to the next depth-0 `;`, or to a line end that
136
+ // closes it: a Prettier-wrapped `label =\n 'Save'` or a multi-line
137
+ // ternary carries on, and cutting it at its first newline would read a
138
+ // changed value as unchanged and swap a class whose live elements keep
139
+ // the old one. Running long only ever refuses a swap.
140
+ const from = i + assign[0].length;
141
+ let j = from;
142
+ let d = 0;
143
+ let inTpl = false;
144
+ for (; j < maskedBody.length; j++) {
145
+ const ch = maskedBody[j];
146
+ if (ch === '`') { inTpl = !inTpl; continue; }
147
+ if (inTpl) continue;
148
+ if (ch === '{' || ch === '(' || ch === '[') d++;
149
+ else if (ch === '}' || ch === ')' || ch === ']') { if (d === 0) break; d--; }
150
+ else if (d === 0 && ch === ';') break;
151
+ else if (d === 0 && ch === '\n'
152
+ && !INIT_CONTINUES_RE.test(maskedBody.slice(from, j))
153
+ && !LINE_CONTINUES_RE.test(maskedBody.slice(j + 1))) break;
154
+ }
155
+ const text = squash(rawBody.slice(i, j));
156
+ if (staticShadow) shadow = squash(rawBody.slice(i + assign[0].length, j));
157
+ else fields.push(text);
158
+ i = j - 1;
159
+ }
160
+ return { ctor, shadow, fields: fields.join('\n') };
161
+ }
162
+
163
+ /**
164
+ * The hot-swap signature of a component module: per named class its
165
+ * constructor text, its instance field initializers, whether it has a
166
+ * `#private` member and its `static shadow` initializer, plus whether the
167
+ * module exports only classes it declares. Two signatures that differ in a
168
+ * class's `ctor`, `fields` or `shadow`, or in their class names, cannot be
169
+ * hot-swapped.
170
+ *
171
+ * @param {string} erasedSrc the module source with TypeScript types erased
172
+ * @returns {HotSignature}
173
+ */
174
+ export function hotSignature(erasedSrc) {
175
+ const masked = redactStringsAndTemplates(erasedSrc, true);
176
+ const classes = classBodies(masked).map(({ name, start, end }) => {
177
+ const body = masked.slice(start, end);
178
+ const { ctor, shadow, fields } = members(body, erasedSrc.slice(start, end));
179
+ return { name, ctor, fields, shadow, priv: /(^|[^\w$])#[A-Za-z_$]/.test(body) };
180
+ });
181
+ const declared = new Set(classes.map((c) => c.name));
182
+ let componentOnlyExports = true;
183
+ for (const m of masked.matchAll(/\bexport\b\s*(default\s+)?([^\n;]*)/g)) {
184
+ const rest = m[2];
185
+ if (/^class\b/.test(rest)) continue;
186
+ const ident = /^([\w$]+)\s*$/.exec(rest);
187
+ if (m[1] && ident && declared.has(ident[1])) continue;
188
+ const list = /^\{([^}]*)\}\s*(from\b)?/.exec(rest);
189
+ if (list && !list[2]) {
190
+ const names = list[1].split(',').map((s) => s.trim().split(/\s+as\s+/)[0]).filter(Boolean);
191
+ if (names.every((n) => declared.has(n))) continue;
192
+ }
193
+ componentOnlyExports = false;
194
+ break;
195
+ }
196
+ return { classes, componentOnlyExports };
197
+ }
198
+
199
+ /**
200
+ * Start reading the hot-swap baseline of each file `baselines` has none for,
201
+ * WITHOUT waiting: the reads only matter once a later edit arrives, so the
202
+ * analysis that gates the first request must not wait on N reads and strips
203
+ * (#1689). Each entry holds the pending read until it lands, and
204
+ * `findHotBoundaries` awaits whatever `baselineOf` returns. A read that lands
205
+ * after a newer signature was recorded for its file (an edit's) is dropped.
206
+ *
207
+ * @param {Iterable<string>} files
208
+ * @param {Map<string, HotSignature | Promise<HotSignature | null>>} baselines
209
+ * @param {(abs: string) => Promise<HotSignature | null>} read never rejects
210
+ * @returns {void}
211
+ */
212
+ export function primeHotBaselines(files, baselines, read) {
213
+ for (const f of files) {
214
+ if (baselines.has(f)) continue;
215
+ const pending = read(f).then((sig) => {
216
+ if (baselines.get(f) === pending) {
217
+ if (sig) baselines.set(f, sig);
218
+ else baselines.delete(f);
219
+ }
220
+ return sig;
221
+ });
222
+ baselines.set(f, pending);
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Walk each changed shipped file UP the shipped importer graph to the component
228
+ * modules (the hot BOUNDARIES) that would have to be re-imported for the tab to
229
+ * run the edit. A component module stops the walk; a browser entry that is not
230
+ * a component (`instrumentation-client`; a page or layout never ships, #1686)
231
+ * or a module nothing shipped imports means the edit has no boundary and the
232
+ * page must reload. Every module the walk passes through on
233
+ * the way must have no module-scope side effect, because re-evaluating it would
234
+ * run that effect a second time. Each boundary must still be hot-swappable
235
+ * against the baseline signature the browser holds.
236
+ *
237
+ * @param {Iterable<string>} changed absolute paths of the changed shipped files
238
+ * @param {{
239
+ * shipped: Set<string>,
240
+ * componentFiles: Set<string>,
241
+ * routeFiles: Set<string>,
242
+ * importersOf: (abs: string) => Iterable<string>,
243
+ * signatureOf: (abs: string) => HotSignature | null | Promise<HotSignature | null>,
244
+ * baselineOf: (abs: string) => HotSignature | null | undefined | Promise<HotSignature | null>,
245
+ * sideEffectOf: (abs: string) => boolean | Promise<boolean>,
246
+ * rel?: (abs: string) => string,
247
+ * }} ctx
248
+ * @returns {Promise<{ ok: true, boundaries: string[] } | { ok: false, why: string, by: string }>}
249
+ */
250
+ export async function findHotBoundaries(changed, ctx) {
251
+ const rel = ctx.rel || ((/** @type {string} */ f) => f);
252
+ const fail = (/** @type {string} */ why, /** @type {string} */ f) => ({ ok: /** @type {false} */ (false), why, by: rel(f) });
253
+ /** @type {Set<string>} */
254
+ const boundaries = new Set();
255
+ /** @type {Set<string>} */
256
+ const seen = new Set();
257
+ const queue = [...changed];
258
+ while (queue.length) {
259
+ const f = /** @type {string} */ (queue.shift());
260
+ if (seen.has(f)) continue;
261
+ seen.add(f);
262
+ // A component module is a boundary before anything else: one the boot
263
+ // imports from a route module's frontier has no shipped importer, and is
264
+ // still one.
265
+ if (ctx.componentFiles.has(f)) { boundaries.add(f); continue; }
266
+ if (ctx.routeFiles.has(f)) return fail('no-hot-boundary', f);
267
+ if (await ctx.sideEffectOf(f)) return fail('side-effect-module', f);
268
+ const importers = [...ctx.importersOf(f)].filter((i) => ctx.shipped.has(i));
269
+ if (!importers.length) return fail('no-hot-boundary', f);
270
+ queue.push(...importers);
271
+ }
272
+ if (!boundaries.size) return fail('no-hot-boundary', [...changed][0] || '');
273
+ for (const b of boundaries) {
274
+ const base = await ctx.baselineOf(b);
275
+ if (!base) return fail('no-hot-baseline', b);
276
+ const sig = await ctx.signatureOf(b);
277
+ if (!sig) return fail('no-hot-baseline', b);
278
+ if (sig.classes.some((c) => c.priv)) return fail('private-members', b);
279
+ const names = (/** @type {HotSignature} */ s) => s.classes.map((c) => c.name).sort().join(',');
280
+ if (names(sig) !== names(base)) return fail('class-set-changed', b);
281
+ const old = new Map(base.classes.map((c) => [c.name, c]));
282
+ for (const c of sig.classes) {
283
+ const o = /** @type {HotClassSignature} */ (old.get(c.name));
284
+ if (c.ctor !== o.ctor) return fail('constructor-changed', b);
285
+ if (c.fields !== o.fields) return fail('instance-fields-changed', b);
286
+ if (c.shadow !== o.shadow) return fail('static-shadow-changed', b);
287
+ }
288
+ if (!sig.componentOnlyExports) return fail('non-component-exports', b);
289
+ }
290
+ return { ok: true, boundaries: [...boundaries].sort() };
291
+ }