@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.
- package/README.md +4 -1
- package/index.d.ts +461 -102
- package/index.js +9 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-input.js +208 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +112 -15
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/helpers.js +85 -0
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +133 -28
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +109 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +24 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +262 -0
- package/src/core-exports.js +190 -0
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/dev-status.js +122 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +632 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +191 -133
- package/src/dev/server.js +109 -37
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +322 -78
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev-styles.js +64 -6
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +150 -69
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +49 -17
- package/src/ssr/head.js +228 -25
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +243 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +52 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +85 -17
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +3 -8
- package/src/component-elision.js +0 -1852
- package/src/elision-differential.js +0 -101
- 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]
|
|
35
|
-
* seams: the runtime (default: detected)
|
|
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))
|
|
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
|
-
|
|
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
|
}
|
package/src/dev-overlay.js
CHANGED
|
@@ -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');
|