@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
@@ -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
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * The dev component hot swap, BROWSER half (#1689). A browser-safe,
3
+ * import-free module that `reloadClientJs` (`dev/helpers.js`) inlines into
4
+ * `/__webjs/reload.js` after an `export`-keyword strip, like `dev-styles.js`,
5
+ * so the browser test drives the exact code that ships. Production never loads
6
+ * it, so `@webjsdev/core`'s `hot.js` reads find nothing there.
7
+ *
8
+ * `installHotClient(globalThis)` publishes `{ strings, replace, applyModules }`
9
+ * on `globalThis[Symbol.for('webjs.hot')]`, which core reads:
10
+ *
11
+ * - `strings(arr, kind)` interns template strings by their text, so the
12
+ * re-evaluated module's unchanged templates keep the identity the reconciler
13
+ * compares (`prev.strings === tr.strings`) and update in place, keeping
14
+ * their DOM (focus, an input's value, a nested component), while a template
15
+ * whose text changed rebuilds.
16
+ * - `replace(tag, Old, New)` is what core's `customElements.define` wrapper
17
+ * calls on a second define of a known tag. The native registry keeps the
18
+ * class and the lifecycle callbacks it read at define time, so the new
19
+ * class's members are copied onto the REGISTERED one, level by level up to
20
+ * the first class both chains share, and every live instance re-renders.
21
+ * Reactive values live per instance in `__propValues` and instance signals in
22
+ * fields, so both survive. A change the native registry would ignore throws
23
+ * before anything is patched, and the reload client reloads with the reason.
24
+ * - `applyModules(urls)` imports the versioned component modules a
25
+ * `component` verdict lists, in order, and reports the tags it swapped.
26
+ *
27
+ * The reload script runs before the page's boot script (`ssr/head.js`), so the
28
+ * interner sees every template from the first evaluation on.
29
+ */
30
+
31
+ /** Callbacks the native registry captures at define time. */
32
+ const HOT_NATIVE_CALLBACKS = [
33
+ 'connectedCallback', 'disconnectedCallback', 'adoptedCallback', 'attributeChangedCallback',
34
+ 'connectedMoveCallback', 'formAssociatedCallback', 'formResetCallback', 'formDisabledCallback',
35
+ 'formStateRestoreCallback',
36
+ ];
37
+
38
+ /** Statics that are part of a class's identity or compared by a guard, never copied. */
39
+ const HOT_FIXED_STATICS = ['prototype', 'name', 'length', 'properties', 'elementProperties', 'elementStyles', 'observedAttributes', 'finalized'];
40
+
41
+ /** @param {any} C */
42
+ function hotChain(C) {
43
+ const out = [];
44
+ for (let c = C; c && c !== Function.prototype; c = Object.getPrototypeOf(c)) out.push(c);
45
+ return out;
46
+ }
47
+
48
+ /** @param {unknown} v */
49
+ function hotText(v) {
50
+ if (v === undefined) return '';
51
+ if (typeof v === 'function') return String(v);
52
+ if (v && typeof v === 'object') {
53
+ const o = /** @type {Record<string, unknown>} */ (v);
54
+ return '{' + Object.keys(o).sort().map((k) => k + ':' + hotText(o[k])).join(',') + '}';
55
+ }
56
+ try { return JSON.stringify(v); } catch (_) { return String(v); }
57
+ }
58
+
59
+ /**
60
+ * The declared reactive property shape of a class, as one comparable string,
61
+ * plus the names (for the patch to skip their prototype accessors).
62
+ * @param {any} C
63
+ */
64
+ function hotShape(C) {
65
+ /** @type {Record<string, unknown>} */
66
+ const all = {};
67
+ for (const c of hotChain(C).reverse()) {
68
+ if (Object.prototype.hasOwnProperty.call(c, 'properties') && c.properties && typeof c.properties === 'object') Object.assign(all, c.properties);
69
+ }
70
+ if (C.elementProperties instanceof Map) for (const [k, v] of C.elementProperties) all[k] = v;
71
+ const names = Object.keys(all).sort();
72
+ const text = names.map((name) => {
73
+ const raw = all[name];
74
+ const d = /** @type {any} */ (raw && typeof raw === 'object' ? raw : { type: raw });
75
+ return [name, d.type && d.type.name, hotText(d.attribute), !!d.reflect, !!d.state, !!d.noAccessor,
76
+ hotText(d.converter), hotText(d.hasChanged), hotText(d.default)].join('|');
77
+ }).join('\n');
78
+ return { names, text };
79
+ }
80
+
81
+ /** @param {any} C */
82
+ function hotObserved(C) {
83
+ try { return [...(C.observedAttributes || [])].map(String).sort().join(','); } catch (_) { return '?'; }
84
+ }
85
+
86
+ /** @param {unknown} s @returns {CSSStyleSheet | null} */
87
+ function hotSheet(s) {
88
+ if (typeof CSSStyleSheet !== 'undefined' && s instanceof CSSStyleSheet) return s;
89
+ const o = /** @type {any} */ (s);
90
+ if (o && typeof CSSStyleSheet !== 'undefined' && o.styleSheet instanceof CSSStyleSheet) return o.styleSheet;
91
+ if (o && typeof o.text === 'string' && typeof CSSStyleSheet !== 'undefined') {
92
+ const sheet = new CSSStyleSheet();
93
+ sheet.replaceSync(o.text);
94
+ return sheet;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Every element in `root` (and in each open shadow root below it) that is an
101
+ * instance of `C`, including instances of a subclass.
102
+ * @param {Document | ShadowRoot | Element} root
103
+ * @param {Function} C
104
+ * @param {Element[]} [out]
105
+ */
106
+ function hotInstances(root, C, out = []) {
107
+ for (const el of root.querySelectorAll('*')) {
108
+ if (el instanceof C) out.push(el);
109
+ if (el.shadowRoot) hotInstances(el.shadowRoot, C, out);
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * The reason `New` cannot be patched onto the registered `Old`, or null.
116
+ * @param {any} Old
117
+ * @param {any} New
118
+ * @returns {{ why: string, depth: number }}
119
+ */
120
+ function hotCheck(Old, New) {
121
+ const oldChain = hotChain(Old);
122
+ const newChain = hotChain(New);
123
+ const depth = newChain.findIndex((c) => oldChain.includes(c));
124
+ if (depth === -1 || oldChain.indexOf(newChain[depth]) !== depth) return { why: 'class-chain-changed', depth };
125
+ if (hotShape(Old).text !== hotShape(New).text) return { why: 'reactive-properties-changed', depth };
126
+ if (hotObserved(Old) !== hotObserved(New)) return { why: 'observed-attributes-changed', depth };
127
+ if (Old.shadow !== New.shadow) return { why: 'static-shadow-changed', depth };
128
+ if (Old.formAssociated !== New.formAssociated) return { why: 'form-associated-changed', depth };
129
+ for (let i = 0; i < depth; i++) {
130
+ for (const cb of HOT_NATIVE_CALLBACKS) {
131
+ const o = Object.getOwnPropertyDescriptor(oldChain[i].prototype, cb);
132
+ const n = Object.getOwnPropertyDescriptor(newChain[i].prototype, cb);
133
+ if (!!o !== !!n || (o && n && String(o.value) !== String(n.value))) return { why: cb + '-changed', depth };
134
+ }
135
+ }
136
+ return { why: '', depth };
137
+ }
138
+
139
+ /**
140
+ * Copy `from`'s own members onto `to`, then drop the ones `from` no longer
141
+ * has. `skip` names members that stay as they are.
142
+ * @param {any} to
143
+ * @param {any} from
144
+ * @param {(key: string | symbol) => boolean} skip
145
+ * @param {(key: string | symbol) => boolean} keep members never deleted
146
+ */
147
+ function hotCopy(to, from, skip, keep) {
148
+ for (const key of Reflect.ownKeys(from)) {
149
+ if (skip(key)) continue;
150
+ const d = Object.getOwnPropertyDescriptor(from, key);
151
+ if (d) Object.defineProperty(to, key, d);
152
+ }
153
+ for (const key of Reflect.ownKeys(to)) {
154
+ if (skip(key) || keep(key) || Object.prototype.hasOwnProperty.call(from, key)) continue;
155
+ delete to[key];
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Install the dev hot client on `g` (normally `globalThis`). Idempotent.
161
+ * @param {any} g
162
+ */
163
+ export function installHotClient(g) {
164
+ const KEY = Symbol.for('webjs.hot');
165
+ if (g[KEY]) return g[KEY];
166
+ /** @type {WeakMap<object, any>} */
167
+ const canonical = new WeakMap();
168
+ /** @type {Map<string, any>} */
169
+ const byText = new Map();
170
+ /** @type {string[] | null} */
171
+ let swapped = null;
172
+
173
+ /** @param {any} arr @param {number} kind */
174
+ function strings(arr, kind) {
175
+ const hit = canonical.get(arr);
176
+ if (hit) return hit;
177
+ const key = kind + '\u0000' + Array.prototype.join.call(arr, '\u0001');
178
+ let c = byText.get(key);
179
+ if (!c) byText.set(key, (c = arr));
180
+ canonical.set(arr, c);
181
+ return c;
182
+ }
183
+
184
+ /** @param {string} tag @param {any} Old @param {any} New */
185
+ function replace(tag, Old, New) {
186
+ const { why, depth } = hotCheck(Old, New);
187
+ if (why) throw new Error('[webjs] hot swap of <' + tag + '> needs a reload: ' + why);
188
+ const props = new Set(hotShape(Old).names);
189
+ const oldChain = hotChain(Old);
190
+ const newChain = hotChain(New);
191
+ for (let i = 0; i < depth; i++) {
192
+ hotCopy(oldChain[i].prototype, newChain[i].prototype,
193
+ (k) => k === 'constructor' || props.has(/** @type {string} */ (k)) || HOT_NATIVE_CALLBACKS.includes(/** @type {string} */ (k)),
194
+ () => false);
195
+ hotCopy(oldChain[i], newChain[i],
196
+ (k) => HOT_FIXED_STATICS.includes(/** @type {string} */ (k)),
197
+ // Framework state cached on a class (a symbol, a `_`-prefixed name)
198
+ // is not the author's, so it is never deleted.
199
+ (k) => typeof k === 'symbol' || k.startsWith('_'));
200
+ }
201
+ // A class that memoizes its finalized styles (Lit's `elementStyles`) keeps
202
+ // the old ones in a cache; an own value shadows it with the new styles.
203
+ if (typeof Old.finalizeStyles === 'function') {
204
+ Object.defineProperty(Old, 'elementStyles', { value: Old.finalizeStyles(Old.styles), configurable: true, writable: true });
205
+ }
206
+ const doc = g.document;
207
+ if (doc) {
208
+ for (const el of hotInstances(doc, Old)) {
209
+ const C = /** @type {any} */ (el).constructor;
210
+ if (el.shadowRoot && C.shadow === true && 'adoptedStyleSheets' in el.shadowRoot) {
211
+ const styles = typeof C.finalizeStyles === 'function' ? C.elementStyles : C.styles;
212
+ const list = Array.isArray(styles) ? styles.flat(Infinity) : styles ? [styles] : [];
213
+ el.shadowRoot.adoptedStyleSheets = list.map(hotSheet).filter(Boolean);
214
+ }
215
+ const inst = /** @type {any} */ (el);
216
+ if (typeof inst.requestUpdate === 'function') inst.requestUpdate();
217
+ }
218
+ }
219
+ if (swapped) swapped.push(tag);
220
+ return Old;
221
+ }
222
+
223
+ /**
224
+ * Import the versioned component modules a `component` verdict lists, in
225
+ * order. A module whose define throws (a guard refused the swap) rejects its
226
+ * import, which ends the apply with that reason.
227
+ * @param {string[]} urls
228
+ * @returns {Promise<{ ok: true, tags: string[] } | { ok: false, why: string }>}
229
+ */
230
+ function applyModules(urls) {
231
+ /** @type {string[]} */
232
+ const tags = [];
233
+ swapped = tags;
234
+ let p = Promise.resolve();
235
+ for (const u of urls) p = p.then(() => import(u));
236
+ return p.then(
237
+ () => { swapped = null; return { ok: /** @type {true} */ (true), tags }; },
238
+ (e) => { swapped = null; return { ok: /** @type {false} */ (false), why: String((e && e.message) || e) }; },
239
+ );
240
+ }
241
+
242
+ const api = { strings, replace, applyModules };
243
+ g[KEY] = api;
244
+ return api;
245
+ }
package/src/dev-import.js CHANGED
@@ -22,17 +22,29 @@
22
22
  * a plain path, so on Bun it falls back to the file URL (no fresh import; the
23
23
  * dev supervisor's restart still picks the edit up).
24
24
  *
25
+ * The content query covers the ENTRY only: a module the entry imports is not
26
+ * re-imported through here. On Node the dev resolve hook (`dev/esm-hot-hooks.js`,
27
+ * #1689) versions everything below it, adding `webjs-v=<n>` to an edited module
28
+ * and its importers on top of this query, which is also what carries a
29
+ * dependency's edit to an entry whose own bytes did not change. Bun has no
30
+ * resolve hook, so there the dev source plugins stamp the versions below the
31
+ * entry (`dev/bun-app-source.js`) and this specifier adds the entry's own
32
+ * `webjs-v=<n>` after the content query.
33
+ *
25
34
  * Outside dev the specifier is the plain file URL, exactly as before.
26
35
  */
27
36
  import { pathToFileURL } from 'node:url';
28
37
  import { readFileSync } from 'node:fs';
29
38
  import { createHash } from 'node:crypto';
39
+ import { explainImportError } from './foreign-imports.js';
40
+ import { moduleVersion } from './dev/esm-hot-hooks.js';
30
41
 
31
42
  /**
32
43
  * @param {string} file absolute file path
33
44
  * @param {boolean} dev whether to cache-bust
34
- * @param {{ bun?: boolean, version?: (file: string) => string }} [opts] test
35
- * seams: the runtime (default: detected) and the content version
45
+ * @param {{ bun?: boolean, version?: (file: string) => string, moduleVersion?: (file: string) => number }} [opts]
46
+ * test seams: the runtime (default: detected), the content version, and the
47
+ * Bun module version (#1689)
36
48
  * @returns {string}
37
49
  */
38
50
  export function devImportSpecifier(file, dev, opts = {}) {
@@ -40,7 +52,10 @@ export function devImportSpecifier(file, dev, opts = {}) {
40
52
  if (!dev) return url;
41
53
  const bun = opts.bun ?? Boolean(typeof process !== 'undefined' && process.versions && process.versions.bun);
42
54
  const bust = `?t=${(opts.version ?? contentVersion)(file)}`;
43
- if (bun && !/[?#]/.test(file)) return file + bust;
55
+ if (bun && !/[?#]/.test(file)) {
56
+ const v = (opts.moduleVersion ?? moduleVersion)(file);
57
+ return file + bust + (v ? `&webjs-v=${v}` : '');
58
+ }
44
59
  return url + bust;
45
60
  }
46
61
 
@@ -113,7 +128,9 @@ export async function devImport(file, dev) {
113
128
  // promise instead. A rejection is evicted so the next call re-imports (the
114
129
  // retry semantic a cold import had), and `clearProdImportCache` drops the
115
130
  // whole map on an embedded host's `rebuild()`.
116
- const p = import(base);
131
+ // A load failure caused by a foreign import (`next/*`, `react`, `lit`)
132
+ // is rewritten to name the WebJs replacement (foreign-imports.js).
133
+ const p = import(base).catch((e) => { throw explainImportError(e, file); });
117
134
  PROD_MODULES.set(file, p);
118
135
  p.catch(() => { if (PROD_MODULES.get(file) === p) PROD_MODULES.delete(file); });
119
136
  return p;
@@ -123,6 +140,6 @@ export async function devImport(file, dev) {
123
140
  return await import(spec);
124
141
  } catch (e) {
125
142
  RETRY_KEYS.set(base, `${base}-r${++retrySeq}`);
126
- throw e;
143
+ throw explainImportError(e, file);
127
144
  }
128
145
  }
@@ -88,6 +88,32 @@ export function dismissDevOverlay() {
88
88
  __wjPendingSeq = -1;
89
89
  }
90
90
 
91
+ /**
92
+ * Take the overlay down after a component hot swap only when the swap settled
93
+ * what it describes (#1689). A component swap re-renders nothing on the
94
+ * server, so nothing would re-raise a frame it wrongly dismissed: a `render`
95
+ * error on this page, or a `ts-strip` failure in some other module, would
96
+ * vanish while it still stands. So the overlay goes only for a `rebuild`
97
+ * frame (the rebuild that produced this swap succeeded) or a frame whose
98
+ * `file` is one the swap re-imported or was edited for, `paths` holding those
99
+ * as app-root paths (`/components/x.ts`).
100
+ *
101
+ * @param {string[]} paths
102
+ * @returns {boolean} whether it dismissed
103
+ */
104
+ export function dismissDevOverlayCoveredBy(paths) {
105
+ const f = __wjFrame;
106
+ if (!f) return false;
107
+ let hit = f.kind === 'rebuild';
108
+ const file = String(f.file || '').replace(/\\/g, '/');
109
+ for (let i = 0; !hit && file && i < paths.length; i++) {
110
+ const p = paths[i];
111
+ hit = !!p && file.length >= p.length && file.slice(file.length - p.length) === p;
112
+ }
113
+ if (hit) dismissDevOverlay();
114
+ return hit;
115
+ }
116
+
91
117
  /** Append a styled text row to `parent`. */
92
118
  function __wjRow(parent, css, text) {
93
119
  const d = document.createElement('div');