@webjsdev/server 0.7.2 → 0.8.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 +10 -5
- package/index.js +21 -3
- package/package.json +4 -6
- package/src/actions.js +6 -6
- package/src/auth.js +1 -1
- package/src/cache.js +19 -2
- package/src/check.js +226 -95
- package/src/component-elision.js +797 -0
- package/src/component-scanner.js +8 -2
- package/src/context.js +36 -0
- package/src/crypto-utils.js +65 -0
- package/src/dev.js +479 -94
- package/src/importmap.js +282 -20
- package/src/js-scan.js +288 -0
- package/src/module-graph.js +150 -13
- package/src/rate-limit.js +100 -12
- package/src/script-tag-json.js +63 -0
- package/src/session.js +60 -14
- package/src/ssr.js +133 -17
- package/src/vendor.js +1231 -103
- package/src/websocket.js +3 -1
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static analyser deciding whether a webjs component module can be
|
|
3
|
+
* ELIDED from the browser, that is, never downloaded as JS because its
|
|
4
|
+
* SSR'd HTML is the complete, final output and the component does no
|
|
5
|
+
* client-side work.
|
|
6
|
+
*
|
|
7
|
+
* Direction of safety: a false "interactive" verdict only costs a
|
|
8
|
+
* missed optimization (we ship a module we could have skipped). A false
|
|
9
|
+
* "display-only" verdict BREAKS the page (an interactive component never
|
|
10
|
+
* boots). So every ambiguity resolves to "interactive / ship". The
|
|
11
|
+
* analyser is a DENYLIST of interactivity signals; anything it does not
|
|
12
|
+
* recognise ships by default.
|
|
13
|
+
*
|
|
14
|
+
* ─────────────────────────────────────────────────────────────────────
|
|
15
|
+
* SINGLE SOURCE OF TRUTH FOR INTERACTIVITY SIGNALS
|
|
16
|
+
* ─────────────────────────────────────────────────────────────────────
|
|
17
|
+
* The exported constants below (REACTIVE_IMPORTS, CLIENT_LIFECYCLE_HOOKS,
|
|
18
|
+
* CLIENT_METHOD_CALLS) ARE the contract. When the framework gains a new
|
|
19
|
+
* interactivity feature (a new lifecycle hook, a new reactive primitive,
|
|
20
|
+
* a new client-only directive, a new event-binding syntax), the marker
|
|
21
|
+
* for it MUST be added to the matching list here, or the analyser will
|
|
22
|
+
* wrongly elide components that use it.
|
|
23
|
+
*
|
|
24
|
+
* This is enforced two ways:
|
|
25
|
+
* 1. The guard test in test/elision/lifecycle-coverage.test.js
|
|
26
|
+
* introspects the live framework surface and fails on drift:
|
|
27
|
+
* it enumerates every overridable WebComponent hook (each must flip
|
|
28
|
+
* a component to interactive), classifies every @webjsdev/core/
|
|
29
|
+
* directives export as client-only or render-time (a new directive
|
|
30
|
+
* fails the test until classified), and checks that no
|
|
31
|
+
* REACTIVE_IMPORTS entry is stale. Adding an interactivity surface
|
|
32
|
+
* without updating these lists fails that test.
|
|
33
|
+
* 2. Maintainer pointers route changes back here: invariant 6 in
|
|
34
|
+
* packages/core/AGENTS.md and the MAINTAINER NOTE on the
|
|
35
|
+
* WebComponent lifecycle in packages/core/src/component.js both
|
|
36
|
+
* direct anyone adding an interactivity surface to update these
|
|
37
|
+
* lists, since webjs development is largely AI-agent driven.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
extractWebComponentClassBodies,
|
|
42
|
+
matchClosingBrace,
|
|
43
|
+
redactStringsAndTemplates,
|
|
44
|
+
} from './js-scan.js';
|
|
45
|
+
import { transitiveDeps } from './module-graph.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Named imports from a `@webjsdev/core` specifier that imply the
|
|
49
|
+
* component does reactive or async client work. Importing any of these
|
|
50
|
+
* forces the module to ship: signals re-render on change, Task resolves
|
|
51
|
+
* on the client, the streaming directives settle on the client, and
|
|
52
|
+
* Context consumers subscribe after upgrade.
|
|
53
|
+
*
|
|
54
|
+
* @type {ReadonlySet<string>}
|
|
55
|
+
*/
|
|
56
|
+
export const REACTIVE_IMPORTS = new Set([
|
|
57
|
+
'signal',
|
|
58
|
+
'computed',
|
|
59
|
+
'effect',
|
|
60
|
+
'watch',
|
|
61
|
+
'Task',
|
|
62
|
+
'until',
|
|
63
|
+
'asyncAppend',
|
|
64
|
+
'asyncReplace',
|
|
65
|
+
'ContextProvider',
|
|
66
|
+
'ContextConsumer',
|
|
67
|
+
'connectWS',
|
|
68
|
+
// Client-only directives. `ref` / `createRef` fire a callback against the
|
|
69
|
+
// live element (focus, measure, third-party mount); `live` syncs an input
|
|
70
|
+
// value against the DOM. All produce identical SSR HTML but do real work
|
|
71
|
+
// only after upgrade, so a component using them must ship. Render-time
|
|
72
|
+
// directives (repeat, unsafeHTML, keyed, guard, cache, map) are NOT here:
|
|
73
|
+
// they are SSR-renderable and common in display-only components.
|
|
74
|
+
'ref',
|
|
75
|
+
'createRef',
|
|
76
|
+
'live',
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Overridable WebComponent lifecycle hooks. Overriding any of them is
|
|
81
|
+
* client-side behaviour the SSR pass never runs, so the module must
|
|
82
|
+
* ship. `render` is deliberately absent: every display-only component
|
|
83
|
+
* defines `render`, and the SSR walker calls it directly. `constructor`
|
|
84
|
+
* is absent for the same reason (it runs during SSR to seed first
|
|
85
|
+
* paint). Keep this list in lockstep with the lifecycle table in
|
|
86
|
+
* agent-docs/components.md.
|
|
87
|
+
*
|
|
88
|
+
* @type {readonly string[]}
|
|
89
|
+
*/
|
|
90
|
+
export const CLIENT_LIFECYCLE_HOOKS = [
|
|
91
|
+
'connectedCallback',
|
|
92
|
+
'disconnectedCallback',
|
|
93
|
+
'attributeChangedCallback',
|
|
94
|
+
// Standard custom-element callback webjs does not itself define (so it
|
|
95
|
+
// is absent from the prototype guard's CLASSIFICATION), but an author
|
|
96
|
+
// can still override it to do client work. Kept for conservatism.
|
|
97
|
+
'adoptedCallback',
|
|
98
|
+
'shouldUpdate',
|
|
99
|
+
'willUpdate',
|
|
100
|
+
'update',
|
|
101
|
+
'updated',
|
|
102
|
+
'firstUpdated',
|
|
103
|
+
'getUpdateComplete',
|
|
104
|
+
'renderError',
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Method calls that only make sense on the client. `addController`
|
|
109
|
+
* registers a ReactiveController (client lifecycle). `requestUpdate`
|
|
110
|
+
* schedules a re-render. Either implies the component is not inert.
|
|
111
|
+
*
|
|
112
|
+
* @type {readonly string[]}
|
|
113
|
+
*/
|
|
114
|
+
export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'requestUpdate'];
|
|
115
|
+
|
|
116
|
+
/** Match a `@event=${...}` binding inside a template (unquoted per invariant 4). */
|
|
117
|
+
const EVENT_BINDING_RE = /@[A-Za-z][\w-]*\s*=\s*\$\{/;
|
|
118
|
+
|
|
119
|
+
/** Match a `.onclick=${...}` (native event-handler property) binding. */
|
|
120
|
+
const EVENT_PROP_RE = /\.on[a-z]+\s*=\s*\$\{/;
|
|
121
|
+
|
|
122
|
+
/** Match a rendered `<slot>` / `<slot ` / `<slot/>`, but not `<slot-machine>`. */
|
|
123
|
+
const SLOT_RE = /<slot[\s/>]/;
|
|
124
|
+
|
|
125
|
+
/** A `.server.{js,ts,mjs,mts}` file: a stub on the client, inert there. */
|
|
126
|
+
const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
|
|
127
|
+
|
|
128
|
+
/** Side-effect or named import of the client router subpath. */
|
|
129
|
+
const CLIENT_ROUTER_SUBPATH_RE = /['"]@webjsdev\/core\/client-router['"]/;
|
|
130
|
+
/** Client-only named APIs from the `@webjsdev/core` main entry. */
|
|
131
|
+
const CLIENT_ROUTER_IMPORTS = ['navigate', 'enableClientRouter', 'disableClientRouter', 'revalidate'];
|
|
132
|
+
|
|
133
|
+
/** Identifiers that only exist in a browser; their presence means client work. */
|
|
134
|
+
const CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|customElements|matchMedia|addEventListener)\b/;
|
|
135
|
+
/** Same, for component source, minus `customElements` (the registration call
|
|
136
|
+
* `customElements.define(...)` legitimately uses it and must not force ship). */
|
|
137
|
+
const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|matchMedia|addEventListener)\b/;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Module-scope client work, detected by an ALLOWLIST of safe top-level forms
|
|
141
|
+
* rather than a denylist of browser globals. A module that runs ANY code when
|
|
142
|
+
* it loads (other than registering a component) does client work the render /
|
|
143
|
+
* lifecycle / event checks would miss, so it must ship. Unlike a global
|
|
144
|
+
* denylist this does not rot as browsers add APIs: a brand-new global trips it
|
|
145
|
+
* automatically, because it is the CALL (or `new`, or dynamic `import()`), not
|
|
146
|
+
* the global's name, that is recognised.
|
|
147
|
+
*
|
|
148
|
+
* Rule: at brace depth 0 (so code inside function / class / method bodies and
|
|
149
|
+
* template holes, which do not run at module load, is ignored), the only
|
|
150
|
+
* permitted forms are declarations (`import` / `export` / `function` / `class`
|
|
151
|
+
* / `const` / `let` / `var`) and the component registration call
|
|
152
|
+
* (`X.register(...)` / `customElements.define(...)`). Any other call, any
|
|
153
|
+
* `new`, any dynamic `import(...)`, or top-level `await` means client work.
|
|
154
|
+
*
|
|
155
|
+
* Scans the redacted copy (strings / templates / comments blanked, regex
|
|
156
|
+
* literals and nested `${...}` interpolation tracked by the lexer) so template
|
|
157
|
+
* prose and JSDoc / TS type annotations cannot trip it; quoted-string bodies,
|
|
158
|
+
* which redaction keeps verbatim for other rules, are blanked here too so a
|
|
159
|
+
* string like `"foo()"` or `"{"` is not read as a call and cannot unbalance
|
|
160
|
+
* the brace scan. The unbalanced-brace and unterminated-string fallbacks below
|
|
161
|
+
* are defense in depth: with the lexer tracking regex literals, neither should
|
|
162
|
+
* trigger on valid code, but if either does the module ships rather than risk
|
|
163
|
+
* hiding client work.
|
|
164
|
+
*
|
|
165
|
+
* Over-detection is safe (a top-level arrow whose body calls something, or a
|
|
166
|
+
* pure top-level helper call, only ships). The accepted residual misses, all
|
|
167
|
+
* contrived and structural (so they do not rot), are a call buried inside a
|
|
168
|
+
* top-level object / array initializer or a destructuring default, and a
|
|
169
|
+
* side-effecting tagged-template hole evaluated at module scope.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} src raw module source
|
|
172
|
+
*/
|
|
173
|
+
function hasModuleScopeSideEffect(src) {
|
|
174
|
+
const redacted = redactStringsAndTemplates(src);
|
|
175
|
+
// Keep only depth-0 text (outside every `{}`). Skip quoted-string bodies so
|
|
176
|
+
// braces/parens inside a string neither desync the depth nor read as a call.
|
|
177
|
+
let depth = 0;
|
|
178
|
+
let frame = '';
|
|
179
|
+
for (let i = 0; i < redacted.length; i++) {
|
|
180
|
+
const c = redacted[i];
|
|
181
|
+
if (c === "'" || c === '"') {
|
|
182
|
+
i++;
|
|
183
|
+
let closed = false;
|
|
184
|
+
while (i < redacted.length) {
|
|
185
|
+
const d = redacted[i];
|
|
186
|
+
if (d === '\\') { i += 2; continue; }
|
|
187
|
+
if (d === '\n') break; // a real quoted string never spans a newline
|
|
188
|
+
if (d === c) { closed = true; break; }
|
|
189
|
+
i++;
|
|
190
|
+
}
|
|
191
|
+
// Not closed on its line is an unterminated string OR a regex literal
|
|
192
|
+
// containing a quote that desynced the upstream redaction (regex bodies
|
|
193
|
+
// are not tracked, so a stray quote shifts quote pairing). Either way the
|
|
194
|
+
// lexical state is unreliable below here, so ship conservatively.
|
|
195
|
+
if (!closed) return true;
|
|
196
|
+
if (depth === 0) frame += "''";
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (c === '{') { depth++; continue; }
|
|
200
|
+
if (c === '}') { if (depth > 0) depth--; continue; }
|
|
201
|
+
if (depth === 0) frame += c;
|
|
202
|
+
}
|
|
203
|
+
// Unbalanced braces mean a construct we could not lexically resolve (a regex
|
|
204
|
+
// literal with a stray brace is the common case). Ship rather than risk a
|
|
205
|
+
// hidden top-level statement.
|
|
206
|
+
if (depth !== 0) return true;
|
|
207
|
+
// Optional-chaining call/index: `foo?.()`, `x?.[i]()` (the `?.` defeats the
|
|
208
|
+
// identifier-before-paren match below).
|
|
209
|
+
if (/\?\.\s*[([]/.test(frame)) return true;
|
|
210
|
+
// Top-level `new` (a `new X()` constructor also trips the call check, but a
|
|
211
|
+
// `new X` without parens would not) and top-level `await`.
|
|
212
|
+
if (/(?<![.\w])(?:new|await)\s/.test(frame)) return true;
|
|
213
|
+
// A call: `(` preceded (ignoring whitespace) by an identifier, `)`, or `]`.
|
|
214
|
+
const CALL_RE = /(?:([A-Za-z_$][\w$]*)|[)\]])\s*\(/g;
|
|
215
|
+
// Identifiers that precede a `(` WITHOUT it being a call (keywords + a
|
|
216
|
+
// `function` declaration's parameter list).
|
|
217
|
+
const NOT_A_CALL = new Set([
|
|
218
|
+
'if', 'for', 'while', 'switch', 'catch', 'with', 'return', 'typeof',
|
|
219
|
+
'instanceof', 'void', 'delete', 'in', 'of', 'yield', 'do', 'else',
|
|
220
|
+
'case', 'default', 'function', 'await', 'new', 'async',
|
|
221
|
+
]);
|
|
222
|
+
let m;
|
|
223
|
+
while ((m = CALL_RE.exec(frame)) !== null) {
|
|
224
|
+
const ident = m[1];
|
|
225
|
+
if (ident === 'import') return true; // dynamic import()
|
|
226
|
+
if (ident && NOT_A_CALL.has(ident)) continue;
|
|
227
|
+
// A `function`-declaration parameter list (`function name(` or
|
|
228
|
+
// `async function name(`) is not a call.
|
|
229
|
+
if (ident && /\bfunction\s*\*?\s*$/.test(frame.slice(0, m.index))) continue;
|
|
230
|
+
// The component registration call is the one permitted top-level call.
|
|
231
|
+
if (ident === 'register' || ident === 'define') continue;
|
|
232
|
+
return true; // any other top-level call
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Match a whole-line SIDE-EFFECT import: `import 'pkg';` (no binding clause).
|
|
238
|
+
* `\s*` before the quote (not `\s+`) so `import"pkg"` (no space) is caught;
|
|
239
|
+
* a binding clause still fails because a non-quote follows `import`. A
|
|
240
|
+
* trailing line comment is tolerated. */
|
|
241
|
+
const SIDE_EFFECT_BARE_IMPORT_RE = /^\s*import\s*(['"])([^'"]+)\1\s*;?\s*(?:\/\/[^\n]*)?$/gm;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* True if `src` imports the client router (the `/client-router` subpath, or
|
|
245
|
+
* a router/nav API from the core main entry). A page or layout that does so
|
|
246
|
+
* is enabling client-side navigation and must ship.
|
|
247
|
+
* @param {string} src
|
|
248
|
+
* @returns {boolean}
|
|
249
|
+
*/
|
|
250
|
+
function importsClientRouter(src) {
|
|
251
|
+
if (CLIENT_ROUTER_SUBPATH_RE.test(src)) return true;
|
|
252
|
+
for (const m of src.matchAll(CORE_IMPORT_RE)) {
|
|
253
|
+
const clause = m[1];
|
|
254
|
+
if (clause.startsWith('{')) {
|
|
255
|
+
const names = clause.slice(1, -1).split(',').map((s) => s.trim().split(/\s+as\s+/)[0].trim());
|
|
256
|
+
if (names.some((n) => CLIENT_ROUTER_IMPORTS.includes(n))) return true;
|
|
257
|
+
} else if (clause.startsWith('*')) {
|
|
258
|
+
// Namespace import: a router/nav member reached through `ns.member`,
|
|
259
|
+
// a destructure of `ns`, or computed access. Mirrors the reactive
|
|
260
|
+
// primitive detection so the two stay symmetric.
|
|
261
|
+
const ns = clause.replace(/^\*\s+as\s+/, '').trim();
|
|
262
|
+
if (!ns || !/^\w+$/.test(ns)) continue;
|
|
263
|
+
for (const name of CLIENT_ROUTER_IMPORTS) {
|
|
264
|
+
if (new RegExp(`\\b${ns}\\.${name}\\b`).test(src)) return true;
|
|
265
|
+
}
|
|
266
|
+
if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(src)) return true;
|
|
267
|
+
if (new RegExp(`\\b${ns}\\s*\\[`).test(src)) return true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* True if `src` SIDE-EFFECT imports a bare npm package other than the (inert)
|
|
275
|
+
* `@webjsdev/core` family (`import 'pkg'`, no binding). A side-effect import
|
|
276
|
+
* runs the package's top-level code when the module loads, which is real
|
|
277
|
+
* client work, so a module that has one must ship.
|
|
278
|
+
*
|
|
279
|
+
* A BINDING import (`import x from 'pkg'`) is deliberately NOT flagged: a page
|
|
280
|
+
* function never runs on the client and a display-only component's render
|
|
281
|
+
* never runs on the client when elided, so a package used only as a value in
|
|
282
|
+
* that code never executes client-side and rides away when the module is
|
|
283
|
+
* dropped. This is what lets an SSR-only dependency stay off the client
|
|
284
|
+
* without a `.server.{js,ts}` wrapper.
|
|
285
|
+
*
|
|
286
|
+
* Residual edge: a package that self-registers on import (e.g. calls
|
|
287
|
+
* `customElements.define` at module top level) imported via a binding clause
|
|
288
|
+
* is NOT caught here, so eliding the importer drops that registration and the
|
|
289
|
+
* element silently does not upgrade. This is the cross-module-registration
|
|
290
|
+
* caveat documented in agent-docs/components.md and server AGENTS invariant 7;
|
|
291
|
+
* the fix is `.server.{js,ts}` for genuinely server-only deps, or an
|
|
292
|
+
* interactivity signal on the consumer. (It is not caught by an SSR crash:
|
|
293
|
+
* the SSR `customElements` shim makes `define` a no-op server-side.)
|
|
294
|
+
* @param {string} src
|
|
295
|
+
* @returns {boolean}
|
|
296
|
+
*/
|
|
297
|
+
function importsSideEffectNonCorePackage(src) {
|
|
298
|
+
for (const m of src.matchAll(SIDE_EFFECT_BARE_IMPORT_RE)) {
|
|
299
|
+
const spec = m[2];
|
|
300
|
+
if (spec.startsWith('.') || spec.startsWith('/')) continue; // relative / absolute
|
|
301
|
+
if (spec === '@webjsdev/core' || spec.startsWith('@webjsdev/core/')) continue; // inert framework / router handled separately
|
|
302
|
+
if (spec.startsWith('node:')) continue; // server-only builtins
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Match a named-import clause from a `@webjsdev/core` specifier. */
|
|
309
|
+
const CORE_IMPORT_RE =
|
|
310
|
+
/import\s+(?:type\s+)?(\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"](@webjsdev\/core[^'"]*|[^'"]*\/__webjs\/core\/[^'"]*)['"]/g;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Decide whether a component module is interactive (must ship) or
|
|
314
|
+
* display-only (may be elided).
|
|
315
|
+
*
|
|
316
|
+
* @param {string} src raw module source
|
|
317
|
+
* @returns {{ interactive: boolean, reason: string | null }}
|
|
318
|
+
*/
|
|
319
|
+
export function analyzeComponentSource(src) {
|
|
320
|
+
// A reactive primitive imported from core is the strongest signal and
|
|
321
|
+
// is file-scoped, so check it first against the whole source.
|
|
322
|
+
const reactiveImport = importsReactivePrimitive(src);
|
|
323
|
+
if (reactiveImport) {
|
|
324
|
+
return { interactive: true, reason: `imports reactive primitive '${reactiveImport}'` };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Event bindings live inside `html` template bodies, which redaction
|
|
328
|
+
// blanks. Scan the RAW source for them (over-detection is safe).
|
|
329
|
+
if (EVENT_BINDING_RE.test(src)) {
|
|
330
|
+
return { interactive: true, reason: 'template has an @event binding' };
|
|
331
|
+
}
|
|
332
|
+
if (EVENT_PROP_RE.test(src)) {
|
|
333
|
+
return { interactive: true, reason: 'template sets a native event-handler property' };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// A rendered `<slot>` relies on webjs's client slot-projection runtime
|
|
337
|
+
// for the slot API (assignedNodes, slotchange) and dynamic re-
|
|
338
|
+
// projection. Shadow DOM slots are native via Declarative Shadow DOM,
|
|
339
|
+
// but proving a given slot is purely native is beyond static analysis,
|
|
340
|
+
// so any rendered slot ships. Tag names like `<slot-machine>` are
|
|
341
|
+
// excluded by requiring a slot-closing character.
|
|
342
|
+
if (SLOT_RE.test(src)) {
|
|
343
|
+
return { interactive: true, reason: 'renders a <slot> (needs the projection runtime)' };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Top-level client work the render/lifecycle checks would miss: a
|
|
347
|
+
// side-effect import of an npm package runs its code when the module
|
|
348
|
+
// loads, and a browser global (window/document/…, excluding the
|
|
349
|
+
// registration's customElements) means the module does client work even
|
|
350
|
+
// if its render is otherwise pure. Eliding such a component would drop
|
|
351
|
+
// that effect, so ship. (Mirrors the route-module analysis.)
|
|
352
|
+
if (importsSideEffectNonCorePackage(src)) {
|
|
353
|
+
return { interactive: true, reason: 'side-effect imports an npm package' };
|
|
354
|
+
}
|
|
355
|
+
if (COMPONENT_CLIENT_GLOBAL_RE.test(src)) {
|
|
356
|
+
return { interactive: true, reason: 'references a browser global at module scope' };
|
|
357
|
+
}
|
|
358
|
+
if (hasModuleScopeSideEffect(src)) {
|
|
359
|
+
return { interactive: true, reason: 'runs code at module scope (a top-level call, new, or dynamic import())' };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// The brace matcher counts depth reliably only on redacted source
|
|
363
|
+
// (template `${...}` holes would otherwise unbalance it). Code-shaped
|
|
364
|
+
// signals (lifecycle hooks, method calls, property descriptors) all
|
|
365
|
+
// survive redaction, so extract and inspect class bodies from there.
|
|
366
|
+
const bodies = extractWebComponentClassBodies(redactStringsAndTemplates(src));
|
|
367
|
+
// A registered component with no recognisable `extends WebComponent`
|
|
368
|
+
// body is a subclass of a custom base (or otherwise unparseable).
|
|
369
|
+
// Cannot prove it inert, so ship it.
|
|
370
|
+
if (bodies.length === 0) {
|
|
371
|
+
return { interactive: true, reason: 'no parseable WebComponent class body' };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (const body of bodies) {
|
|
375
|
+
for (const hook of CLIENT_LIFECYCLE_HOOKS) {
|
|
376
|
+
// A client lifecycle hook as a method (`hook(`) OR as an arrow class
|
|
377
|
+
// field (`hook = () =>`), which shadows the prototype method and still
|
|
378
|
+
// runs. Either way the component is not inert.
|
|
379
|
+
if (new RegExp(`\\b${hook}\\s*[=(]`).test(body)) {
|
|
380
|
+
return { interactive: true, reason: `overrides lifecycle hook '${hook}'` };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
for (const call of CLIENT_METHOD_CALLS) {
|
|
384
|
+
if (new RegExp(`\\b${call}\\s*\\(`).test(body)) {
|
|
385
|
+
return { interactive: true, reason: `calls '${call}'` };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (hasNonStateReactiveProperty(body)) {
|
|
389
|
+
return {
|
|
390
|
+
interactive: true,
|
|
391
|
+
reason: 'declares a reactive property that is not { state: true }',
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return { interactive: false, reason: null };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* @param {string} src
|
|
401
|
+
* @returns {string | null} the offending imported name, or null
|
|
402
|
+
*/
|
|
403
|
+
function importsReactivePrimitive(src) {
|
|
404
|
+
for (const m of src.matchAll(CORE_IMPORT_RE)) {
|
|
405
|
+
const clause = m[1];
|
|
406
|
+
if (clause.startsWith('{')) {
|
|
407
|
+
const names = clause
|
|
408
|
+
.slice(1, -1)
|
|
409
|
+
.split(',')
|
|
410
|
+
.map((s) => s.trim().split(/\s+as\s+/)[0].trim())
|
|
411
|
+
.filter(Boolean);
|
|
412
|
+
for (const name of names) {
|
|
413
|
+
if (REACTIVE_IMPORTS.has(name)) return name;
|
|
414
|
+
}
|
|
415
|
+
} else if (clause.startsWith('*')) {
|
|
416
|
+
// Namespace import (`import * as core from '@webjsdev/core'`). We
|
|
417
|
+
// cannot see which members are used from the clause, so look for a
|
|
418
|
+
// reactive member reached through the namespace identifier `ns`
|
|
419
|
+
// (which is a bare `\w+`, safe to interpolate into a RegExp).
|
|
420
|
+
const ns = clause.replace(/^\*\s+as\s+/, '').trim();
|
|
421
|
+
if (!ns || !/^\w+$/.test(ns)) continue;
|
|
422
|
+
for (const name of REACTIVE_IMPORTS) {
|
|
423
|
+
if (new RegExp(`\\b${ns}\\.${name}\\b`).test(src)) return name;
|
|
424
|
+
}
|
|
425
|
+
// Destructuring the namespace (`const { signal } = core`) or computed
|
|
426
|
+
// access (`core['signal']`) hides which members are pulled. Ship.
|
|
427
|
+
if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(src)) {
|
|
428
|
+
return `${ns} (destructured namespace)`;
|
|
429
|
+
}
|
|
430
|
+
if (new RegExp(`\\b${ns}\\s*\\[`).test(src)) {
|
|
431
|
+
return `${ns} (computed namespace access)`;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* True if the class body declares a `static properties` block with any
|
|
440
|
+
* entry that is NOT marked `{ state: true }`. Non-state reactive
|
|
441
|
+
* properties ride an attribute or a `.prop` binding, the channel through
|
|
442
|
+
* which a parent pushes updates, which is client-side reactivity.
|
|
443
|
+
*
|
|
444
|
+
* Conservative on parse failure: a `static get properties()` accessor or
|
|
445
|
+
* an unbalanced object literal returns true (ship).
|
|
446
|
+
*
|
|
447
|
+
* @param {string} classBody
|
|
448
|
+
* @returns {boolean}
|
|
449
|
+
*/
|
|
450
|
+
function hasNonStateReactiveProperty(classBody) {
|
|
451
|
+
// No reactive properties declared at all: nothing rides an attribute.
|
|
452
|
+
if (!/\bstatic\s+(?:get\s+)?properties\b/.test(classBody)) return false;
|
|
453
|
+
// Properties ARE declared. We can only clear a component as inert when
|
|
454
|
+
// the declaration is a brace literal whose every entry is { state: true }.
|
|
455
|
+
// A getter (`static get properties()`) or a non-literal assignment
|
|
456
|
+
// (`= buildProps()`, `= SHARED_PROPS`, `= Object.assign(...)`) cannot be
|
|
457
|
+
// parsed for state flags, so ship conservatively. `[^=]*` tolerates a TS
|
|
458
|
+
// type annotation between the name and `=` without crossing the `=`.
|
|
459
|
+
const m = /\bstatic\s+properties\b[^=]*=\s*\{/.exec(classBody);
|
|
460
|
+
if (!m) return true;
|
|
461
|
+
const objStart = m.index + m[0].length;
|
|
462
|
+
const objEnd = matchClosingBrace(classBody, objStart);
|
|
463
|
+
if (objEnd === -1) return true;
|
|
464
|
+
const obj = classBody.slice(objStart, objEnd);
|
|
465
|
+
// A spread (`...BASE_PROPS`) can inject reactive properties we cannot
|
|
466
|
+
// see; ship rather than guess they are all { state: true }.
|
|
467
|
+
if (/\.\.\./.test(obj)) return true;
|
|
468
|
+
for (const entry of topLevelPropertyValues(obj)) {
|
|
469
|
+
// Object-literal descriptor: inert only when it carries state: true.
|
|
470
|
+
if (entry.startsWith('{')) {
|
|
471
|
+
// Blank string / template bodies first. Redaction keeps quoted
|
|
472
|
+
// string contents verbatim (so register('tag') stays readable), so
|
|
473
|
+
// a descriptor like `{ attribute: 'data-state: true' }` would
|
|
474
|
+
// otherwise forge the state flag. The real `state: true` is code,
|
|
475
|
+
// not a string, so it survives this blanking.
|
|
476
|
+
const code = entry
|
|
477
|
+
.replace(/'[^'\n]*'/g, "''")
|
|
478
|
+
.replace(/"[^"\n]*"/g, '""')
|
|
479
|
+
.replace(/`[^`]*`/g, '``');
|
|
480
|
+
if (!/\bstate\s*:\s*true\b/.test(code)) return true;
|
|
481
|
+
} else {
|
|
482
|
+
// Shorthand like `count: Number` rides an attribute, not state.
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Yield the trimmed VALUE text of each top-level `key: value` entry in a
|
|
491
|
+
* properties object body, splitting on depth-0 commas and respecting
|
|
492
|
+
* nested braces, brackets, parens, strings, and templates.
|
|
493
|
+
*
|
|
494
|
+
* @param {string} obj the body between the outer braces
|
|
495
|
+
* @returns {string[]}
|
|
496
|
+
*/
|
|
497
|
+
function topLevelPropertyValues(obj) {
|
|
498
|
+
/** @type {string[]} */
|
|
499
|
+
const values = [];
|
|
500
|
+
let depth = 0;
|
|
501
|
+
let str = '';
|
|
502
|
+
let colonAt = -1;
|
|
503
|
+
const push = (end) => {
|
|
504
|
+
if (colonAt === -1) return;
|
|
505
|
+
values.push(obj.slice(colonAt + 1, end).trim());
|
|
506
|
+
};
|
|
507
|
+
for (let i = 0; i < obj.length; i++) {
|
|
508
|
+
const c = obj[i];
|
|
509
|
+
if (str) {
|
|
510
|
+
if (c === '\\') { i++; continue; }
|
|
511
|
+
if (c === str) str = '';
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (c === "'" || c === '"' || c === '`') { str = c; continue; }
|
|
515
|
+
if (c === '{' || c === '[' || c === '(') { depth++; continue; }
|
|
516
|
+
if (c === '}' || c === ']' || c === ')') { depth--; continue; }
|
|
517
|
+
if (depth === 0) {
|
|
518
|
+
if (c === ':' && colonAt === -1) colonAt = i;
|
|
519
|
+
else if (c === ',') {
|
|
520
|
+
push(i);
|
|
521
|
+
colonAt = -1;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
push(obj.length);
|
|
526
|
+
return values;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Custom-element tag names a module references in its templates. A tag
|
|
531
|
+
* must contain a hyphen (HTML custom-element spec), which excludes
|
|
532
|
+
* native elements. Over-detection is safe: it only forces more modules
|
|
533
|
+
* to ship.
|
|
534
|
+
*
|
|
535
|
+
* @param {string} src raw module source
|
|
536
|
+
* @returns {Set<string>}
|
|
537
|
+
*/
|
|
538
|
+
export function extractRenderedTags(src) {
|
|
539
|
+
/** @type {Set<string>} */
|
|
540
|
+
const tags = new Set();
|
|
541
|
+
const re = /<([a-z][a-z0-9]*-[a-z0-9-]*)\b/g;
|
|
542
|
+
let m;
|
|
543
|
+
while ((m = re.exec(src)) !== null) tags.add(m[1]);
|
|
544
|
+
return tags;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Compute the set of component FILES whose browser download can be
|
|
549
|
+
* elided. A file is elidable only when every component it defines is
|
|
550
|
+
* display-only AND it is not pulled into the client by an interactive
|
|
551
|
+
* component (rendered by, or imported by, a shipping module).
|
|
552
|
+
*
|
|
553
|
+
* Two propagation rules iterate to a fixpoint:
|
|
554
|
+
* - render rule: a shipping component that can emit `<child-tag>` on a
|
|
555
|
+
* client re-render forces the child to ship. The tags a component can
|
|
556
|
+
* emit are not only those in its own template but also those returned
|
|
557
|
+
* by the template helpers it imports (the documented `lib/utils/ui.ts`
|
|
558
|
+
* pattern), so the rule scans the component's transitive app-internal
|
|
559
|
+
* import closure, not just its own source.
|
|
560
|
+
* - import rule: a component that imports a shipping component module
|
|
561
|
+
* ships too (matches the issue's transitive criterion; conservative).
|
|
562
|
+
*
|
|
563
|
+
* @param {Array<{ tag: string, file: string }>} components
|
|
564
|
+
* @param {import('./module-graph.js').ModuleGraph} moduleGraph
|
|
565
|
+
* @param {(file: string) => Promise<string>} readFileFn
|
|
566
|
+
* @param {string} [appDir] app root; enables the helper-closure render rule
|
|
567
|
+
* @returns {Promise<Set<string>>} absolute paths of elidable component files
|
|
568
|
+
*/
|
|
569
|
+
export async function computeElidableComponents(components, moduleGraph, readFileFn, appDir) {
|
|
570
|
+
const { elidableComponents } = await analyzeElision(components, [], moduleGraph, readFileFn, appDir);
|
|
571
|
+
return elidableComponents;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Full elision analysis: which display-only COMPONENT modules can be elided,
|
|
576
|
+
* AND which page/layout ROUTE modules are inert (do no client work) and can
|
|
577
|
+
* therefore be dropped from the client boot script entirely. The second is
|
|
578
|
+
* the progressive-enhancement completion of the first: a route whose whole
|
|
579
|
+
* subtree is inert ships zero JavaScript.
|
|
580
|
+
*
|
|
581
|
+
* A route module is inert only when neither it nor its effective client
|
|
582
|
+
* closure (the import graph with elided components and `.server` stubs
|
|
583
|
+
* skipped, since those never run on the client) touches anything
|
|
584
|
+
* client-effecting: a reactive primitive, the client router, an `@event` /
|
|
585
|
+
* `.on*` binding, a non-core npm import (which may self-execute), a client
|
|
586
|
+
* global (`window`, `document`, …), or a shipping component. Anything
|
|
587
|
+
* ambiguous or unreadable keeps shipping.
|
|
588
|
+
*
|
|
589
|
+
* @param {Array<{ tag: string, file: string }>} components
|
|
590
|
+
* @param {string[]} routeModules absolute paths of page + layout files
|
|
591
|
+
* @param {import('./module-graph.js').ModuleGraph} moduleGraph
|
|
592
|
+
* @param {(file: string) => Promise<string>} readFileFn
|
|
593
|
+
* @param {string} [appDir]
|
|
594
|
+
* @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string> }>}
|
|
595
|
+
*/
|
|
596
|
+
export async function analyzeElision(components, routeModules, moduleGraph, readFileFn, appDir) {
|
|
597
|
+
/** @type {Set<string>} */
|
|
598
|
+
const componentFiles = new Set();
|
|
599
|
+
/** @type {Map<string, string>} */
|
|
600
|
+
const tagToFile = new Map();
|
|
601
|
+
for (const c of components) {
|
|
602
|
+
componentFiles.add(c.file);
|
|
603
|
+
tagToFile.set(c.tag, c.file);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** @type {Set<string>} */
|
|
607
|
+
const mustShip = new Set();
|
|
608
|
+
/** @type {Map<string, Set<string>>} */
|
|
609
|
+
const fileTags = new Map();
|
|
610
|
+
/** @type {Set<string>} modules importing a reactive primitive from core */
|
|
611
|
+
const reactiveFiles = new Set();
|
|
612
|
+
/** @type {Set<string>} modules enabling the client router */
|
|
613
|
+
const clientRouterFiles = new Set();
|
|
614
|
+
/** @type {Set<string>} modules with an @event/.on* binding, a non-core npm import, or a client global */
|
|
615
|
+
const clientGlobalOrBareFiles = new Set();
|
|
616
|
+
/** @type {Set<string>} */
|
|
617
|
+
const serverFiles = new Set();
|
|
618
|
+
|
|
619
|
+
/** @type {Set<string>} */
|
|
620
|
+
const allFiles = new Set(componentFiles);
|
|
621
|
+
for (const f of routeModules) allFiles.add(f);
|
|
622
|
+
for (const [k, vs] of moduleGraph) {
|
|
623
|
+
if (!appDir || k.startsWith(appDir)) allFiles.add(k);
|
|
624
|
+
for (const v of vs) if (!appDir || v.startsWith(appDir)) allFiles.add(v);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
for (const file of allFiles) {
|
|
628
|
+
if (SERVER_FILE_RE.test(file)) { serverFiles.add(file); continue; }
|
|
629
|
+
let src;
|
|
630
|
+
try { src = await readFileFn(file); }
|
|
631
|
+
catch {
|
|
632
|
+
// A component file we cannot read ships conservatively; a helper we
|
|
633
|
+
// cannot read simply contributes no tags.
|
|
634
|
+
if (componentFiles.has(file)) mustShip.add(file);
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (typeof src !== 'string') continue;
|
|
638
|
+
fileTags.set(file, extractRenderedTags(src));
|
|
639
|
+
if (importsReactivePrimitive(src)) reactiveFiles.add(file);
|
|
640
|
+
if (importsClientRouter(src)) clientRouterFiles.add(file);
|
|
641
|
+
if (EVENT_BINDING_RE.test(src) || EVENT_PROP_RE.test(src) ||
|
|
642
|
+
importsSideEffectNonCorePackage(src) || CLIENT_GLOBAL_RE.test(src) ||
|
|
643
|
+
hasModuleScopeSideEffect(src)) {
|
|
644
|
+
clientGlobalOrBareFiles.add(file);
|
|
645
|
+
}
|
|
646
|
+
if (componentFiles.has(file) && analyzeComponentSource(src).interactive) {
|
|
647
|
+
mustShip.add(file);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Ship any component whose transitive import closure does client work,
|
|
652
|
+
// through ANY import (not just npm): a relative helper that imports a
|
|
653
|
+
// reactive primitive (shared module-scope signal), enables the client
|
|
654
|
+
// router, references a browser global, or side-effect imports a package.
|
|
655
|
+
// Same closure rule the route analysis applies, so a display-only
|
|
656
|
+
// component that pulls in a client-effecting helper still ships.
|
|
657
|
+
const closureIsClientEffecting = (d) =>
|
|
658
|
+
reactiveFiles.has(d) || clientRouterFiles.has(d) || clientGlobalOrBareFiles.has(d);
|
|
659
|
+
if (appDir) {
|
|
660
|
+
for (const file of componentFiles) {
|
|
661
|
+
if (mustShip.has(file)) continue;
|
|
662
|
+
const deps = transitiveDeps(moduleGraph, [file], appDir, serverFiles);
|
|
663
|
+
if (deps.some(closureIsClientEffecting)) mustShip.add(file);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Tags each component can emit on a client re-render (own + helper closure).
|
|
668
|
+
/** @type {Map<string, Set<string>>} */
|
|
669
|
+
const emittableTags = new Map();
|
|
670
|
+
for (const file of componentFiles) {
|
|
671
|
+
const tags = new Set(fileTags.get(file));
|
|
672
|
+
const deps = appDir ? transitiveDeps(moduleGraph, [file], appDir) : [];
|
|
673
|
+
for (const dep of deps) {
|
|
674
|
+
const dt = fileTags.get(dep);
|
|
675
|
+
if (dt) for (const t of dt) tags.add(t);
|
|
676
|
+
}
|
|
677
|
+
emittableTags.set(file, tags);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Fixpoint: render rule + import rule.
|
|
681
|
+
let changed = true;
|
|
682
|
+
while (changed) {
|
|
683
|
+
changed = false;
|
|
684
|
+
for (const parent of mustShip) {
|
|
685
|
+
const tags = emittableTags.get(parent);
|
|
686
|
+
if (!tags) continue;
|
|
687
|
+
for (const tag of tags) {
|
|
688
|
+
const childFile = tagToFile.get(tag);
|
|
689
|
+
if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); changed = true; }
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
for (const file of componentFiles) {
|
|
693
|
+
if (mustShip.has(file)) continue;
|
|
694
|
+
const deps = moduleGraph.get(file);
|
|
695
|
+
if (!deps) continue;
|
|
696
|
+
for (const dep of deps) {
|
|
697
|
+
if (componentFiles.has(dep) && mustShip.has(dep)) { mustShip.add(file); changed = true; break; }
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** @type {Set<string>} */
|
|
703
|
+
const elidableComponents = new Set();
|
|
704
|
+
for (const file of componentFiles) {
|
|
705
|
+
if (!mustShip.has(file)) elidableComponents.add(file);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// A file does client work if it ships as a component, or itself reaches a
|
|
709
|
+
// reactive primitive / client router / event binding / non-core npm import
|
|
710
|
+
// / client global.
|
|
711
|
+
const isClientEffecting = (file) =>
|
|
712
|
+
(componentFiles.has(file) && mustShip.has(file)) ||
|
|
713
|
+
reactiveFiles.has(file) ||
|
|
714
|
+
clientRouterFiles.has(file) ||
|
|
715
|
+
clientGlobalOrBareFiles.has(file);
|
|
716
|
+
|
|
717
|
+
// Route modules: inert iff neither the module nor its effective client
|
|
718
|
+
// closure (skipping elided components and server stubs, which never load)
|
|
719
|
+
// is client-effecting.
|
|
720
|
+
/** @type {Set<string>} */
|
|
721
|
+
const skip = new Set([...elidableComponents, ...serverFiles]);
|
|
722
|
+
/** @type {Set<string>} */
|
|
723
|
+
const inertRouteModules = new Set();
|
|
724
|
+
for (const file of routeModules) {
|
|
725
|
+
if (!fileTags.has(file)) continue; // unreadable / not analysed: ship (omit from inert set)
|
|
726
|
+
if (isClientEffecting(file)) continue;
|
|
727
|
+
const closure = appDir ? transitiveDeps(moduleGraph, [file], appDir, skip) : [];
|
|
728
|
+
if (closure.some(isClientEffecting)) continue;
|
|
729
|
+
inertRouteModules.add(file);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return { elidableComponents, inertRouteModules };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Match a whole-line side-effect import: `import './x.js';` (no bindings). */
|
|
736
|
+
const SIDE_EFFECT_IMPORT_RE = /^([ \t]*)import\s+(['"])([^'"]+)\2\s*;?[ \t]*$/gm;
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Remove side-effect imports of elidable components from a browser
|
|
740
|
+
* module's served source, so the browser never downloads them. This is
|
|
741
|
+
* what actually elides the JS: a component is fetched because the page
|
|
742
|
+
* (or another component) statically imports it for registration, and
|
|
743
|
+
* the modulepreload hint only parallelises that fetch.
|
|
744
|
+
*
|
|
745
|
+
* ONLY side-effect imports (`import './x'`) are stripped. A binding
|
|
746
|
+
* import (`import { X } from './x'`) is left intact: its binding may be
|
|
747
|
+
* used as a value at runtime, so removing it would break the module.
|
|
748
|
+
* That is also why eliding stays correct, an elidable component is one
|
|
749
|
+
* used purely as an SSR'd tag, never as an imported value.
|
|
750
|
+
*
|
|
751
|
+
* Fast path: if the importer has no elidable dependency in the graph,
|
|
752
|
+
* the source is returned untouched without any regex work.
|
|
753
|
+
*
|
|
754
|
+
* Matching runs over a REDACTED copy (comment / string / template bodies
|
|
755
|
+
* blanked, positions preserved) so a line that merely reads like an
|
|
756
|
+
* import inside an `html\`...\`` template or a comment is never rewritten.
|
|
757
|
+
* Real top-level import statements survive redaction; the quoted
|
|
758
|
+
* specifier survives too (redaction keeps string bodies verbatim), so it
|
|
759
|
+
* is read from the redacted match and the original source is spliced at
|
|
760
|
+
* the matched range.
|
|
761
|
+
*
|
|
762
|
+
* @param {string} source module source (already type-stripped if TS)
|
|
763
|
+
* @param {string} importerAbs absolute path of the importing module
|
|
764
|
+
* @param {import('./module-graph.js').ModuleGraph | undefined} moduleGraph
|
|
765
|
+
* @param {Set<string> | undefined} elidableSet absolute paths of elidable files
|
|
766
|
+
* @param {(spec: string, fromFile: string, appDir: string) => (string|null)} resolveImport
|
|
767
|
+
* @param {string} appDir
|
|
768
|
+
* @returns {string}
|
|
769
|
+
*/
|
|
770
|
+
export function elideImportsFromSource(source, importerAbs, moduleGraph, elidableSet, resolveImport, appDir) {
|
|
771
|
+
if (!elidableSet || elidableSet.size === 0) return source;
|
|
772
|
+
const deps = moduleGraph && moduleGraph.get(importerAbs);
|
|
773
|
+
if (!deps) return source;
|
|
774
|
+
let hasElidableDep = false;
|
|
775
|
+
for (const d of deps) {
|
|
776
|
+
if (elidableSet.has(d)) { hasElidableDep = true; break; }
|
|
777
|
+
}
|
|
778
|
+
if (!hasElidableDep) return source;
|
|
779
|
+
|
|
780
|
+
const redacted = redactStringsAndTemplates(source);
|
|
781
|
+
let out = '';
|
|
782
|
+
let last = 0;
|
|
783
|
+
for (const m of redacted.matchAll(SIDE_EFFECT_IMPORT_RE)) {
|
|
784
|
+
const start = /** @type {number} */ (m.index);
|
|
785
|
+
const end = start + m[0].length;
|
|
786
|
+
const resolved = resolveImport(m[3], importerAbs, appDir);
|
|
787
|
+
out += source.slice(last, start);
|
|
788
|
+
if (resolved && elidableSet.has(resolved)) {
|
|
789
|
+
out += `${m[1]}/* webjs: elided display-only component */`;
|
|
790
|
+
} else {
|
|
791
|
+
out += source.slice(start, end);
|
|
792
|
+
}
|
|
793
|
+
last = end;
|
|
794
|
+
}
|
|
795
|
+
out += source.slice(last);
|
|
796
|
+
return out;
|
|
797
|
+
}
|