@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/component-elision.js
DELETED
|
@@ -1,1828 +0,0 @@
|
|
|
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
|
-
maskComments,
|
|
45
|
-
redactToPlaceholders,
|
|
46
|
-
} from './js-scan.js';
|
|
47
|
-
import { transitiveDeps, expandImportAlias } from './module-graph.js';
|
|
48
|
-
import { ensureStripper } from './ts-strip.js';
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Named imports from a `@webjsdev/core` specifier that imply the
|
|
52
|
-
* component does reactive or async client work. Importing any of these
|
|
53
|
-
* forces the module to ship: signals re-render on change, Task resolves
|
|
54
|
-
* on the client, the streaming directives settle on the client, and
|
|
55
|
-
* Context consumers subscribe after upgrade.
|
|
56
|
-
*
|
|
57
|
-
* @type {ReadonlySet<string>}
|
|
58
|
-
*/
|
|
59
|
-
export const REACTIVE_IMPORTS = new Set([
|
|
60
|
-
'signal',
|
|
61
|
-
'computed',
|
|
62
|
-
'effect',
|
|
63
|
-
'watch',
|
|
64
|
-
// `optimistic(signal, value, action)` mutates a signal (client work) and
|
|
65
|
-
// settles after a server round-trip, so a component importing it is not
|
|
66
|
-
// display-only and must ship.
|
|
67
|
-
'optimistic',
|
|
68
|
-
'Task',
|
|
69
|
-
'until',
|
|
70
|
-
'asyncAppend',
|
|
71
|
-
'asyncReplace',
|
|
72
|
-
'ContextProvider',
|
|
73
|
-
'ContextConsumer',
|
|
74
|
-
'connectWS',
|
|
75
|
-
// `renderStream(payload)` applies a server stream-action payload to the live
|
|
76
|
-
// DOM (append/replace/remove), so a component importing it does client work
|
|
77
|
-
// and must ship (#248).
|
|
78
|
-
'renderStream',
|
|
79
|
-
// Client-only directives. `ref` / `createRef` fire a callback against the
|
|
80
|
-
// live element (focus, measure, third-party mount); `live` syncs an input
|
|
81
|
-
// value against the DOM. All produce identical SSR HTML but do real work
|
|
82
|
-
// only after upgrade, so a component using them must ship. Render-time
|
|
83
|
-
// directives (repeat, unsafeHTML, keyed, guard, cache, map) are NOT here:
|
|
84
|
-
// they are SSR-renderable and common in display-only components.
|
|
85
|
-
'ref',
|
|
86
|
-
'createRef',
|
|
87
|
-
'live',
|
|
88
|
-
]);
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Overridable WebComponent lifecycle hooks. Overriding any of them is
|
|
92
|
-
* client-side behaviour the SSR pass never runs, so the module must
|
|
93
|
-
* ship. `render` is deliberately absent: every display-only component
|
|
94
|
-
* defines `render`, and the SSR walker calls it directly. `constructor`
|
|
95
|
-
* is absent for the same reason (it runs during SSR to seed first
|
|
96
|
-
* paint). Keep this list in lockstep with the lifecycle table in
|
|
97
|
-
* the skill's references/components.md.
|
|
98
|
-
*
|
|
99
|
-
* @type {readonly string[]}
|
|
100
|
-
*/
|
|
101
|
-
export const CLIENT_LIFECYCLE_HOOKS = [
|
|
102
|
-
'connectedCallback',
|
|
103
|
-
'disconnectedCallback',
|
|
104
|
-
'attributeChangedCallback',
|
|
105
|
-
// Standard custom-element callback WebJs does not itself define (so it
|
|
106
|
-
// is absent from the prototype guard's CLASSIFICATION), but an author
|
|
107
|
-
// can still override it to do client work. Kept for conservatism.
|
|
108
|
-
'adoptedCallback',
|
|
109
|
-
'shouldUpdate',
|
|
110
|
-
'willUpdate',
|
|
111
|
-
'update',
|
|
112
|
-
'updated',
|
|
113
|
-
'firstUpdated',
|
|
114
|
-
'getUpdateComplete',
|
|
115
|
-
'renderError',
|
|
116
|
-
// The async-render re-fetch loading UI (#469): defining it means the
|
|
117
|
-
// component suspends and re-renders on the client, so it must ship.
|
|
118
|
-
'renderFallback',
|
|
119
|
-
];
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Method calls that only make sense on the client. `addController` registers a
|
|
123
|
-
* ReactiveController (client lifecycle); `requestUpdate` schedules a re-render.
|
|
124
|
-
* Any of these implies the component is not inert. (The dynamic slot read
|
|
125
|
-
* surface lives in `SLOT_DYNAMIC_RE`, not here.)
|
|
126
|
-
*
|
|
127
|
-
* @type {readonly string[]}
|
|
128
|
-
*/
|
|
129
|
-
export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'requestUpdate'];
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Static class fields whose declaration (to a non-`false` value) marks a
|
|
133
|
-
* component interactive (must ship). Kept as ONE registry so a new
|
|
134
|
-
* interactivity-relevant static convention is added in a single place; the
|
|
135
|
-
* per-class analysis loops over it. Unlike prototype methods (guarded by
|
|
136
|
-
* `lifecycle-coverage.test.js` via prototype introspection), there is no
|
|
137
|
-
* enumerable runtime source of "all static conventions", so the contract is
|
|
138
|
-
* a documented one: a new interactivity static field MUST be added here AND
|
|
139
|
-
* to the lifecycle table in the skill's references/components.md. `sigil-coverage.test.js`
|
|
140
|
-
* asserts each entry is honoured as a ship signal.
|
|
141
|
-
*
|
|
142
|
-
* - `shadow`: Declarative Shadow DOM attaches ONLY during HTML parsing, so a
|
|
143
|
-
* component that arrives via a client DOM insertion (a soft-nav swap, a
|
|
144
|
-
* streamed <webjs-suspense> boundary's replaceWith) would never re-attach
|
|
145
|
-
* its shadow root if elided. Context-free, so any shadow component ships.
|
|
146
|
-
* - `interactive`: `static interactive = true` is the explicit author
|
|
147
|
-
* override that forces a component to ship even when the analyser would
|
|
148
|
-
* elide it. It is the escape hatch for interactivity the analyser cannot
|
|
149
|
-
* see statically (an OBSERVER that computes the tag it waits for, a
|
|
150
|
-
* `:defined` rule in an external stylesheet outside the module graph, a
|
|
151
|
-
* consumer reaching the element through a string selector). It
|
|
152
|
-
* cannot rescue a component whose OWN registration tag is computed: the
|
|
153
|
-
* scanner requires a literal tag (invariant 3), so such a component is
|
|
154
|
-
* never in `components` at all and nothing consults this analyser for it.
|
|
155
|
-
* Context-free: any value other than `false` ships.
|
|
156
|
-
*
|
|
157
|
-
* @type {readonly string[]}
|
|
158
|
-
*/
|
|
159
|
-
export const INTERACTIVITY_STATIC_FIELDS = ['shadow', 'interactive'];
|
|
160
|
-
|
|
161
|
-
/** Why each INTERACTIVITY_STATIC_FIELDS entry forces a ship (analyser reason). */
|
|
162
|
-
const STATIC_FIELD_REASONS = {
|
|
163
|
-
shadow: 'declares static shadow (DSD must re-attach on a client swap)',
|
|
164
|
-
interactive: 'declares static interactive = true (author-declared ship override)',
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* A bare `async render()` is NOT a standalone ship signal (#474). Its SSR
|
|
169
|
-
* pass bakes the resolved data into the first paint, so a light-DOM async
|
|
170
|
-
* component with no OTHER client signal renders identical HTML with or
|
|
171
|
-
* without its JS and is elidable like any display-only component, saving a
|
|
172
|
-
* module download plus a redundant on-hydration re-fetch. It ships only when
|
|
173
|
-
* it ALSO carries an independent signal: an `@event`, a declared reactive
|
|
174
|
-
* prop, a reactive import, a lifecycle hook (`renderFallback()` included, via
|
|
175
|
-
* CLIENT_LIFECYCLE_HOOKS), a `<slot>`, `static shadow = true`, `static
|
|
176
|
-
* interactive = true`, cross-module observation, or a transitively-reachable
|
|
177
|
-
* interactive dep / child (the fixpoint's import + render rules). The two
|
|
178
|
-
* per-class static-field carve-outs handled below are `static shadow`
|
|
179
|
-
* (Declarative Shadow DOM only attaches during HTML parsing, so a streamed or
|
|
180
|
-
* soft-navigated shadow component needs its module to re-run `attachShadow`)
|
|
181
|
-
* and the explicit `static interactive = true` override (forces a ship when
|
|
182
|
-
* the analyser cannot see a component's interactivity statically).
|
|
183
|
-
*/
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Template binding-prefix classification, the anchor for the elision drift
|
|
187
|
-
* guard. Every prefix core's renderers recognise (core's `BINDING_PREFIXES`)
|
|
188
|
-
* is one of two kinds:
|
|
189
|
-
* - SSR_DROPPED_PREFIXES: a CLIENT-BEHAVIOUR ship signal. The binding drops
|
|
190
|
-
* at SSR and is wired only after hydration (`@event`), so a component whose
|
|
191
|
-
* only interactivity is such a binding MUST ship.
|
|
192
|
-
* - ROUND_TRIP_PREFIXES: an SSR-SAFE binding that survives into the served
|
|
193
|
-
* HTML (`.prop` via `data-webjs-prop-*`, `?bool` as a boolean attribute),
|
|
194
|
-
* so it is NOT a ship signal on its own and stays elidable.
|
|
195
|
-
* `sigil-coverage.test.js` asserts these two lists PARTITION `BINDING_PREFIXES`
|
|
196
|
-
* exactly (union equal, disjoint), so a new sigil cannot be added to the
|
|
197
|
-
* renderers without being classified here. That closes the one interactivity
|
|
198
|
-
* surface the prototype-introspection guard (`lifecycle-coverage.test.js`)
|
|
199
|
-
* cannot see, because a sigil is template syntax, not a method or an export.
|
|
200
|
-
*/
|
|
201
|
-
export const SSR_DROPPED_PREFIXES = ['@'];
|
|
202
|
-
export const ROUND_TRIP_PREFIXES = ['.', '?'];
|
|
203
|
-
|
|
204
|
-
/** Escape a single char for safe inclusion in a regex character class. */
|
|
205
|
-
const escapeForCharClass = (c) => c.replace(/[\\\]^-]/g, '\\$&');
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Match a client-behaviour binding (`@event=${...}`), a ship signal. The
|
|
209
|
-
* prefix set is DERIVED from SSR_DROPPED_PREFIXES (not hardcoded), so a new
|
|
210
|
-
* client-behaviour sigil added to core is detected here automatically once it
|
|
211
|
-
* is classified above. (unquoted per invariant 4)
|
|
212
|
-
*/
|
|
213
|
-
const EVENT_BINDING_RE = new RegExp(
|
|
214
|
-
`[${SSR_DROPPED_PREFIXES.map(escapeForCharClass).join('')}][A-Za-z][\\w-]*\\s*=\\s*[\"']?\\$\\{`,
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
/** Match a `.onclick=${...}` (native event-handler property) binding. */
|
|
218
|
-
const EVENT_PROP_RE = /\.on[a-z]+\s*=\s*["']?\$\{/;
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Narrow dynamic-slot signals (#1015, retained under #1021's native-parity
|
|
222
|
-
* record model). The old blanket rule shipped ANY component that merely
|
|
223
|
-
* RENDERS a `<slot>`, but the SSR output already carries the placed children,
|
|
224
|
-
* so a display-only slotted wrapper is byte-identical with or without its JS
|
|
225
|
-
* and is elidable. What genuinely needs the client slot runtime is the
|
|
226
|
-
* DYNAMIC READ surface: a `slotchange` listener or an `assignedNodes` /
|
|
227
|
-
* `assignedElements` / `assignedSlot` read. Native WRITE liveness
|
|
228
|
-
* (appendChild, slot= flips) is consumer-driven and usually forces the ship
|
|
229
|
-
* through the consumer's own signals (a shipping component that renders the
|
|
230
|
-
* tag, or an observation form); the remaining carve-out, a shipped script
|
|
231
|
-
* reaching an elided host only via document.querySelector, is inert by
|
|
232
|
-
* design with the `static interactive = true` escape hatch (see the
|
|
233
|
-
* slot.js banner).
|
|
234
|
-
*/
|
|
235
|
-
const SLOT_DYNAMIC_RE = /\bslotchange\b|\bassignedNodes\s*\(|\bassignedElements\s*\(|\bassignedSlot\b/;
|
|
236
|
-
|
|
237
|
-
/** A `.server.{js,ts,mjs,mts}` file: a stub on the client, inert there. */
|
|
238
|
-
const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
|
|
239
|
-
|
|
240
|
-
/** Side-effect or named import of the client router subpath. */
|
|
241
|
-
const CLIENT_ROUTER_SUBPATH_RE = /['"]@webjsdev\/core\/client-router['"]/;
|
|
242
|
-
/**
|
|
243
|
-
* Client-only named APIs from the `@webjsdev/core` main entry. Exported so
|
|
244
|
-
* `test/elision/lifecycle-coverage.test.js` can guard it the way it guards
|
|
245
|
-
* REACTIVE_IMPORTS: a router function the barrel re-exports but this list omits
|
|
246
|
-
* is not recognized as client work, so a component using it is wrongly elided.
|
|
247
|
-
*/
|
|
248
|
-
export const CLIENT_ROUTER_IMPORTS = ['navigate', 'enableClientRouter', 'disableClientRouter', 'revalidate', 'refreshPage', 'loadFrame'];
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Identifiers that only exist in a browser; their presence means client work.
|
|
252
|
-
* Both this and COMPONENT_CLIENT_GLOBAL_RE are tested against the
|
|
253
|
-
* PLACEHOLDER redaction (`redactToPlaceholders`), never against raw or
|
|
254
|
-
* comment-masked source: a quoted string body or a template literal's TEXT is
|
|
255
|
-
* data, not a reference, so `'lucide-app-window'` must not read as `window`,
|
|
256
|
-
* while code inside a `${...}` hole stays real code and still matches. A global
|
|
257
|
-
* written in template text is SSR output (an inline `<script>` runs from the
|
|
258
|
-
* served HTML whether or not the module ships), the reasoning #623 applied to
|
|
259
|
-
* route modules, now extended to every module.
|
|
260
|
-
*/
|
|
261
|
-
const CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|customElements|matchMedia|addEventListener)\b/;
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Cross-module observation of a component's registration. A module that
|
|
265
|
-
* observes another component's tag forces that component to register on the
|
|
266
|
-
* client, so the observed component cannot be elided even when its own render
|
|
267
|
-
* is display-only (eliding it drops its `customElements.define`, after which
|
|
268
|
-
* the observation silently fails). These scan for the three statically visible
|
|
269
|
-
* forms; the captured group is the observed tag (or class) name.
|
|
270
|
-
* - `customElements.whenDefined('my-tag')` / `whenDefined("my-tag")`
|
|
271
|
-
* - a CSS `my-tag:defined { … }` selector
|
|
272
|
-
* - `x instanceof MyClass` (mapped back to a tag via the component's className)
|
|
273
|
-
*/
|
|
274
|
-
const WHEN_DEFINED_RE = /\bwhenDefined\s*\(\s*['"`]([a-z][a-z0-9]*-[a-z0-9-]*)['"`]/g;
|
|
275
|
-
const TAG_DEFINED_RE = /\b([a-z][a-z0-9]*-[a-z0-9-]*):defined\b/g;
|
|
276
|
-
const INSTANCEOF_RE = /\binstanceof\s+([A-Z][A-Za-z0-9_$]*)/g;
|
|
277
|
-
/** Same, for component source, minus `customElements` (the registration call
|
|
278
|
-
* `customElements.define(...)` legitimately uses it and must not force ship). */
|
|
279
|
-
const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|matchMedia|addEventListener)\b/;
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* The same globals reached through a string key on the global object
|
|
283
|
-
* (`globalThis['matchMedia']`, `self["document"]`). The global scan reads the
|
|
284
|
-
* placeholder redaction, which blanks string literals, and `globalThis` itself
|
|
285
|
-
* exists on the server, so this one form has to be read from the RAW source.
|
|
286
|
-
*/
|
|
287
|
-
const COMPONENT_CLIENT_GLOBAL_KEY_RE = /\b(?:globalThis|self)\s*(?:\?\.)?\[\s*(['"`])(?:window|document|navigator|localStorage|sessionStorage|matchMedia|addEventListener)\1\s*\]/;
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Constructors that produce inert DATA with no side effect, so a module-scope
|
|
291
|
-
* `export const X = new Set([...])` (a lookup table, a compiled RegExp, a
|
|
292
|
-
* parsed URL) is not client work and must not pin an importing page/layout
|
|
293
|
-
* (#623). Any constructor NOT in this set (`new WebSocket()`, `new Worker()`,
|
|
294
|
-
* `new EventSource()`, `new Audio()`) IS a side effect and still ships.
|
|
295
|
-
*/
|
|
296
|
-
const PURE_DATA_CONSTRUCTORS = new Set([
|
|
297
|
-
'Set', 'Map', 'WeakSet', 'WeakMap', 'Date', 'RegExp', 'Array', 'Object',
|
|
298
|
-
'Number', 'String', 'Boolean', 'BigInt', 'Symbol',
|
|
299
|
-
'Error', 'TypeError', 'RangeError', 'SyntaxError',
|
|
300
|
-
'URL', 'URLSearchParams', 'ArrayBuffer', 'DataView',
|
|
301
|
-
'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array',
|
|
302
|
-
'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array',
|
|
303
|
-
'BigInt64Array', 'BigUint64Array',
|
|
304
|
-
]);
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Marks where `hasModuleScopeSideEffect` dropped a top-level `{...}` body from
|
|
308
|
-
* its depth-0 frame, so an arrow followed by a block body is not mistaken for
|
|
309
|
-
* an expression body that runs on into the next statement. Removed again
|
|
310
|
-
* before the call scan.
|
|
311
|
-
*/
|
|
312
|
-
const BLOCK = '\x01';
|
|
313
|
-
|
|
314
|
-
/** `function`, `function*`, `async function name` up to its parameter list's `(`. */
|
|
315
|
-
const FUNCTION_PARAMS_RE = /(?<![\w$.])function\s*\*?\s*(?:[A-Za-z_$][\w$]*)?\s*\(/g;
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* The text before an arrow's `=>` when that arrow is a declarator initializer
|
|
319
|
-
* (`const usd = (m) =>`, `let f = async x =>`, `, g = (a) =>`). The parameter
|
|
320
|
-
* list must hold no parens, so a call in a parameter default
|
|
321
|
-
* (`(a = init()) =>`) keeps its arrow unrecognised and still ships.
|
|
322
|
-
*/
|
|
323
|
-
const ARROW_DECLARATOR_RE =
|
|
324
|
-
/(?:(?<![\w$.])(?:const|let|var)\s+|,\s*)[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?(?:\([^()]*\)|[A-Za-z_$][\w$]*)\s*$/;
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* An expression body that ends this way cannot stop at the newline (a binary
|
|
328
|
-
* operator, `?`, `:`, or a curried `=>`). Postfix `++` / `--` and a regex
|
|
329
|
-
* literal's closing `/` end a statement, so neither is listed.
|
|
330
|
-
*/
|
|
331
|
-
const BODY_CONTINUES_RE = /(?:=>|&&|\|\||\?\?|[?:=*%|&^<>]|(?<![+])\+|(?<![-])-)\s*$/;
|
|
332
|
-
|
|
333
|
-
/** A line starting this way continues the previous one: no statement starts with it. */
|
|
334
|
-
const LINE_CONTINUES_RE = /^\s*(?:\?|:|&&|\|\||\.(?![.\d])|\*|%|=(?!>)|\|)/;
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* Removes the two depth-0 shapes that look like calls and run nothing at module
|
|
338
|
-
* load: a `function` declaration's parameter list (a default such as
|
|
339
|
-
* `now = Date.now()` runs per call) and an arrow's expression body when the
|
|
340
|
-
* arrow initializes a declarator (`const usd = (m) => Math.round(m) / MICROS`).
|
|
341
|
-
*
|
|
342
|
-
* The arrow body ends at a `,` or `;` at its own paren/bracket depth 0, at a
|
|
343
|
-
* `)` / `]` closing something outside it, or at a newline once the body has
|
|
344
|
-
* content, unless `BODY_CONTINUES_RE` / `LINE_CONTINUES_RE` prove the
|
|
345
|
-
* expression carries on. Ending early is always safe, because whatever is left
|
|
346
|
-
* stays in the frame and is still scanned; ending late could swallow a real
|
|
347
|
-
* statement, so every rule errs towards ending.
|
|
348
|
-
*
|
|
349
|
-
* @param {string} frame depth-0 text, with `BLOCK` where a `{...}` was dropped
|
|
350
|
-
* @returns {string | null} the frame without those parts, or null when a
|
|
351
|
-
* parameter list does not close (the caller then ships)
|
|
352
|
-
*/
|
|
353
|
-
function blankDeclarationOnlyCode(frame) {
|
|
354
|
-
let out = '';
|
|
355
|
-
let last = 0;
|
|
356
|
-
for (const m of frame.matchAll(FUNCTION_PARAMS_RE)) {
|
|
357
|
-
if (m.index < last) continue;
|
|
358
|
-
const open = m.index + m[0].length - 1;
|
|
359
|
-
let depth = 0;
|
|
360
|
-
let close = -1;
|
|
361
|
-
for (let j = open; j < frame.length; j++) {
|
|
362
|
-
if (frame[j] === '(') depth++;
|
|
363
|
-
else if (frame[j] === ')' && --depth === 0) { close = j; break; }
|
|
364
|
-
}
|
|
365
|
-
if (close < 0) return null;
|
|
366
|
-
out += frame.slice(last, open + 1);
|
|
367
|
-
last = close;
|
|
368
|
-
}
|
|
369
|
-
frame = out + frame.slice(last);
|
|
370
|
-
|
|
371
|
-
out = '';
|
|
372
|
-
let depth = 0;
|
|
373
|
-
for (let i = 0; i < frame.length; i++) {
|
|
374
|
-
const c = frame[i];
|
|
375
|
-
if (c === '(' || c === '[') depth++;
|
|
376
|
-
else if ((c === ')' || c === ']') && depth > 0) depth--;
|
|
377
|
-
if (depth === 0 && c === '=' && frame[i + 1] === '>' && ARROW_DECLARATOR_RE.test(out)) {
|
|
378
|
-
out += '=>';
|
|
379
|
-
i = arrowBodyEnd(frame, i + 2) - 1;
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
out += c;
|
|
383
|
-
}
|
|
384
|
-
return out;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Index where an arrow's expression body starting at `start` ends (see
|
|
389
|
-
* `blankDeclarationOnlyCode`).
|
|
390
|
-
*
|
|
391
|
-
* @param {string} frame
|
|
392
|
-
* @param {number} start index just past the `=>`
|
|
393
|
-
* @returns {number}
|
|
394
|
-
*/
|
|
395
|
-
function arrowBodyEnd(frame, start) {
|
|
396
|
-
let depth = 0;
|
|
397
|
-
let i = start;
|
|
398
|
-
for (; i < frame.length; i++) {
|
|
399
|
-
const c = frame[i];
|
|
400
|
-
if (c === '(' || c === '[') { depth++; continue; }
|
|
401
|
-
if (c === ')' || c === ']') {
|
|
402
|
-
if (depth === 0) return i;
|
|
403
|
-
depth--;
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
if (depth > 0) continue;
|
|
407
|
-
if (c === ',' || c === ';') return i;
|
|
408
|
-
if (c === '\n') {
|
|
409
|
-
const body = frame.slice(start, i);
|
|
410
|
-
if (!body.trim() || BODY_CONTINUES_RE.test(body)) continue;
|
|
411
|
-
if (LINE_CONTINUES_RE.test(frame.slice(i + 1))) continue;
|
|
412
|
-
return i;
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
return i;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* Module-scope client work, detected by an ALLOWLIST of safe top-level forms
|
|
420
|
-
* rather than a denylist of browser globals. A module that runs ANY code when
|
|
421
|
-
* it loads (other than registering a component) does client work the render /
|
|
422
|
-
* lifecycle / event checks would miss, so it must ship. Unlike a global
|
|
423
|
-
* denylist this does not rot as browsers add APIs: a brand-new global trips it
|
|
424
|
-
* automatically, because it is the CALL (or `new`, or dynamic `import()`), not
|
|
425
|
-
* the global's name, that is recognised.
|
|
426
|
-
*
|
|
427
|
-
* Rule: at brace depth 0 (so code inside function / class / method bodies and
|
|
428
|
-
* template holes, which do not run at module load, is ignored), the only
|
|
429
|
-
* permitted forms are declarations (`import` / `export` / `function` / `class`
|
|
430
|
-
* / `const` / `let` / `var`) and the component registration call
|
|
431
|
-
* (`X.register(...)` / `customElements.define(...)`). Any other call, any
|
|
432
|
-
* `new`, any dynamic `import(...)`, or top-level `await` means client work.
|
|
433
|
-
*
|
|
434
|
-
* Scans the redacted copy (strings / templates / comments blanked, regex
|
|
435
|
-
* literals and nested `${...}` interpolation tracked by the lexer) so template
|
|
436
|
-
* prose and JSDoc cannot trip it; quoted-string bodies, which redaction keeps
|
|
437
|
-
* verbatim for other rules, are blanked here too so a string like `"foo()"` or
|
|
438
|
-
* `"{"` is not read as a call and cannot unbalance the brace scan. The
|
|
439
|
-
* unbalanced-brace and unterminated-string fallbacks below are defense in
|
|
440
|
-
* depth: with the lexer tracking regex literals, neither should trigger on
|
|
441
|
-
* valid code, but if either does the module ships rather than risk hiding
|
|
442
|
-
* client work.
|
|
443
|
-
*
|
|
444
|
-
* PASS TYPE-ERASED SOURCE for a TypeScript module. Redaction blanks comments
|
|
445
|
-
* and literals, NOT type syntax, and TS annotations are call-shaped often
|
|
446
|
-
* enough to matter: `readonly (readonly [number, number, number])[]` is an
|
|
447
|
-
* identifier immediately followed by `(`, so a module holding nothing but a
|
|
448
|
-
* typed data literal reads as running code at module scope (#1423). The
|
|
449
|
-
* analyser erases types before it calls this (`eraseTypesForScan`), so the
|
|
450
|
-
* request path is covered; a DIRECT caller handing it `.ts` source as authored
|
|
451
|
-
* is opting into that false positive.
|
|
452
|
-
*
|
|
453
|
-
* A `function` declaration's parameter list and the expression body of an
|
|
454
|
-
* arrow that initializes a `const` / `let` / `var` declarator are dropped from
|
|
455
|
-
* the frame first (`blankDeclarationOnlyCode`): `now = Date.now()` and
|
|
456
|
-
* `(m) => Math.round(m) / MICROS` declare code, they do not run it.
|
|
457
|
-
*
|
|
458
|
-
* Over-detection is safe (a pure top-level helper call, or an arrow outside a
|
|
459
|
-
* declarator whose body calls something, only ships). The accepted residual misses, all
|
|
460
|
-
* contrived and structural (so they do not rot), are a call buried inside a
|
|
461
|
-
* top-level object / array initializer or a destructuring default, and a
|
|
462
|
-
* side-effecting tagged-template hole evaluated at module scope.
|
|
463
|
-
*
|
|
464
|
-
* @param {string} src module source, type-erased if it came from a TypeScript file
|
|
465
|
-
* @param {string[]} [literals] the redaction's literal bodies, when already computed
|
|
466
|
-
* @returns {boolean}
|
|
467
|
-
*/
|
|
468
|
-
export function hasModuleScopeSideEffect(src, literals) {
|
|
469
|
-
let redacted = src;
|
|
470
|
-
if (!literals) {
|
|
471
|
-
const r = redactToPlaceholders(src);
|
|
472
|
-
redacted = r.redacted;
|
|
473
|
-
literals = r.literals;
|
|
474
|
-
}
|
|
475
|
-
// Keep only depth-0 text (outside every `{}`). Skip quoted-string bodies so
|
|
476
|
-
// braces/parens inside a string neither desync the depth nor read as a call.
|
|
477
|
-
let depth = 0;
|
|
478
|
-
let frame = '';
|
|
479
|
-
for (let i = 0; i < redacted.length; i++) {
|
|
480
|
-
const c = redacted[i];
|
|
481
|
-
if (c === "'" || c === '"') {
|
|
482
|
-
i++;
|
|
483
|
-
let closed = false;
|
|
484
|
-
while (i < redacted.length) {
|
|
485
|
-
const d = redacted[i];
|
|
486
|
-
if (d === '\\') { i += 2; continue; }
|
|
487
|
-
if (d === '\n') break; // a real quoted string never spans a newline
|
|
488
|
-
if (d === c) { closed = true; break; }
|
|
489
|
-
i++;
|
|
490
|
-
}
|
|
491
|
-
// Not closed on its line is an unterminated string OR a regex literal
|
|
492
|
-
// containing a quote that desynced the upstream redaction (regex bodies
|
|
493
|
-
// are not tracked, so a stray quote shifts quote pairing). Either way the
|
|
494
|
-
// lexical state is unreliable below here, so ship conservatively.
|
|
495
|
-
if (!closed) return true;
|
|
496
|
-
if (depth === 0) frame += "''";
|
|
497
|
-
continue;
|
|
498
|
-
}
|
|
499
|
-
if (c === '{') { if (depth === 0) frame += BLOCK; depth++; continue; }
|
|
500
|
-
if (c === '}') { if (depth > 0) depth--; continue; }
|
|
501
|
-
if (depth === 0) frame += c;
|
|
502
|
-
}
|
|
503
|
-
// Unbalanced braces mean a construct we could not lexically resolve (a regex
|
|
504
|
-
// literal with a stray brace is the common case). Ship rather than risk a
|
|
505
|
-
// hidden top-level statement.
|
|
506
|
-
if (depth !== 0) return true;
|
|
507
|
-
// Parameter lists and declarator arrow bodies are declarations, not code
|
|
508
|
-
// that runs at load.
|
|
509
|
-
const declared = blankDeclarationOnlyCode(frame);
|
|
510
|
-
if (declared === null) return true;
|
|
511
|
-
frame = declared.replaceAll(BLOCK, '');
|
|
512
|
-
// Optional-chaining call/index: `foo?.()`, `x?.[i]()` (the `?.` defeats the
|
|
513
|
-
// identifier-before-paren match below).
|
|
514
|
-
if (/\?\.\s*[([]/.test(frame)) return true;
|
|
515
|
-
// Top-level `new X`: a pure-data builtin constructor (Set / Map / Date /
|
|
516
|
-
// RegExp / typed array / URL / ...) is inert module data, not client work, so
|
|
517
|
-
// it stays elidable (#623); any other constructor is a side effect. Catches
|
|
518
|
-
// both `new X` and `new X(...)` (the constructor's own `(`, if present, is
|
|
519
|
-
// then skipped in the call scan below).
|
|
520
|
-
for (const nm of frame.matchAll(/(?<![.\w])new\s+([A-Za-z_$][\w$]*)/g)) {
|
|
521
|
-
if (!PURE_DATA_CONSTRUCTORS.has(nm[1])) return true;
|
|
522
|
-
}
|
|
523
|
-
// Top-level `await`.
|
|
524
|
-
if (/(?<![.\w])await\s/.test(frame)) return true;
|
|
525
|
-
// A call: `(` preceded (ignoring whitespace) by an identifier, `)`, or `]`.
|
|
526
|
-
const CALL_RE = /(?:([A-Za-z_$][\w$]*)|[)\]])\s*\(/g;
|
|
527
|
-
// Identifiers that precede a `(` WITHOUT it being a call (keywords + a
|
|
528
|
-
// `function` declaration's parameter list).
|
|
529
|
-
const NOT_A_CALL = new Set([
|
|
530
|
-
'if', 'for', 'while', 'switch', 'catch', 'with', 'return', 'typeof',
|
|
531
|
-
'instanceof', 'void', 'delete', 'in', 'of', 'yield', 'do', 'else',
|
|
532
|
-
'case', 'default', 'function', 'await', 'new', 'async',
|
|
533
|
-
]);
|
|
534
|
-
let m;
|
|
535
|
-
while ((m = CALL_RE.exec(frame)) !== null) {
|
|
536
|
-
const ident = m[1];
|
|
537
|
-
if (ident === 'import') return true; // dynamic import()
|
|
538
|
-
if (ident && NOT_A_CALL.has(ident)) continue;
|
|
539
|
-
// A `function`-declaration parameter list (`function name(` or
|
|
540
|
-
// `async function name(`) is not a call.
|
|
541
|
-
if (ident && /\bfunction\s*\*?\s*$/.test(frame.slice(0, m.index))) continue;
|
|
542
|
-
// The component registration call is the one permitted top-level call.
|
|
543
|
-
if (ident === 'register' || ident === 'define') continue;
|
|
544
|
-
// `extends WebComponent({ … })` is the declare-free reactive-prop DX (#597
|
|
545
|
-
// / #599): a class-declaration construct, not an independent side-effecting
|
|
546
|
-
// statement. Exempt it so a display-only factory-form component stays
|
|
547
|
-
// elidable; a genuinely interactive one still ships via the per-class and
|
|
548
|
-
// module-level signal checks below (events, slots, shadow, lifecycle, a
|
|
549
|
-
// declared factory prop, a reactive import). Scoped to the `extends`
|
|
550
|
-
// position, so a top-level `WebComponent(...)` call used anywhere else
|
|
551
|
-
// still counts as a side effect (#604).
|
|
552
|
-
if (ident === 'WebComponent' && /\bextends\s+$/.test(frame.slice(0, m.index))) continue;
|
|
553
|
-
// A `new X(...)` constructor call: the `new`-prefixed cases were already
|
|
554
|
-
// vetted above (a non-pure constructor returned true; a pure-data one is
|
|
555
|
-
// inert), so its `(` here is not an independent side-effecting call.
|
|
556
|
-
if (ident && /(?<![.\w])new\s+$/.test(frame.slice(0, m.index))) continue;
|
|
557
|
-
return true; // any other top-level call
|
|
558
|
-
}
|
|
559
|
-
return false;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/** Match a whole-line SIDE-EFFECT import: `import 'pkg';` (no binding clause).
|
|
563
|
-
* `\s*` before the quote (not `\s+`) so `import"pkg"` (no space) is caught;
|
|
564
|
-
* a binding clause still fails because a non-quote follows `import`. A
|
|
565
|
-
* trailing line comment is tolerated. */
|
|
566
|
-
const SIDE_EFFECT_BARE_IMPORT_RE = /^\s*import\s*(['"])([^'"]+)\1\s*;?\s*(?:\/\/[^\n]*)?$/gm;
|
|
567
|
-
|
|
568
|
-
/**
|
|
569
|
-
* True if `src` imports the client router (the `/client-router` subpath, or
|
|
570
|
-
* a router/nav API from the core main entry). A page or layout that does so
|
|
571
|
-
* is enabling client-side navigation and must ship.
|
|
572
|
-
* @param {string} src
|
|
573
|
-
* @param {string[]} [literals]
|
|
574
|
-
* @returns {boolean}
|
|
575
|
-
*/
|
|
576
|
-
function importsClientRouter(src, literals) {
|
|
577
|
-
let redacted = src;
|
|
578
|
-
if (!literals) {
|
|
579
|
-
const r = redactToPlaceholders(src);
|
|
580
|
-
redacted = r.redacted;
|
|
581
|
-
literals = r.literals;
|
|
582
|
-
}
|
|
583
|
-
if (literals.includes('@webjsdev/core/client-router')) return true;
|
|
584
|
-
for (const m of redacted.matchAll(CORE_IMPORT_RE)) {
|
|
585
|
-
const clause = m[1];
|
|
586
|
-
const spec = m[2];
|
|
587
|
-
const idx = m[3];
|
|
588
|
-
if (idx !== undefined) {
|
|
589
|
-
const resolved = literals[parseInt(idx, 10)];
|
|
590
|
-
if (!resolved || (!resolved.startsWith('@webjsdev/core') && !resolved.includes('/__webjs/core/'))) {
|
|
591
|
-
continue;
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
if (clause.startsWith('{')) {
|
|
595
|
-
const names = clause.slice(1, -1).split(',').map((s) => s.trim().split(/\s+as\s+/)[0].trim());
|
|
596
|
-
if (names.some((n) => CLIENT_ROUTER_IMPORTS.includes(n))) return true;
|
|
597
|
-
} else if (clause.startsWith('*')) {
|
|
598
|
-
const ns = clause.replace(/^\*\s+as\s+/, '').trim();
|
|
599
|
-
if (!ns || !/^\w+$/.test(ns)) continue;
|
|
600
|
-
for (const name of CLIENT_ROUTER_IMPORTS) {
|
|
601
|
-
if (new RegExp(`\\b${ns}\\.${name}\\b`).test(redacted)) return true;
|
|
602
|
-
}
|
|
603
|
-
if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(redacted)) return true;
|
|
604
|
-
if (new RegExp(`\\b${ns}\\s*\\[`).test(redacted)) return true;
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
return false;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/**
|
|
611
|
-
* True if `src` SIDE-EFFECT imports a bare npm package other than the (inert)
|
|
612
|
-
* `@webjsdev/core` family (`import 'pkg'`, no binding). A side-effect import
|
|
613
|
-
* runs the package's top-level code when the module loads, which is real
|
|
614
|
-
* client work, so a module that has one must ship.
|
|
615
|
-
*
|
|
616
|
-
* A BINDING import (`import x from 'pkg'`) is deliberately NOT flagged: a page
|
|
617
|
-
* function never runs on the client and a display-only component's render
|
|
618
|
-
* never runs on the client when elided, so a package used only as a value in
|
|
619
|
-
* that code never executes client-side and rides away when the module is
|
|
620
|
-
* dropped. This is what lets an SSR-only dependency stay off the client
|
|
621
|
-
* without a `.server.{js,ts}` wrapper.
|
|
622
|
-
*
|
|
623
|
-
* Residual edge: a package that self-registers on import (e.g. calls
|
|
624
|
-
* `customElements.define` at module top level) imported via a binding clause
|
|
625
|
-
* is NOT caught here, so eliding the importer drops that registration and the
|
|
626
|
-
* element silently does not upgrade. This is the cross-module-registration
|
|
627
|
-
* caveat documented in the skill's references/components.md and server AGENTS invariant 7;
|
|
628
|
-
* the fix is `.server.{js,ts}` for genuinely server-only deps, or an
|
|
629
|
-
* interactivity signal on the consumer. (It is not caught by an SSR crash:
|
|
630
|
-
* the SSR `customElements` shim makes `define` a no-op server-side.)
|
|
631
|
-
*
|
|
632
|
-
* A `#` PATH-ALIAS specifier (#555, e.g. `import '#components/x.ts'`, the
|
|
633
|
-
* idiomatic way a page/layout registers a component) is NOT a bare npm
|
|
634
|
-
* package: Node's `package.json` "imports" resolves it, in the scaffold's
|
|
635
|
-
* catch-all `"#*": "./*"` to a LOCAL file. Expanding it through the same
|
|
636
|
-
* `expandImportAlias` the module graph uses, a local-resolving alias is
|
|
637
|
-
* treated like a relative import (skipped); only an alias mapped to a real
|
|
638
|
-
* bare package (`"#crypto": "crypto-browserify"`) falls through to the
|
|
639
|
-
* package check. Without this, every `#`-imported component side effect
|
|
640
|
-
* wrongly pinned its importing page/layout to the browser (#623). An
|
|
641
|
-
* unexpandable `#` (no `appDir`, or no matching key) is assumed local,
|
|
642
|
-
* since `#` is the local-subpath sigil and an unmapped `#` is a Node error,
|
|
643
|
-
* not a package.
|
|
644
|
-
* @param {string} src
|
|
645
|
-
* @param {string} [appDir] app root, used to expand `#` path aliases
|
|
646
|
-
* @param {string[]} [literals]
|
|
647
|
-
* @returns {boolean}
|
|
648
|
-
*/
|
|
649
|
-
function importsSideEffectNonCorePackage(src, appDir, literals) {
|
|
650
|
-
let redacted = src;
|
|
651
|
-
if (!literals) {
|
|
652
|
-
const r = redactToPlaceholders(src);
|
|
653
|
-
redacted = r.redacted;
|
|
654
|
-
literals = r.literals;
|
|
655
|
-
}
|
|
656
|
-
for (const m of redacted.matchAll(SIDE_EFFECT_BARE_IMPORT_RE)) {
|
|
657
|
-
let spec = m[2];
|
|
658
|
-
const match = /^__STR_(\d+)__$/.exec(spec);
|
|
659
|
-
if (match) {
|
|
660
|
-
const idx = parseInt(match[1], 10);
|
|
661
|
-
spec = literals[idx];
|
|
662
|
-
}
|
|
663
|
-
if (!spec) continue;
|
|
664
|
-
if (spec.startsWith('.') || spec.startsWith('/')) continue; // relative / absolute
|
|
665
|
-
if (spec.startsWith('#')) {
|
|
666
|
-
const expanded = appDir ? expandImportAlias(spec, appDir) : null;
|
|
667
|
-
if (expanded == null) continue; // unmapped alias: local
|
|
668
|
-
if (expanded.startsWith('.') || expanded.startsWith('/')) continue; // resolves to a local file
|
|
669
|
-
spec = expanded; // mapped to a bare spec: check it below
|
|
670
|
-
}
|
|
671
|
-
if (spec === '@webjsdev/core' || spec.startsWith('@webjsdev/core/')) continue; // inert framework / router handled separately
|
|
672
|
-
if (spec.startsWith('node:')) continue; // server-only builtins
|
|
673
|
-
return true;
|
|
674
|
-
}
|
|
675
|
-
return false;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/** Match a named-import clause from a `@webjsdev/core` specifier (or its placeholder). */
|
|
679
|
-
const CORE_IMPORT_RE =
|
|
680
|
-
/import\s+(?:type\s+)?(\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"`](@webjsdev\/core[^'"`]*|[^'"`]*\/__webjs\/core\/[^'"`]*|__STR_(\d+)__)['"`]/g;
|
|
681
|
-
|
|
682
|
-
/**
|
|
683
|
-
* Decide whether a component module is interactive (must ship) or
|
|
684
|
-
* display-only (may be elided).
|
|
685
|
-
*
|
|
686
|
-
* @param {string} src raw module source
|
|
687
|
-
* @returns {{ interactive: boolean, reason: string | null }}
|
|
688
|
-
*/
|
|
689
|
-
export function analyzeComponentSource(src) {
|
|
690
|
-
// A reactive primitive imported from core is the strongest signal and
|
|
691
|
-
// is file-scoped, so check it first against the whole source.
|
|
692
|
-
const reactiveImport = importsReactivePrimitive(src);
|
|
693
|
-
if (reactiveImport) {
|
|
694
|
-
return { interactive: true, reason: `imports reactive primitive '${reactiveImport}'` };
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
// Event bindings live inside `html` template bodies, which redaction
|
|
698
|
-
// blanks. Scan the RAW source for them (over-detection is safe).
|
|
699
|
-
if (EVENT_BINDING_RE.test(src)) {
|
|
700
|
-
return { interactive: true, reason: 'template has an @event binding' };
|
|
701
|
-
}
|
|
702
|
-
if (EVENT_PROP_RE.test(src)) {
|
|
703
|
-
return { interactive: true, reason: 'template sets a native event-handler property' };
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// Dynamic slot usage (#1015). Merely RENDERING a `<slot>` no longer
|
|
707
|
-
// ships: the SSR output carries the placed children, and with no
|
|
708
|
-
// observers in the runtime a display-only slotted wrapper is
|
|
709
|
-
// byte-identical with or without its JS. Only the dynamic slot surface
|
|
710
|
-
// (slotchange, the assigned* reads) needs the client runtime.
|
|
711
|
-
if (SLOT_DYNAMIC_RE.test(src)) {
|
|
712
|
-
return { interactive: true, reason: 'reads the dynamic slot surface (slotchange / assignedNodes / assignedElements / assignedSlot)' };
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
// Top-level client work the render/lifecycle checks would miss: a
|
|
716
|
-
// side-effect import of an npm package runs its code when the module
|
|
717
|
-
// loads, and a browser global (window/document/…, excluding the
|
|
718
|
-
// registration's customElements) means the module does client work even
|
|
719
|
-
// if its render is otherwise pure. Eliding such a component would drop
|
|
720
|
-
// that effect, so ship. (Mirrors the route-module analysis.) The global
|
|
721
|
-
// scan reads the placeholder redaction, so a name inside a string or in
|
|
722
|
-
// template text is not a reference, while one in a `${...}` hole still is.
|
|
723
|
-
if (importsSideEffectNonCorePackage(src)) {
|
|
724
|
-
return { interactive: true, reason: 'side-effect imports an npm package' };
|
|
725
|
-
}
|
|
726
|
-
if (COMPONENT_CLIENT_GLOBAL_RE.test(redactToPlaceholders(src).redacted) || COMPONENT_CLIENT_GLOBAL_KEY_RE.test(src)) {
|
|
727
|
-
return { interactive: true, reason: 'references a browser global at module scope' };
|
|
728
|
-
}
|
|
729
|
-
if (hasModuleScopeSideEffect(src)) {
|
|
730
|
-
return { interactive: true, reason: 'runs code at module scope (a top-level call, new, or dynamic import())' };
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// The brace matcher counts depth reliably only on redacted source
|
|
734
|
-
// (template `${...}` holes would otherwise unbalance it). Code-shaped
|
|
735
|
-
// signals (lifecycle hooks, method calls, property descriptors) all
|
|
736
|
-
// survive redaction, so extract and inspect class bodies from there.
|
|
737
|
-
const bodies = extractWebComponentClassBodies(redactStringsAndTemplates(src));
|
|
738
|
-
// A registered component with no recognisable `extends WebComponent`
|
|
739
|
-
// body is a subclass of a custom base (or otherwise unparseable).
|
|
740
|
-
// Cannot prove it inert, so ship it.
|
|
741
|
-
if (bodies.length === 0) {
|
|
742
|
-
return { interactive: true, reason: 'no parseable WebComponent class body' };
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
for (const { body, factoryArg } of bodies) {
|
|
746
|
-
// Interactivity-signal static conventions (`static shadow` / `static
|
|
747
|
-
// interactive = true`), driven by the INTERACTIVITY_STATIC_FIELDS registry
|
|
748
|
-
// so a new convention is added in one place (see its doc for why each ships).
|
|
749
|
-
for (const field of INTERACTIVITY_STATIC_FIELDS) {
|
|
750
|
-
if (declaresStaticTrue(body, field)) {
|
|
751
|
-
return { interactive: true, reason: STATIC_FIELD_REASONS[field] };
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
for (const hook of CLIENT_LIFECYCLE_HOOKS) {
|
|
755
|
-
// A client lifecycle hook as a method (`hook(`) OR as an arrow class
|
|
756
|
-
// field (`hook = () =>`), which shadows the prototype method and still
|
|
757
|
-
// runs. Either way the component is not inert.
|
|
758
|
-
if (new RegExp(`\\b${hook}\\s*[=(]`).test(body)) {
|
|
759
|
-
return { interactive: true, reason: `overrides lifecycle hook '${hook}'` };
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
for (const call of CLIENT_METHOD_CALLS) {
|
|
763
|
-
if (new RegExp(`\\b${call}\\s*\\(`).test(body)) {
|
|
764
|
-
return { interactive: true, reason: `calls '${call}'` };
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
if (hasReactiveProperty(body) || hasFactoryProperty(factoryArg)) {
|
|
768
|
-
return {
|
|
769
|
-
interactive: true,
|
|
770
|
-
reason: 'declares a reactive property',
|
|
771
|
-
};
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
return { interactive: false, reason: null };
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
/**
|
|
779
|
-
* True if a class body declares a `static <name>` whose value is not the
|
|
780
|
-
* literal `false`. Backs the `static shadow` (DSD-on-client-swap) and
|
|
781
|
-
* `static interactive = true` (author-declared ship override) ship signals.
|
|
782
|
-
*
|
|
783
|
-
* Conservative on anything it cannot evaluate: a getter form
|
|
784
|
-
* (`static get <name>()`) ships, and a non-`false` value of any shape
|
|
785
|
-
* (`true`, a variable, an expression) ships. Only an absent declaration or a
|
|
786
|
-
* literal `= false` is cleared as inert, matching the light-DOM /
|
|
787
|
-
* not-declared defaults.
|
|
788
|
-
*
|
|
789
|
-
* @param {string} classBody redacted class body
|
|
790
|
-
* @param {string} name the static field name (`shadow` / `interactive`)
|
|
791
|
-
* @returns {boolean}
|
|
792
|
-
*/
|
|
793
|
-
function declaresStaticTrue(classBody, name) {
|
|
794
|
-
// A getter cannot be evaluated statically; ship.
|
|
795
|
-
if (new RegExp(`\\bstatic\\s+get\\s+${name}\\b`).test(classBody)) return true;
|
|
796
|
-
const m = new RegExp(`\\bstatic\\s+${name}\\b\\s*=\\s*([^;\\n]*)`).exec(classBody);
|
|
797
|
-
if (!m) return false; // not declared: the default (light DOM / not forced)
|
|
798
|
-
return !/^false\b/.test(m[1].trim()); // `= false` is inert; anything else ships
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
/**
|
|
802
|
-
* @param {string} src
|
|
803
|
-
* @param {string[]} [literals]
|
|
804
|
-
* @returns {string | null} the offending imported name, or null
|
|
805
|
-
*/
|
|
806
|
-
function importsReactivePrimitive(src, literals) {
|
|
807
|
-
let redacted = src;
|
|
808
|
-
if (!literals) {
|
|
809
|
-
const r = redactToPlaceholders(src);
|
|
810
|
-
redacted = r.redacted;
|
|
811
|
-
literals = r.literals;
|
|
812
|
-
}
|
|
813
|
-
for (const m of redacted.matchAll(CORE_IMPORT_RE)) {
|
|
814
|
-
const clause = m[1];
|
|
815
|
-
const spec = m[2];
|
|
816
|
-
const idx = m[3];
|
|
817
|
-
if (idx !== undefined) {
|
|
818
|
-
const resolved = literals[parseInt(idx, 10)];
|
|
819
|
-
if (!resolved || (!resolved.startsWith('@webjsdev/core') && !resolved.includes('/__webjs/core/'))) {
|
|
820
|
-
continue;
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
if (clause.startsWith('{')) {
|
|
824
|
-
const names = clause
|
|
825
|
-
.slice(1, -1)
|
|
826
|
-
.split(',')
|
|
827
|
-
.map((s) => s.trim().split(/\s+as\s+/)[0].trim())
|
|
828
|
-
.filter(Boolean);
|
|
829
|
-
for (const name of names) {
|
|
830
|
-
if (REACTIVE_IMPORTS.has(name)) return name;
|
|
831
|
-
}
|
|
832
|
-
} else if (clause.startsWith('*')) {
|
|
833
|
-
// Namespace import (`import * as core from '@webjsdev/core'`). We
|
|
834
|
-
// cannot see which members are used from the clause, so look for a
|
|
835
|
-
// reactive member reached through the namespace identifier `ns`
|
|
836
|
-
// (which is a bare `\w+`, safe to interpolate into a RegExp).
|
|
837
|
-
const ns = clause.replace(/^\*\s+as\s+/, '').trim();
|
|
838
|
-
if (!ns || !/^\w+$/.test(ns)) continue;
|
|
839
|
-
for (const name of REACTIVE_IMPORTS) {
|
|
840
|
-
if (new RegExp(`\\b${ns}\\.${name}\\b`).test(redacted)) return name;
|
|
841
|
-
}
|
|
842
|
-
// Destructuring the namespace (`const { signal } = core`) or computed
|
|
843
|
-
// access (`core['signal']`) hides which members are pulled. Ship.
|
|
844
|
-
if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(redacted)) {
|
|
845
|
-
return `${ns} (destructured namespace)`;
|
|
846
|
-
}
|
|
847
|
-
if (new RegExp(`\\b${ns}\\s*\\[`).test(redacted)) {
|
|
848
|
-
return `${ns} (computed namespace access)`;
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
return null;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
/**
|
|
856
|
-
* True if the class body declares a `static properties` block with any
|
|
857
|
-
* entry. A reactive property rides an attribute or a `.prop` binding, the
|
|
858
|
-
* channel through which a parent pushes updates, which is client-side
|
|
859
|
-
* reactivity. Internal state is a signal, not a declared property (the
|
|
860
|
-
* factory refuses a `state` option, #1677), so every entry counts.
|
|
861
|
-
*
|
|
862
|
-
* Conservative on parse failure: a `static get properties()` accessor, a
|
|
863
|
-
* non-literal assignment, a spread, or an unbalanced object literal returns
|
|
864
|
-
* true (ship).
|
|
865
|
-
*
|
|
866
|
-
* @param {string} classBody
|
|
867
|
-
* @returns {boolean}
|
|
868
|
-
*/
|
|
869
|
-
function hasReactiveProperty(classBody) {
|
|
870
|
-
// No reactive properties declared at all: nothing rides an attribute.
|
|
871
|
-
if (!/\bstatic\s+(?:get\s+)?properties\b/.test(classBody)) return false;
|
|
872
|
-
// A getter (`static get properties()`) or a non-literal assignment
|
|
873
|
-
// (`= buildProps()`, `= SHARED_PROPS`) cannot be read, so ship
|
|
874
|
-
// conservatively. `[^=]*` tolerates a TS type annotation between the name
|
|
875
|
-
// and `=` without crossing the `=`.
|
|
876
|
-
const m = /\bstatic\s+properties\b[^=]*=\s*\{/.exec(classBody);
|
|
877
|
-
if (!m) return true;
|
|
878
|
-
const objStart = m.index + m[0].length;
|
|
879
|
-
const objEnd = matchClosingBrace(classBody, objStart);
|
|
880
|
-
if (objEnd === -1) return true;
|
|
881
|
-
return declaresAnyEntry(classBody.slice(objStart, objEnd));
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
/**
|
|
885
|
-
* True if the class factory argument (`extends WebComponent({ … })`) declares
|
|
886
|
-
* any reactive property. Each one rides an HTML attribute or a `.prop`
|
|
887
|
-
* hydration binding, the channel that forces the component to ship.
|
|
888
|
-
* Conservative on a spread or an unbalanced brace (ship).
|
|
889
|
-
*
|
|
890
|
-
* @param {string} factoryArg
|
|
891
|
-
* @returns {boolean}
|
|
892
|
-
*/
|
|
893
|
-
function hasFactoryProperty(factoryArg) {
|
|
894
|
-
if (!factoryArg) return false;
|
|
895
|
-
const objStart = factoryArg.indexOf('{');
|
|
896
|
-
if (objStart === -1) return false;
|
|
897
|
-
// matchClosingBrace starts at depth 1, so pass the index AFTER the brace.
|
|
898
|
-
const objEnd = matchClosingBrace(factoryArg, objStart + 1);
|
|
899
|
-
if (objEnd === -1) return true;
|
|
900
|
-
return declaresAnyEntry(factoryArg.slice(objStart + 1, objEnd));
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
/**
|
|
904
|
-
* True if a properties object body holds at least one entry. A spread
|
|
905
|
-
* (`...BASE_PROPS`) can inject entries we cannot see, so it counts.
|
|
906
|
-
*
|
|
907
|
-
* @param {string} obj the body between the outer braces
|
|
908
|
-
* @returns {boolean}
|
|
909
|
-
*/
|
|
910
|
-
function declaresAnyEntry(obj) {
|
|
911
|
-
if (/\.\.\./.test(obj)) return true;
|
|
912
|
-
return topLevelPropertyValues(obj).length > 0;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
/**
|
|
916
|
-
* Yield the trimmed VALUE text of each top-level `key: value` entry in a
|
|
917
|
-
* properties object body, splitting on depth-0 commas and respecting
|
|
918
|
-
* nested braces, brackets, parens, strings, and templates.
|
|
919
|
-
*
|
|
920
|
-
* @param {string} obj the body between the outer braces
|
|
921
|
-
* @returns {string[]}
|
|
922
|
-
*/
|
|
923
|
-
function topLevelPropertyValues(obj) {
|
|
924
|
-
/** @type {string[]} */
|
|
925
|
-
const values = [];
|
|
926
|
-
let depth = 0;
|
|
927
|
-
let str = '';
|
|
928
|
-
let colonAt = -1;
|
|
929
|
-
const push = (end) => {
|
|
930
|
-
if (colonAt === -1) return;
|
|
931
|
-
values.push(obj.slice(colonAt + 1, end).trim());
|
|
932
|
-
};
|
|
933
|
-
for (let i = 0; i < obj.length; i++) {
|
|
934
|
-
const c = obj[i];
|
|
935
|
-
if (str) {
|
|
936
|
-
if (c === '\\') { i++; continue; }
|
|
937
|
-
if (c === str) str = '';
|
|
938
|
-
continue;
|
|
939
|
-
}
|
|
940
|
-
if (c === "'" || c === '"' || c === '`') { str = c; continue; }
|
|
941
|
-
if (c === '{' || c === '[' || c === '(') { depth++; continue; }
|
|
942
|
-
if (c === '}' || c === ']' || c === ')') { depth--; continue; }
|
|
943
|
-
if (depth === 0) {
|
|
944
|
-
if (c === ':' && colonAt === -1) colonAt = i;
|
|
945
|
-
else if (c === ',') {
|
|
946
|
-
push(i);
|
|
947
|
-
colonAt = -1;
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
push(obj.length);
|
|
952
|
-
return values;
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
/**
|
|
956
|
-
* Custom-element tag names a module references in its templates. A tag
|
|
957
|
-
* must contain a hyphen (HTML custom-element spec), which excludes
|
|
958
|
-
* native elements. Over-detection is safe: it only forces more modules
|
|
959
|
-
* to ship.
|
|
960
|
-
*
|
|
961
|
-
* @param {string} src raw module source
|
|
962
|
-
* @returns {Set<string>}
|
|
963
|
-
*/
|
|
964
|
-
export function extractRenderedTags(src) {
|
|
965
|
-
/** @type {Set<string>} */
|
|
966
|
-
const tags = new Set();
|
|
967
|
-
// Mask comments first so a `<some-tag>` written in a doc comment is not read
|
|
968
|
-
// as a rendered tag (#179). String and template content is kept, so real tags
|
|
969
|
-
// inside `html` templates are still found.
|
|
970
|
-
const masked = maskComments(src);
|
|
971
|
-
const re = /<([a-z][a-z0-9]*-[a-z0-9-]*)\b/g;
|
|
972
|
-
let m;
|
|
973
|
-
while ((m = re.exec(masked)) !== null) tags.add(m[1]);
|
|
974
|
-
return tags;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
/**
|
|
978
|
-
* The extensions that can carry TypeScript type syntax INTO this analysis.
|
|
979
|
-
*
|
|
980
|
-
* Derive it from what actually reaches `allFiles`, which is seeded from three
|
|
981
|
-
* places with three different filters: the component set from `scanComponents`
|
|
982
|
-
* (`/\.m?[jt]sx?$/`), the route modules from the router (`js|mjs|ts|mts`), and
|
|
983
|
-
* the module graph from its walker (`/\.(js|ts|mjs|mts)$/`). The COMPONENT set
|
|
984
|
-
* is the widest and it does not pass through the other two, so a `.tsx`
|
|
985
|
-
* component arrives here whatever the walker and the router admit. Hence
|
|
986
|
-
* `.tsx`, even though nothing in WebJs serves one: leaving it out is how the
|
|
987
|
-
* #1423 false positive comes back for exactly the file class no other filter
|
|
988
|
-
* would have let through. `.cts` is absent because no filter admits it.
|
|
989
|
-
*
|
|
990
|
-
* The rule to hold, if any of those three filters moves: this set is every
|
|
991
|
-
* TypeScript-carrying extension in their UNION. Narrowing it to the servable
|
|
992
|
-
* set instead is a change that looks tighter and silently un-erases files.
|
|
993
|
-
*/
|
|
994
|
-
const TS_SOURCE_RE = /\.m?tsx?$/;
|
|
995
|
-
|
|
996
|
-
/**
|
|
997
|
-
* Per-file memo of {@link eraseTypesForScan}, holding the source it was derived
|
|
998
|
-
* from so a hit is proven rather than assumed. One entry per file, so it is
|
|
999
|
-
* bounded by the app's file count and needs no eviction policy; a deleted file
|
|
1000
|
-
* leaves one dead entry until the process ends, which is cheaper than tracking
|
|
1001
|
-
* liveness for it.
|
|
1002
|
-
*
|
|
1003
|
-
* @type {Map<string, { src: string, out: string }>}
|
|
1004
|
-
*/
|
|
1005
|
-
const STRIP_CACHE = new Map();
|
|
1006
|
-
|
|
1007
|
-
/**
|
|
1008
|
-
* Return `src` with its TYPE syntax erased, so every scan below reads the code
|
|
1009
|
-
* that actually RUNS rather than the code as authored (#1423).
|
|
1010
|
-
*
|
|
1011
|
-
* The scans are lexical heuristics, and TypeScript annotations are syntax the
|
|
1012
|
-
* heuristics were never designed for: `readonly (readonly [number, number,
|
|
1013
|
-
* number])[]` is an identifier immediately followed by `(`, which the
|
|
1014
|
-
* top-level-call matcher in `hasModuleScopeSideEffect` reads as a call, so a
|
|
1015
|
-
* module holding nothing but a typed data literal was classified as running
|
|
1016
|
-
* code at module scope and pinned every route module that imported it. Type
|
|
1017
|
-
* syntax is erased before anything runs, so it can never be a runtime signal,
|
|
1018
|
-
* and the only sound reading of it is none at all.
|
|
1019
|
-
*
|
|
1020
|
-
* This uses the framework's own stripper rather than a lexical annotation
|
|
1021
|
-
* matcher: it is the same erasure the browser is served, so the analyser and
|
|
1022
|
-
* the runtime agree by construction, where a hand-rolled matcher would meet
|
|
1023
|
-
* generics, `as`, `satisfies`, and conditional types and get some of them
|
|
1024
|
-
* wrong. It is position-preserving whitespace replacement, so every offset,
|
|
1025
|
-
* line, and column the other scans depend on is unchanged.
|
|
1026
|
-
*
|
|
1027
|
-
* A non-TypeScript file is returned untouched (there is nothing to erase, and
|
|
1028
|
-
* running a TS parser over one buys only new ways to fail). A PER-FILE strip
|
|
1029
|
-
* failure also returns the source untouched, which is the pre-#1423 behaviour
|
|
1030
|
-
* and therefore the conservative direction: the scans then over-detect at
|
|
1031
|
-
* worst. Two things take that path, both silent because both are one file's
|
|
1032
|
-
* problem. A non-erasable module, which invariant 10 forbids and `webjs check`
|
|
1033
|
-
* catches at edit time, so it is a broken app rather than a supported one. And
|
|
1034
|
-
* a `.tsx` holding real JSX, which the stripper parses as non-JSX TypeScript
|
|
1035
|
-
* and rejects, so a JSX component is scanned as authored while a `.tsx` that is
|
|
1036
|
-
* only TypeScript is erased normally.
|
|
1037
|
-
*
|
|
1038
|
-
* A missing stripper BACKEND is the failure that does not stay silent, and it
|
|
1039
|
-
* is why the backend is resolved once by the caller rather than per file. It
|
|
1040
|
-
* means no TypeScript in the app can be erased (a runtime with neither the
|
|
1041
|
-
* built-in nor `amaro`), so every module is scanned as authored and the #1423
|
|
1042
|
-
* verdict comes back app-wide. Swallowing that alongside a syntax rejection
|
|
1043
|
-
* would make an app-wide regression indistinguishable from one broken file, so
|
|
1044
|
-
* `resolveScanStripper` warns once and this returns the source untouched.
|
|
1045
|
-
*
|
|
1046
|
-
* Results are memoized per file and validated against the exact source, since
|
|
1047
|
-
* this runs on every dev rebuild and stripping costs roughly 50x the read it
|
|
1048
|
-
* sits beside. Validating on content rather than mtime means a same-mtime
|
|
1049
|
-
* rewrite cannot serve a stale strip.
|
|
1050
|
-
*
|
|
1051
|
-
* @param {import('./ts-strip.js').Stripper | null} stripper resolved once per run, null when unavailable
|
|
1052
|
-
* @param {string} file absolute path, the cache key and the extension source
|
|
1053
|
-
* @param {string} src
|
|
1054
|
-
* @returns {string}
|
|
1055
|
-
*/
|
|
1056
|
-
function eraseTypesForScan(stripper, file, src) {
|
|
1057
|
-
if (!stripper || !TS_SOURCE_RE.test(file)) return src;
|
|
1058
|
-
const hit = STRIP_CACHE.get(file);
|
|
1059
|
-
if (hit && hit.src === src) return hit.out;
|
|
1060
|
-
let out;
|
|
1061
|
-
try {
|
|
1062
|
-
out = stripper.fn(src);
|
|
1063
|
-
} catch {
|
|
1064
|
-
out = src;
|
|
1065
|
-
}
|
|
1066
|
-
STRIP_CACHE.set(file, { src, out });
|
|
1067
|
-
return out;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
/** Whether {@link resolveScanStripper} has already reported a missing backend. */
|
|
1071
|
-
let warnedNoStripper = false;
|
|
1072
|
-
|
|
1073
|
-
/**
|
|
1074
|
-
* Resolve the TypeScript stripper backend once for a whole analysis run, or
|
|
1075
|
-
* `null` when this runtime has none.
|
|
1076
|
-
*
|
|
1077
|
-
* `analyzeElision` runs outside the dev request handler too (`webjs check`,
|
|
1078
|
-
* `webjs elision`), and `ensureStripper` is called on the dev path only, so
|
|
1079
|
-
* this is where the no-backend case surfaces for the other entry points. It
|
|
1080
|
-
* warns once per process rather than per file: the condition is a property of
|
|
1081
|
-
* the runtime, so repeating it once per module would bury it.
|
|
1082
|
-
*
|
|
1083
|
-
* @returns {Promise<import('./ts-strip.js').Stripper | null>}
|
|
1084
|
-
*/
|
|
1085
|
-
async function resolveScanStripper() {
|
|
1086
|
-
try {
|
|
1087
|
-
return await ensureStripper();
|
|
1088
|
-
} catch (e) {
|
|
1089
|
-
if (!warnedNoStripper) {
|
|
1090
|
-
warnedNoStripper = true;
|
|
1091
|
-
console.warn(
|
|
1092
|
-
'[webjs] elision analysis could not resolve a TypeScript stripper, so type syntax ' +
|
|
1093
|
-
'is not erased before the scans read a module. A type annotation can then read as ' +
|
|
1094
|
-
'module-scope work, which ships modules that would otherwise be elided. Underlying: ' +
|
|
1095
|
-
(e && e.message ? e.message : String(e)),
|
|
1096
|
-
);
|
|
1097
|
-
}
|
|
1098
|
-
return null;
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
/**
|
|
1103
|
-
* Compute the set of component FILES whose browser download can be
|
|
1104
|
-
* elided. A file is elidable only when every component it defines is
|
|
1105
|
-
* display-only AND it is not pulled into the client by an interactive
|
|
1106
|
-
* component (rendered by, or imported by, a shipping module).
|
|
1107
|
-
*
|
|
1108
|
-
* Two propagation rules iterate to a fixpoint:
|
|
1109
|
-
* - render rule: a shipping component that can emit `<child-tag>` on a
|
|
1110
|
-
* client re-render forces the child to ship. The tags a component can
|
|
1111
|
-
* emit are not only those in its own template but also those returned
|
|
1112
|
-
* by the template helpers it imports (the documented `lib/utils/ui.ts`
|
|
1113
|
-
* pattern), so the rule scans the component's transitive app-internal
|
|
1114
|
-
* import closure, not just its own source.
|
|
1115
|
-
* - import rule: a component that imports a shipping component module
|
|
1116
|
-
* ships too (matches the issue's transitive criterion; conservative).
|
|
1117
|
-
*
|
|
1118
|
-
* @param {Array<{ tag: string, file: string }>} components
|
|
1119
|
-
* @param {import('./module-graph.js').ModuleGraph} moduleGraph
|
|
1120
|
-
* @param {(file: string) => Promise<string>} readFileFn
|
|
1121
|
-
* @param {string} [appDir] app root; enables the helper-closure render rule
|
|
1122
|
-
* @returns {Promise<Set<string>>} absolute paths of elidable component files
|
|
1123
|
-
*/
|
|
1124
|
-
export async function computeElidableComponents(components, moduleGraph, readFileFn, appDir) {
|
|
1125
|
-
const { elidableComponents } = await analyzeElision(components, [], moduleGraph, readFileFn, appDir);
|
|
1126
|
-
return elidableComponents;
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
/**
|
|
1130
|
-
* Per-file scan results, keyed by path and validated on the exact source and
|
|
1131
|
-
* the two facts about the file the scan depends on (#1575). The scan reads one
|
|
1132
|
-
* file and nothing else, so an unchanged file never needs it again; on a large
|
|
1133
|
-
* app it was most of the elision pass (about 350 ms of Crisp's 450 ms rebuild),
|
|
1134
|
-
* repeated in full after every edit.
|
|
1135
|
-
* @type {Map<string, { src: string, isRoute: boolean, isComponent: boolean, result: ReturnType<typeof scanFileUncached> }>}
|
|
1136
|
-
*/
|
|
1137
|
-
const SCAN_RESULT_CACHE = new Map();
|
|
1138
|
-
|
|
1139
|
-
/**
|
|
1140
|
-
* The per-file half of `analyzeElision`: every signal it reads off ONE file.
|
|
1141
|
-
* @param {string} file
|
|
1142
|
-
* @param {string} src the raw source as read
|
|
1143
|
-
* @param {{ isRoute: boolean, isComponent: boolean, appDir: string, scanStripper: any }} ctx
|
|
1144
|
-
*/
|
|
1145
|
-
function scanFileForElision(file, src, ctx) {
|
|
1146
|
-
const hit = SCAN_RESULT_CACHE.get(file);
|
|
1147
|
-
if (hit && hit.src === src && hit.isRoute === ctx.isRoute && hit.isComponent === ctx.isComponent) return hit.result;
|
|
1148
|
-
const result = scanFileUncached(file, src, ctx);
|
|
1149
|
-
SCAN_RESULT_CACHE.set(file, { src, isRoute: ctx.isRoute, isComponent: ctx.isComponent, result });
|
|
1150
|
-
return result;
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
/**
|
|
1154
|
-
* @param {string} file
|
|
1155
|
-
* @param {string} src
|
|
1156
|
-
* @param {{ isRoute: boolean, isComponent: boolean, appDir: string, scanStripper: any }} ctx
|
|
1157
|
-
*/
|
|
1158
|
-
function scanFileUncached(file, src, { isRoute, isComponent, appDir, scanStripper }) {
|
|
1159
|
-
// Erase type syntax first, so every scan below reads the code that RUNS
|
|
1160
|
-
// (#1423). This is the single point where the analyser reads a file, so
|
|
1161
|
-
// doing it here covers the module-scope, template, import, and component
|
|
1162
|
-
// scans at once.
|
|
1163
|
-
src = eraseTypesForScan(scanStripper, file, src);
|
|
1164
|
-
// Mask comments once for every signal scan below (#179): a `<tag>`, an
|
|
1165
|
-
// `@event`, a browser global, an `import`, or a `whenDefined` written in a
|
|
1166
|
-
// comment must not register as a real signal. String and template content
|
|
1167
|
-
// is kept, so a real rendered tag, a real `@click=${}` in an html template,
|
|
1168
|
-
// and a real `whenDefined('tag')` (the tag rides a string) still match. The
|
|
1169
|
-
// browser-global scans are the exception: they read `redacted` below, since
|
|
1170
|
-
// a global's name inside a string or template text is data, not a reference.
|
|
1171
|
-
// (`importsSideEffectNonCorePackage` / `hasModuleScopeSideEffect` /
|
|
1172
|
-
// `analyzeComponentSource` also redact strings/templates internally; running
|
|
1173
|
-
// them on the comment-masked source just additionally drops comment prose.)
|
|
1174
|
-
const masked = maskComments(src);
|
|
1175
|
-
const tags = extractRenderedTags(masked);
|
|
1176
|
-
const { redacted, literals } = redactToPlaceholders(src);
|
|
1177
|
-
// A page/layout NEVER hydrates (#605), so its `html` TEMPLATE content is
|
|
1178
|
-
// SSR output, not module client work: an inline `<script>`'s browser
|
|
1179
|
-
// globals run from the rendered HTML, and a page-template `@event` is
|
|
1180
|
-
// dropped at SSR. For a route module, scan the `@event` signals on the
|
|
1181
|
-
// template-redacted source so they do not pin the module; a non-route
|
|
1182
|
-
// module keeps its template text, because a helper's `@click=${}` binds when
|
|
1183
|
-
// a shipping component renders it. The browser-global scan reads `redacted`
|
|
1184
|
-
// for EVERY module: a global's name in a string or template text is data
|
|
1185
|
-
// (an inline `<script>` runs from the HTML, a `'lucide-app-window'` class
|
|
1186
|
-
// is a class), while a genuine `document.x` in code, including code in a
|
|
1187
|
-
// `${...}` hole, still flags (#623).
|
|
1188
|
-
// The import-based checks stay on `redacted`: a real `import 'pkg'` side
|
|
1189
|
-
// effect DOES run when a page/layout module loads in the browser, and
|
|
1190
|
-
// `importsSideEffectNonCorePackage` itself skips local `#`-alias imports.
|
|
1191
|
-
const templateScan = isRoute ? redacted : masked;
|
|
1192
|
-
const clientGlobalOrBare = EVENT_BINDING_RE.test(templateScan) || EVENT_PROP_RE.test(templateScan) ||
|
|
1193
|
-
importsSideEffectNonCorePackage(redacted, appDir, literals) || CLIENT_GLOBAL_RE.test(redacted) ||
|
|
1194
|
-
hasModuleScopeSideEffect(redacted, literals);
|
|
1195
|
-
let interactive = false;
|
|
1196
|
-
/** @type {string | null} */
|
|
1197
|
-
let interactiveReason = null;
|
|
1198
|
-
if (isComponent) {
|
|
1199
|
-
const v = analyzeComponentSource(masked);
|
|
1200
|
-
if (v.interactive) { interactive = true; interactiveReason = v.reason ?? null; }
|
|
1201
|
-
}
|
|
1202
|
-
return {
|
|
1203
|
-
tags,
|
|
1204
|
-
reactive: importsReactivePrimitive(redacted, literals),
|
|
1205
|
-
clientRouter: importsClientRouter(redacted, literals),
|
|
1206
|
-
clientGlobalOrBare,
|
|
1207
|
-
interactive,
|
|
1208
|
-
interactiveReason,
|
|
1209
|
-
observedTags: [...[...masked.matchAll(WHEN_DEFINED_RE)].map((m) => m[1]), ...[...masked.matchAll(TAG_DEFINED_RE)].map((m) => m[1])],
|
|
1210
|
-
observedClasses: [...masked.matchAll(INSTANCEOF_RE)].map((m) => m[1]),
|
|
1211
|
-
};
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
/**
|
|
1215
|
-
* Full elision analysis: which display-only COMPONENT modules can be elided,
|
|
1216
|
-
* AND which page/layout ROUTE modules are inert (do no client work) and can
|
|
1217
|
-
* therefore be dropped from the client boot script entirely. The second is
|
|
1218
|
-
* the progressive-enhancement completion of the first: a route whose whole
|
|
1219
|
-
* subtree is inert ships zero JavaScript.
|
|
1220
|
-
*
|
|
1221
|
-
* A route module is inert only when neither it nor its effective client
|
|
1222
|
-
* closure (the import graph with elided components and `.server` stubs
|
|
1223
|
-
* skipped, since those never run on the client) touches anything
|
|
1224
|
-
* client-effecting: a reactive primitive, the client router, an `@event` /
|
|
1225
|
-
* `.on*` binding, a non-core npm import (which may self-execute), a client
|
|
1226
|
-
* global (`window`, `document`, …), or a shipping component. Anything
|
|
1227
|
-
* ambiguous or unreadable keeps shipping.
|
|
1228
|
-
*
|
|
1229
|
-
* @param {Array<{ tag: string, file: string, className?: string }>} components
|
|
1230
|
-
* @param {string[]} routeModules absolute paths of page + layout files
|
|
1231
|
-
* @param {import('./module-graph.js').ModuleGraph} moduleGraph
|
|
1232
|
-
* @param {(file: string) => Promise<string>} readFileFn
|
|
1233
|
-
* @param {string} [appDir]
|
|
1234
|
-
* @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string>, importOnlyRouteModules: Map<string, string[]>, shippedRouteModules: Map<string, { blocker: string|null, reason: string }>, componentVerdicts: Map<string, { tags: string[], className: string|null, shipped: boolean, evidence: string|null, by: string|null, reason: string|null }> }>}
|
|
1235
|
-
*/
|
|
1236
|
-
export async function analyzeElision(components, routeModules, moduleGraph, readFileFn, appDir) {
|
|
1237
|
-
/** @type {Set<string>} */
|
|
1238
|
-
const componentFiles = new Set();
|
|
1239
|
-
/** @type {Map<string, string>} */
|
|
1240
|
-
const tagToFile = new Map();
|
|
1241
|
-
/** @type {Map<string, string>} className -> file, for instanceof observation */
|
|
1242
|
-
const classToFile = new Map();
|
|
1243
|
-
for (const c of components) {
|
|
1244
|
-
componentFiles.add(c.file);
|
|
1245
|
-
tagToFile.set(c.tag, c.file);
|
|
1246
|
-
if (c.className) classToFile.set(c.className, c.file);
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
/** @type {Set<string>} */
|
|
1250
|
-
const mustShip = new Set();
|
|
1251
|
-
/**
|
|
1252
|
-
* WHY each shipping component ships (#1308), so the app-level report can name
|
|
1253
|
-
* the evidence instead of only the verdict. Written alongside every
|
|
1254
|
-
* `mustShip.add` of a COMPONENT file; FIRST write wins, matching the
|
|
1255
|
-
* first-match convention the analyser uses everywhere else.
|
|
1256
|
-
* @type {Map<string, { evidence: string, by: string|null, reason: string|null }>}
|
|
1257
|
-
*/
|
|
1258
|
-
const shipEvidence = new Map();
|
|
1259
|
-
/**
|
|
1260
|
-
* @param {string} file the component file that ships
|
|
1261
|
-
* @param {string} evidence one of own | observed | closure | render | import | unreadable
|
|
1262
|
-
* @param {string|null} by the module that forced it, where one exists
|
|
1263
|
-
* @param {string|null} [reason]
|
|
1264
|
-
*/
|
|
1265
|
-
const noteShip = (file, evidence, by, reason) => {
|
|
1266
|
-
if (!shipEvidence.has(file)) shipEvidence.set(file, { evidence, by, reason: reason ?? null });
|
|
1267
|
-
};
|
|
1268
|
-
/** @type {Map<string, Set<string>>} */
|
|
1269
|
-
const fileTags = new Map();
|
|
1270
|
-
/** @type {Set<string>} modules importing a reactive primitive from core */
|
|
1271
|
-
const reactiveFiles = new Set();
|
|
1272
|
-
/** @type {Set<string>} modules enabling the client router */
|
|
1273
|
-
const clientRouterFiles = new Set();
|
|
1274
|
-
/** @type {Set<string>} modules with an @event/.on* binding, a non-core npm import, or a client global */
|
|
1275
|
-
const clientGlobalOrBareFiles = new Set();
|
|
1276
|
-
/** @type {Set<string>} */
|
|
1277
|
-
const serverFiles = new Set();
|
|
1278
|
-
/** @type {Set<string>} component files forced to ship because some module
|
|
1279
|
-
* observes their registration (whenDefined / :defined / instanceof). */
|
|
1280
|
-
const observedComponentFiles = new Set();
|
|
1281
|
-
/** @type {Map<string, string>} observed component file -> the module observing it (#1308) */
|
|
1282
|
-
const observedBy = new Map();
|
|
1283
|
-
|
|
1284
|
-
/** @type {Set<string>} */
|
|
1285
|
-
const allFiles = new Set(componentFiles);
|
|
1286
|
-
/** @type {Set<string>} page/layout modules: never hydrate, so template content is SSR output. */
|
|
1287
|
-
const routeModuleSet = new Set(routeModules);
|
|
1288
|
-
for (const f of routeModules) allFiles.add(f);
|
|
1289
|
-
for (const [k, vs] of moduleGraph) {
|
|
1290
|
-
if (!appDir || k.startsWith(appDir)) allFiles.add(k);
|
|
1291
|
-
for (const v of vs) if (!appDir || v.startsWith(appDir)) allFiles.add(v);
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
// Resolve the stripper ONCE for the run: the backend is a property of the
|
|
1295
|
-
// runtime, not of a file, and the no-backend case must be reported rather
|
|
1296
|
-
// than swallowed per module (see `eraseTypesForScan`).
|
|
1297
|
-
const scanStripper = await resolveScanStripper();
|
|
1298
|
-
|
|
1299
|
-
for (const file of allFiles) {
|
|
1300
|
-
if (SERVER_FILE_RE.test(file)) { serverFiles.add(file); continue; }
|
|
1301
|
-
let src;
|
|
1302
|
-
try { src = await readFileFn(file); }
|
|
1303
|
-
catch {
|
|
1304
|
-
// A component file we cannot read ships conservatively; a helper we
|
|
1305
|
-
// cannot read simply contributes no tags.
|
|
1306
|
-
if (componentFiles.has(file)) { mustShip.add(file); noteShip(file, 'unreadable', null, null); }
|
|
1307
|
-
continue;
|
|
1308
|
-
}
|
|
1309
|
-
if (typeof src !== 'string') continue;
|
|
1310
|
-
// Erase type syntax first, so every scan below reads the code that RUNS
|
|
1311
|
-
// (#1423). This is the single point where the analyser reads a file, so
|
|
1312
|
-
// doing it here covers the module-scope, template, import, and component
|
|
1313
|
-
// scans at once.
|
|
1314
|
-
const scan = scanFileForElision(file, src, {
|
|
1315
|
-
isRoute: routeModuleSet.has(file),
|
|
1316
|
-
isComponent: componentFiles.has(file),
|
|
1317
|
-
appDir,
|
|
1318
|
-
scanStripper,
|
|
1319
|
-
});
|
|
1320
|
-
fileTags.set(file, scan.tags);
|
|
1321
|
-
if (scan.reactive) reactiveFiles.add(file);
|
|
1322
|
-
if (scan.clientRouter) clientRouterFiles.add(file);
|
|
1323
|
-
if (scan.clientGlobalOrBare) clientGlobalOrBareFiles.add(file);
|
|
1324
|
-
if (scan.interactive) { mustShip.add(file); noteShip(file, 'own', null, scan.interactiveReason); }
|
|
1325
|
-
// Cross-module registration observation (#169): if THIS module observes
|
|
1326
|
-
// another component's tag, that component must register client-side, so
|
|
1327
|
-
// it cannot be elided. Map each observed tag/class back to its component
|
|
1328
|
-
// file. Resolution against tagToFile / classToFile happens here, outside
|
|
1329
|
-
// the per-file cache, because the component set is not a property of this
|
|
1330
|
-
// file. Verdict-safe: only ever forces MORE components to ship.
|
|
1331
|
-
for (const tag of scan.observedTags) {
|
|
1332
|
-
const f = tagToFile.get(tag); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
|
|
1333
|
-
}
|
|
1334
|
-
for (const cls of scan.observedClasses) {
|
|
1335
|
-
const f = classToFile.get(cls); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
// Force every observed component to ship before the fixpoint runs, so the
|
|
1340
|
-
// render/import rules propagate from it too. Dynamic tag strings and external
|
|
1341
|
-
// (non graph-reachable) stylesheets remain an author-facing caveat, since
|
|
1342
|
-
// static analysis cannot see them.
|
|
1343
|
-
for (const f of observedComponentFiles) { mustShip.add(f); noteShip(f, 'observed', observedBy.get(f) ?? null, null); }
|
|
1344
|
-
|
|
1345
|
-
// Reverse import edges (who imports each file), built once from the graph.
|
|
1346
|
-
// Drives both the closure-client-work reachability below and the fixpoint's
|
|
1347
|
-
// import rule, each in O(N+E) rather than a per-component closure walk.
|
|
1348
|
-
/** @type {Map<string, Set<string>>} */
|
|
1349
|
-
const importersOf = new Map();
|
|
1350
|
-
for (const [file, deps] of moduleGraph) {
|
|
1351
|
-
for (const dep of deps) {
|
|
1352
|
-
let set = importersOf.get(dep);
|
|
1353
|
-
if (!set) { set = new Set(); importersOf.set(dep, set); }
|
|
1354
|
-
set.add(file);
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
// Files that reach client work through their imports: a reactive primitive,
|
|
1359
|
-
// the client router, a browser global, an `@event` binding, or a side-effect
|
|
1360
|
-
// npm import. Computed by propagating BACKWARD from the client-effecting
|
|
1361
|
-
// files through the reverse edges, stopping at `.server` files (the forward
|
|
1362
|
-
// closure skips them, since the browser only ever sees their stub). This is
|
|
1363
|
-
// O(N+E) instead of a full transitive-closure walk per component, which was
|
|
1364
|
-
// the second O(N^2) on a deep component chain.
|
|
1365
|
-
/** @type {Set<string>} */
|
|
1366
|
-
const reachesClientWork = new Set();
|
|
1367
|
-
{
|
|
1368
|
-
const work = [];
|
|
1369
|
-
for (const f of [...reactiveFiles, ...clientRouterFiles, ...clientGlobalOrBareFiles]) {
|
|
1370
|
-
if (!reachesClientWork.has(f)) { reachesClientWork.add(f); work.push(f); }
|
|
1371
|
-
}
|
|
1372
|
-
while (work.length) {
|
|
1373
|
-
const node = /** @type {string} */ (work.pop());
|
|
1374
|
-
const importers = importersOf.get(node);
|
|
1375
|
-
if (!importers) continue;
|
|
1376
|
-
for (const imp of importers) {
|
|
1377
|
-
if (serverFiles.has(imp)) continue; // a server file blocks the forward closure
|
|
1378
|
-
if (!reachesClientWork.has(imp)) { reachesClientWork.add(imp); work.push(imp); }
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
// Ship any component whose transitive import closure does client work (the
|
|
1384
|
-
// helper-imports-a-signal case): it ships if any of its direct, non-server
|
|
1385
|
-
// deps reaches client work. Equivalent to the old per-component closure walk
|
|
1386
|
-
// (a component that is itself client-effecting is already shipping via
|
|
1387
|
-
// analyzeComponentSource), but linear.
|
|
1388
|
-
if (appDir) {
|
|
1389
|
-
for (const file of componentFiles) {
|
|
1390
|
-
if (mustShip.has(file)) continue;
|
|
1391
|
-
const deps = moduleGraph.get(file);
|
|
1392
|
-
if (!deps) continue;
|
|
1393
|
-
for (const dep of deps) {
|
|
1394
|
-
if (serverFiles.has(dep)) continue;
|
|
1395
|
-
if (reachesClientWork.has(dep)) { mustShip.add(file); noteShip(file, 'closure', dep, null); break; }
|
|
1396
|
-
}
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
// Tags each component can emit on a client re-render: its OWN rendered tags
|
|
1401
|
-
// plus tags returned by the template HELPERS it imports (the lib/utils/ui.ts
|
|
1402
|
-
// pattern). The closure deliberately SKIPS component and server files:
|
|
1403
|
-
// importing another component does not mean rendering its tag (a rendered tag
|
|
1404
|
-
// is already in the importer's own source via extractRenderedTags), and a
|
|
1405
|
-
// server file renders nothing client-side. Following component edges here
|
|
1406
|
-
// makes the closure O(N^2) in time AND memory on a deep component chain
|
|
1407
|
-
// (every component would accumulate every downstream tag); helper-only
|
|
1408
|
-
// closures keep it linear. This is verdict-SAFE: it never elides a component
|
|
1409
|
-
// that the render rule requires to ship (so it can never break a page), and
|
|
1410
|
-
// it actually elides strictly MORE in some shapes, because following
|
|
1411
|
-
// component edges made the old version over-ship components whose tags
|
|
1412
|
-
// nothing actually renders client-side.
|
|
1413
|
-
const tagClosureSkip = new Set([...componentFiles, ...serverFiles]);
|
|
1414
|
-
/** @type {Map<string, Set<string>>} */
|
|
1415
|
-
const emittableTags = new Map();
|
|
1416
|
-
for (const file of componentFiles) {
|
|
1417
|
-
const tags = new Set(fileTags.get(file));
|
|
1418
|
-
const deps = appDir ? transitiveDeps(moduleGraph, [file], appDir, tagClosureSkip) : [];
|
|
1419
|
-
for (const dep of deps) {
|
|
1420
|
-
const dt = fileTags.get(dep);
|
|
1421
|
-
if (dt) for (const t of dt) tags.add(t);
|
|
1422
|
-
}
|
|
1423
|
-
emittableTags.set(file, tags);
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
// Fixpoint by worklist (render rule + import rule), O(N+E). Seed with the
|
|
1427
|
-
// components already known to ship; each shipping node forces the components
|
|
1428
|
-
// whose tags it can emit (render rule) and the COMPONENT files that import it
|
|
1429
|
-
// (import rule). Replaces the old iterate-until-stable double loop, which was
|
|
1430
|
-
// O(N^2) per pass and O(N^3) / out-of-memory on a deep render chain.
|
|
1431
|
-
const queue = [...mustShip];
|
|
1432
|
-
while (queue.length) {
|
|
1433
|
-
const node = /** @type {string} */ (queue.pop());
|
|
1434
|
-
const tags = emittableTags.get(node);
|
|
1435
|
-
if (tags) {
|
|
1436
|
-
for (const tag of tags) {
|
|
1437
|
-
const childFile = tagToFile.get(tag);
|
|
1438
|
-
if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); noteShip(childFile, 'render', node, null); queue.push(childFile); }
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
const importers = importersOf.get(node);
|
|
1442
|
-
if (importers) {
|
|
1443
|
-
for (const imp of importers) {
|
|
1444
|
-
if (!componentFiles.has(imp)) continue; // import rule is component -> component
|
|
1445
|
-
if (!mustShip.has(imp)) { mustShip.add(imp); noteShip(imp, 'import', node, null); queue.push(imp); }
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
/** @type {Set<string>} */
|
|
1451
|
-
const elidableComponents = new Set();
|
|
1452
|
-
for (const file of componentFiles) {
|
|
1453
|
-
if (!mustShip.has(file)) elidableComponents.add(file);
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
// A file does client work if it ships as a component, or itself reaches a
|
|
1457
|
-
// reactive primitive / client router / event binding / non-core npm import
|
|
1458
|
-
// / client global.
|
|
1459
|
-
const isClientEffecting = (file) =>
|
|
1460
|
-
(componentFiles.has(file) && mustShip.has(file)) ||
|
|
1461
|
-
reactiveFiles.has(file) ||
|
|
1462
|
-
clientRouterFiles.has(file) ||
|
|
1463
|
-
clientGlobalOrBareFiles.has(file);
|
|
1464
|
-
|
|
1465
|
-
// A human reason for WHY a file does client work, for the advisory that
|
|
1466
|
-
// names why a page/layout ships (#646). Mirrors the isClientEffecting
|
|
1467
|
-
// branches, most specific first.
|
|
1468
|
-
const clientEffectReason = (file) => {
|
|
1469
|
-
if (componentFiles.has(file) && mustShip.has(file)) return 'is an interactive component';
|
|
1470
|
-
if (clientRouterFiles.has(file)) return 'imports the client router';
|
|
1471
|
-
if (reactiveFiles.has(file)) return 'imports a reactive primitive (signal / computed / watch)';
|
|
1472
|
-
if (clientGlobalOrBareFiles.has(file)) return 'references a browser global at module scope, runs code at module scope, or has a bare side-effect import';
|
|
1473
|
-
return 'does client work';
|
|
1474
|
-
};
|
|
1475
|
-
|
|
1476
|
-
// Per-component verdict plus the evidence that produced it (#1308). Assembled
|
|
1477
|
-
// ENTIRELY from what the passes above already computed: nothing is re-read and
|
|
1478
|
-
// nothing is re-analysed, so this is a projection, not a second analysis. A
|
|
1479
|
-
// file may register more than one tag, so rows are keyed by FILE with a
|
|
1480
|
-
// sorted tag list.
|
|
1481
|
-
/** @type {Map<string, { tags: string[], className: string|null, shipped: boolean, evidence: string|null, by: string|null, reason: string|null }>} */
|
|
1482
|
-
const componentVerdicts = new Map();
|
|
1483
|
-
for (const c of components) {
|
|
1484
|
-
let row = componentVerdicts.get(c.file);
|
|
1485
|
-
if (!row) {
|
|
1486
|
-
const shipped = mustShip.has(c.file);
|
|
1487
|
-
const ev = shipped ? shipEvidence.get(c.file) : undefined;
|
|
1488
|
-
row = {
|
|
1489
|
-
tags: [], className: c.className ?? null, shipped,
|
|
1490
|
-
evidence: ev ? ev.evidence : null,
|
|
1491
|
-
by: ev ? ev.by : null,
|
|
1492
|
-
// An ELIDED component carries no reason on purpose: elision is the
|
|
1493
|
-
// ABSENCE of every signal, so there is no positive fact to report.
|
|
1494
|
-
// A shipping component with no evidence is only reachable if a future
|
|
1495
|
-
// rule adds to `mustShip` without calling `noteShip`, so report null
|
|
1496
|
-
// rather than a wrong claim (sigil-coverage guards against that drift).
|
|
1497
|
-
reason: !shipped || !ev ? null
|
|
1498
|
-
: ev.evidence === 'own' ? ev.reason
|
|
1499
|
-
: ev.evidence === 'observed' ? `its registration is observed by ${ev.by}`
|
|
1500
|
-
: ev.evidence === 'closure' ? `its import ${ev.by} ${clientEffectReason(/** @type {string} */ (ev.by))}`
|
|
1501
|
-
: ev.evidence === 'render' ? `${ev.by} ships and can render its tag`
|
|
1502
|
-
: ev.evidence === 'import' ? `${ev.by} ships and imports it`
|
|
1503
|
-
: 'its source could not be read (ships conservatively)',
|
|
1504
|
-
};
|
|
1505
|
-
componentVerdicts.set(c.file, row);
|
|
1506
|
-
}
|
|
1507
|
-
if (!row.tags.includes(c.tag)) row.tags.push(c.tag);
|
|
1508
|
-
}
|
|
1509
|
-
for (const row of componentVerdicts.values()) row.tags.sort();
|
|
1510
|
-
|
|
1511
|
-
// Route modules fall into three classes by their effective client closure
|
|
1512
|
-
// (skipping elided components and server stubs, which never load on the
|
|
1513
|
-
// client):
|
|
1514
|
-
// - INERT: neither the module nor its closure does any client work. Dropped
|
|
1515
|
-
// from the boot script entirely (#179).
|
|
1516
|
-
// - IMPORT-ONLY (#605): the module itself does no client work, and the ONLY
|
|
1517
|
-
// client work its closure reaches is SHIPPING COMPONENTS (or modules
|
|
1518
|
-
// those components carry). Such a page / layout module is just the
|
|
1519
|
-
// import-graph carrier for its components; the boot can emit those
|
|
1520
|
-
// component modules directly and drop the module.
|
|
1521
|
-
// - SHIP: anything else (the module itself is client-effecting, or its
|
|
1522
|
-
// closure reaches a client-effecting non-component through a path that
|
|
1523
|
-
// does NOT pass through a shipping component). Unchanged behaviour.
|
|
1524
|
-
//
|
|
1525
|
-
// The walk is PATH-AWARE (#963): it stops descending at a shipping
|
|
1526
|
-
// component, because that component is emitted in the module's place, so
|
|
1527
|
-
// its whole subtree loads regardless of whether the page module ships.
|
|
1528
|
-
// Consequently a client-effecting non-component reachable ONLY through
|
|
1529
|
-
// shipping components (the module-scope-signal-in-its-own-file idiom of
|
|
1530
|
-
// invariant 5, imported by a component the page renders) does not block
|
|
1531
|
-
// import-only: dropping the page loses nothing, the emitted component
|
|
1532
|
-
// still imports it. A client-effecting non-component reachable through a
|
|
1533
|
-
// component-free path still ships the whole module, since dropping it
|
|
1534
|
-
// would lose that side effect.
|
|
1535
|
-
/** @type {Set<string>} */
|
|
1536
|
-
const skip = new Set([...elidableComponents, ...serverFiles]);
|
|
1537
|
-
/** @type {Set<string>} */
|
|
1538
|
-
const inertRouteModules = new Set();
|
|
1539
|
-
/** @type {Map<string, string[]>} route file -> component files to emit in its place */
|
|
1540
|
-
const importOnlyRouteModules = new Map();
|
|
1541
|
-
// Advisory (#646): for each route module that SHIPS WHOLE (neither inert nor
|
|
1542
|
-
// import-only), record the first client-effecting blocker so a tool can name
|
|
1543
|
-
// why it ships. blocker is null when the module's OWN code is the cause.
|
|
1544
|
-
/** @type {Map<string, { blocker: string|null, reason: string }>} */
|
|
1545
|
-
const shippedRouteModules = new Map();
|
|
1546
|
-
for (const file of routeModules) {
|
|
1547
|
-
if (!fileTags.has(file)) continue; // unreadable / not analysed: ship (omit from both sets)
|
|
1548
|
-
if (isClientEffecting(file)) {
|
|
1549
|
-
// The module itself ships whole; its own signal is the blocker.
|
|
1550
|
-
shippedRouteModules.set(file, { blocker: null, reason: clientEffectReason(file) });
|
|
1551
|
-
continue;
|
|
1552
|
-
}
|
|
1553
|
-
// Truncated walk (path-aware, #963): BFS the module's import graph, but
|
|
1554
|
-
// do NOT descend into shipping components (they are emitted in the
|
|
1555
|
-
// module's place, so their subtrees load regardless). Collect those
|
|
1556
|
-
// frontier components as the emit set: what loading the module would
|
|
1557
|
-
// have registered, so a component imported but only conditionally
|
|
1558
|
-
// rendered still registers. Skip elided components and server files
|
|
1559
|
-
// exactly like transitiveDeps (they never load client-side; a stub is
|
|
1560
|
-
// served instead). Non-appDir deps (core, vendor) are traversed but
|
|
1561
|
-
// never classified, matching the old closure's appDir restriction.
|
|
1562
|
-
//
|
|
1563
|
-
// A `static lazy` component is NOT special-cased here: it only appears
|
|
1564
|
-
// on this STATIC walk when the route statically imports it, and in that
|
|
1565
|
-
// case loading the module already eager-loaded it before elision, so
|
|
1566
|
-
// emitting it directly preserves that exact behaviour. A normally-used
|
|
1567
|
-
// lazy component is tag-referenced (never statically imported), so it
|
|
1568
|
-
// is absent from this walk and still loads via the IntersectionObserver
|
|
1569
|
-
// `observeLazy` path.
|
|
1570
|
-
/** @type {Set<string>} shipping components to emit in the module's place */
|
|
1571
|
-
const frontier = new Set();
|
|
1572
|
-
/** @type {string|null} first client-effecting non-component on a component-free path */
|
|
1573
|
-
let blocker = null;
|
|
1574
|
-
if (appDir) {
|
|
1575
|
-
const visited = new Set([file]);
|
|
1576
|
-
const queue = [file];
|
|
1577
|
-
while (queue.length) {
|
|
1578
|
-
const cur = /** @type {string} */ (queue.shift());
|
|
1579
|
-
const deps = moduleGraph.get(cur);
|
|
1580
|
-
if (!deps) continue;
|
|
1581
|
-
for (const dep of deps) {
|
|
1582
|
-
if (visited.has(dep)) continue;
|
|
1583
|
-
visited.add(dep);
|
|
1584
|
-
// Elided components and appDir server files are in `skip`;
|
|
1585
|
-
// SERVER_FILE_RE additionally stops at a `.server.*` OUTSIDE appDir
|
|
1586
|
-
// (a relative import escaping the app dir), exactly like
|
|
1587
|
-
// transitiveDeps: the browser only ever sees its stub, so its
|
|
1588
|
-
// subtree never loads and must not contribute a blocker.
|
|
1589
|
-
if (skip.has(dep) || SERVER_FILE_RE.test(dep)) continue;
|
|
1590
|
-
if (componentFiles.has(dep)) { frontier.add(dep); continue; } // emitted; subtree carried
|
|
1591
|
-
if (dep.startsWith(appDir) && isClientEffecting(dep)) blocker ??= dep;
|
|
1592
|
-
queue.push(dep);
|
|
1593
|
-
}
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
if (blocker !== null) {
|
|
1597
|
-
// A client-effecting non-component is reachable on a component-free
|
|
1598
|
-
// path; ship the whole module and name the blocker for the advisory.
|
|
1599
|
-
shippedRouteModules.set(file, { blocker, reason: clientEffectReason(blocker) });
|
|
1600
|
-
} else if (frontier.size === 0) {
|
|
1601
|
-
inertRouteModules.add(file);
|
|
1602
|
-
} else {
|
|
1603
|
-
importOnlyRouteModules.set(file, [...frontier]);
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
return { elidableComponents, inertRouteModules, importOnlyRouteModules, shippedRouteModules, componentVerdicts };
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
/** Match a whole-line side-effect import: `import './x.js';` (no bindings). */
|
|
1611
|
-
const SIDE_EFFECT_IMPORT_RE = /^([ \t]*)import\s+(['"])([^'"]+)\2\s*;?[ \t]*$/gm;
|
|
1612
|
-
|
|
1613
|
-
/**
|
|
1614
|
-
* Remove side-effect imports of elidable components from a browser
|
|
1615
|
-
* module's served source, so the browser never downloads them. This is
|
|
1616
|
-
* what actually elides the JS: a component is fetched because the page
|
|
1617
|
-
* (or another component) statically imports it for registration, and
|
|
1618
|
-
* the modulepreload hint only parallelises that fetch.
|
|
1619
|
-
*
|
|
1620
|
-
* ONLY side-effect imports (`import './x'`) are stripped. A binding
|
|
1621
|
-
* import (`import { X } from './x'`) is left intact: its binding may be
|
|
1622
|
-
* used as a value at runtime, so removing it would break the module.
|
|
1623
|
-
* That is also why eliding stays correct, an elidable component is one
|
|
1624
|
-
* used purely as an SSR'd tag, never as an imported value.
|
|
1625
|
-
*
|
|
1626
|
-
* Fast path: if the importer has no elidable dependency in the graph,
|
|
1627
|
-
* the source is returned untouched without any regex work.
|
|
1628
|
-
*
|
|
1629
|
-
* Matching runs over a REDACTED copy (comment / string / template bodies
|
|
1630
|
-
* blanked, positions preserved) so a line that merely reads like an
|
|
1631
|
-
* import inside an `html\`...\`` template or a comment is never rewritten.
|
|
1632
|
-
* Real top-level import statements survive redaction; the quoted
|
|
1633
|
-
* specifier survives too (redaction keeps string bodies verbatim), so it
|
|
1634
|
-
* is read from the redacted match and the original source is spliced at
|
|
1635
|
-
* the matched range.
|
|
1636
|
-
*
|
|
1637
|
-
* @param {string} source module source (already type-stripped if TS)
|
|
1638
|
-
* @param {string} importerAbs absolute path of the importing module
|
|
1639
|
-
* @param {import('./module-graph.js').ModuleGraph | undefined} moduleGraph
|
|
1640
|
-
* @param {Set<string> | undefined} elidableSet absolute paths of elidable files
|
|
1641
|
-
* @param {(spec: string, fromFile: string, appDir: string) => (string|null)} resolveImport
|
|
1642
|
-
* @param {string} appDir
|
|
1643
|
-
* @returns {string}
|
|
1644
|
-
*/
|
|
1645
|
-
export function elideImportsFromSource(source, importerAbs, moduleGraph, elidableSet, resolveImport, appDir) {
|
|
1646
|
-
if (!elidableSet || elidableSet.size === 0) return source;
|
|
1647
|
-
const deps = moduleGraph && moduleGraph.get(importerAbs);
|
|
1648
|
-
if (!deps) return source;
|
|
1649
|
-
let hasElidableDep = false;
|
|
1650
|
-
for (const d of deps) {
|
|
1651
|
-
if (elidableSet.has(d)) { hasElidableDep = true; break; }
|
|
1652
|
-
}
|
|
1653
|
-
if (!hasElidableDep) return source;
|
|
1654
|
-
|
|
1655
|
-
const redacted = redactStringsAndTemplates(source);
|
|
1656
|
-
let out = '';
|
|
1657
|
-
let last = 0;
|
|
1658
|
-
for (const m of redacted.matchAll(SIDE_EFFECT_IMPORT_RE)) {
|
|
1659
|
-
const start = /** @type {number} */ (m.index);
|
|
1660
|
-
const end = start + m[0].length;
|
|
1661
|
-
const resolved = resolveImport(m[3], importerAbs, appDir);
|
|
1662
|
-
out += source.slice(last, start);
|
|
1663
|
-
if (resolved && elidableSet.has(resolved)) {
|
|
1664
|
-
out += `${m[1]}/* webjs: elided display-only component */`;
|
|
1665
|
-
} else {
|
|
1666
|
-
out += source.slice(start, end);
|
|
1667
|
-
}
|
|
1668
|
-
last = end;
|
|
1669
|
-
}
|
|
1670
|
-
out += source.slice(last);
|
|
1671
|
-
return out;
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
/**
|
|
1675
|
-
* Defer side-effect imports of `static lazy` components (#1524). A lazy
|
|
1676
|
-
* component that a shipping module imports for registration used to load
|
|
1677
|
-
* eagerly as part of that module's static graph, which defeated `static lazy`
|
|
1678
|
-
* whenever the class had to be imported at all (and SSR needs the import: the
|
|
1679
|
-
* server only knows the tag is lazy once the class registered). The SERVER
|
|
1680
|
-
* keeps the real import; this rewrites only the BROWSER copy, replacing the
|
|
1681
|
-
* import line in place (same line, so positions stay stable) with a
|
|
1682
|
-
* registration on the lazy loader:
|
|
1683
|
-
*
|
|
1684
|
-
* import('@webjsdev/core/lazy-loader').then((m) => m.observeLazy({"x-tag":"/url"}));
|
|
1685
|
-
*
|
|
1686
|
-
* The loader then imports the module the first time an element with one of
|
|
1687
|
-
* its tags is in (or near) the viewport: a hidden tab panel when it first
|
|
1688
|
-
* shows, a closed dialog's content when it opens, a below-the-fold element
|
|
1689
|
-
* when it scrolls near. Its MutationObserver also catches a tag rendered
|
|
1690
|
-
* later on the client, so an imported-but-not-yet-rendered component still
|
|
1691
|
-
* registers when it appears.
|
|
1692
|
-
*
|
|
1693
|
-
* Same contract as {@link elideImportsFromSource}: only a side-effect import
|
|
1694
|
-
* is rewritten (a binding import needs its value now, so it stays eager), the
|
|
1695
|
-
* match runs over a redacted copy so an import-looking line inside a string,
|
|
1696
|
-
* template, or comment is never touched, and an importer with no lazy
|
|
1697
|
-
* dependency in the graph returns untouched with no regex work. Run it AFTER
|
|
1698
|
-
* the elision strip: an elided import is already a comment by then.
|
|
1699
|
-
*
|
|
1700
|
-
* @param {string} source module source (already type-stripped if TS)
|
|
1701
|
-
* @param {string} importerAbs absolute path of the importing module
|
|
1702
|
-
* @param {import('./module-graph.js').ModuleGraph | undefined} moduleGraph
|
|
1703
|
-
* @param {Map<string, string[]> | undefined} lazyFiles lazy component file -> the tags it registers
|
|
1704
|
-
* @param {(spec: string, fromFile: string, appDir: string) => (string|null)} resolveImport
|
|
1705
|
-
* @param {string} appDir
|
|
1706
|
-
* @param {(abs: string) => string} urlFor the browser URL the loader imports (base path + content hash applied)
|
|
1707
|
-
* @returns {string}
|
|
1708
|
-
*/
|
|
1709
|
-
export function deferLazyImportsFromSource(source, importerAbs, moduleGraph, lazyFiles, resolveImport, appDir, urlFor) {
|
|
1710
|
-
if (!lazyFiles || lazyFiles.size === 0) return source;
|
|
1711
|
-
const deps = moduleGraph && moduleGraph.get(importerAbs);
|
|
1712
|
-
if (!deps) return source;
|
|
1713
|
-
let hasLazyDep = false;
|
|
1714
|
-
for (const d of deps) {
|
|
1715
|
-
if (lazyFiles.has(d)) { hasLazyDep = true; break; }
|
|
1716
|
-
}
|
|
1717
|
-
if (!hasLazyDep) return source;
|
|
1718
|
-
|
|
1719
|
-
const redacted = redactStringsAndTemplates(source);
|
|
1720
|
-
let out = '';
|
|
1721
|
-
let last = 0;
|
|
1722
|
-
for (const m of redacted.matchAll(SIDE_EFFECT_IMPORT_RE)) {
|
|
1723
|
-
const start = /** @type {number} */ (m.index);
|
|
1724
|
-
const end = start + m[0].length;
|
|
1725
|
-
const resolved = resolveImport(m[3], importerAbs, appDir);
|
|
1726
|
-
const tags = resolved ? lazyFiles.get(resolved) : undefined;
|
|
1727
|
-
out += source.slice(last, start);
|
|
1728
|
-
if (tags && tags.length) {
|
|
1729
|
-
const url = urlFor(/** @type {string} */ (resolved));
|
|
1730
|
-
const entries = Object.fromEntries(tags.map((t) => [t, url]));
|
|
1731
|
-
out += `${m[1]}import('@webjsdev/core/lazy-loader').then((m) => m.observeLazy(${JSON.stringify(entries)}));`;
|
|
1732
|
-
} else {
|
|
1733
|
-
out += source.slice(start, end);
|
|
1734
|
-
}
|
|
1735
|
-
last = end;
|
|
1736
|
-
}
|
|
1737
|
-
out += source.slice(last);
|
|
1738
|
-
return out;
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1741
|
-
/**
|
|
1742
|
-
* The lazy component files of an app (#1524): every scanned component file
|
|
1743
|
-
* that declares `static lazy = true`, mapped to the tags it registers. An
|
|
1744
|
-
* elided (display-only) file is left out: its import is stripped outright,
|
|
1745
|
-
* which beats deferring it.
|
|
1746
|
-
*
|
|
1747
|
-
* @param {Array<{ tag: string, file: string, lazy?: boolean }>} components
|
|
1748
|
-
* @param {Set<string>} [elidable]
|
|
1749
|
-
* @returns {Map<string, string[]>}
|
|
1750
|
-
*/
|
|
1751
|
-
export function lazyComponentFiles(components, elidable) {
|
|
1752
|
-
/** @type {Map<string, string[]>} */
|
|
1753
|
-
const out = new Map();
|
|
1754
|
-
for (const c of components) {
|
|
1755
|
-
if (!c.lazy || (elidable && elidable.has(c.file))) continue;
|
|
1756
|
-
const tags = out.get(c.file);
|
|
1757
|
-
if (tags) { if (!tags.includes(c.tag)) tags.push(c.tag); } else out.set(c.file, [c.tag]);
|
|
1758
|
-
}
|
|
1759
|
-
return out;
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
/** A test module (the same rule the component scanner skips). */
|
|
1763
|
-
const TEST_FILE_RE = /\.(test|spec)\.m?[jt]sx?$/;
|
|
1764
|
-
|
|
1765
|
-
/** `import <bindings> from 'x'` (the bindings part is captured). */
|
|
1766
|
-
const BINDING_IMPORT_RE = /\bimport\s+(type\s+)?([\w$*{}\s,]+?)\s+from\s+(['"])([^'"]+)\3/g;
|
|
1767
|
-
/** `export { a } from 'x'` / `export * from 'x'` (a re-export needs the module too). */
|
|
1768
|
-
const REEXPORT_RE = /\bexport\s+(type\s+)?(?:\*|\{[^}]*\}|\*\s+as\s+[\w$]+)\s+from\s+(['"])([^'"]+)\2/g;
|
|
1769
|
-
|
|
1770
|
-
/**
|
|
1771
|
-
* Is this import clause types only (`import type { A }`, or every named
|
|
1772
|
-
* specifier marked `type`)? Those are erased before the browser sees them.
|
|
1773
|
-
*
|
|
1774
|
-
* @param {string | undefined} typeKw
|
|
1775
|
-
* @param {string} clause
|
|
1776
|
-
*/
|
|
1777
|
-
function typeOnlyClause(typeKw, clause) {
|
|
1778
|
-
if (typeKw) return true;
|
|
1779
|
-
const m = clause.trim().match(/^\{([\s\S]*)\}$/);
|
|
1780
|
-
if (!m) return false;
|
|
1781
|
-
const names = m[1].split(',').map((s) => s.trim()).filter(Boolean);
|
|
1782
|
-
return names.length > 0 && names.every((n) => /^type\s/.test(n));
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
/**
|
|
1786
|
-
* The lazy component files some module imports WITH bindings (#1524). A
|
|
1787
|
-
* binding import loads the module eagerly whatever the class says, because the
|
|
1788
|
-
* importer needs the value when it runs. Such a file is not lazy in practice,
|
|
1789
|
-
* so the caller drops it from the lazy set: otherwise the preload walks would
|
|
1790
|
-
* skip a module the browser still fetches up front, which only makes it slower.
|
|
1791
|
-
*
|
|
1792
|
-
* Only importers whose graph edges reach a lazy file are read. A server file is
|
|
1793
|
-
* never served, and a test file (`*.test.*` / `*.spec.*`) never ships to a
|
|
1794
|
-
* page, so their imports do not count: a component's own browser test imports
|
|
1795
|
-
* it with bindings, and that must not make it eager in the app.
|
|
1796
|
-
*
|
|
1797
|
-
* @param {Map<string, string[]>} lazyFiles
|
|
1798
|
-
* @param {import('./module-graph.js').ModuleGraph | undefined} moduleGraph
|
|
1799
|
-
* @param {(file: string) => Promise<string>} readFileFn
|
|
1800
|
-
* @param {(spec: string, fromFile: string, appDir: string) => (string|null)} resolveImport
|
|
1801
|
-
* @param {string} appDir
|
|
1802
|
-
* @returns {Promise<Set<string>>}
|
|
1803
|
-
*/
|
|
1804
|
-
export async function bindingImportedLazyFiles(lazyFiles, moduleGraph, readFileFn, resolveImport, appDir) {
|
|
1805
|
-
/** @type {Set<string>} */
|
|
1806
|
-
const out = new Set();
|
|
1807
|
-
if (!lazyFiles.size || !moduleGraph) return out;
|
|
1808
|
-
for (const [importer, deps] of moduleGraph) {
|
|
1809
|
-
if (SERVER_FILE_RE.test(importer) || TEST_FILE_RE.test(importer)) continue;
|
|
1810
|
-
let reaches = false;
|
|
1811
|
-
for (const d of deps) if (lazyFiles.has(d) && !out.has(d)) { reaches = true; break; }
|
|
1812
|
-
if (!reaches) continue;
|
|
1813
|
-
let src;
|
|
1814
|
-
try { src = await readFileFn(importer); } catch { continue; }
|
|
1815
|
-
const code = redactStringsAndTemplates(maskComments(src));
|
|
1816
|
-
for (const m of code.matchAll(BINDING_IMPORT_RE)) {
|
|
1817
|
-
if (typeOnlyClause(m[1], m[2])) continue;
|
|
1818
|
-
const abs = resolveImport(m[4], importer, appDir);
|
|
1819
|
-
if (abs && lazyFiles.has(abs)) out.add(abs);
|
|
1820
|
-
}
|
|
1821
|
-
for (const m of code.matchAll(REEXPORT_RE)) {
|
|
1822
|
-
if (m[1]) continue;
|
|
1823
|
-
const abs = resolveImport(m[3], importer, appDir);
|
|
1824
|
-
if (abs && lazyFiles.has(abs)) out.add(abs);
|
|
1825
|
-
}
|
|
1826
|
-
}
|
|
1827
|
-
return out;
|
|
1828
|
-
}
|