@webjsdev/server 0.9.0 → 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 +427 -109
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -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 +96 -14
- 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/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- 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 +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -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/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -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 +177 -129
- package/src/dev/server.js +92 -33
- 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 +2 -2
- 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.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 +140 -65
- 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 +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +80 -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 +1 -6
- package/src/component-elision.js +0 -1828
- package/src/elision-differential.js +0 -101
- package/src/elision-report.js +0 -193
package/src/dev-classify.js
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
54
|
-
* unrecognised
|
|
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
|
|
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
|
|
84
|
+
export function mergeVerdicts(a, b) {
|
|
67
85
|
if (!a) return b || null;
|
|
68
86
|
if (!b) return a;
|
|
69
|
-
|
|
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
|
|
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
|
-
*
|
|
108
|
-
*
|
|
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
|
-
*
|
|
112
|
-
* land here. `public/` is not in the module graph at all, so the
|
|
113
|
-
* cannot tell what the file IS or what depends on it, and most of
|
|
114
|
-
* there (an image, a font, a service worker, a hand-written
|
|
115
|
-
* something no amount of re-rendering picks up.
|
|
116
|
-
* the
|
|
117
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|