@webjsdev/server 0.8.31 → 0.8.33
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/package.json +1 -1
- package/src/check.js +112 -66
- package/src/component-elision.js +259 -52
- package/src/component-scanner.js +15 -10
- package/src/dev.js +56 -4
- package/src/js-scan.js +292 -10
- package/src/ssr.js +38 -4
- package/src/vendor.js +4 -1
- package/webjs-config.schema.json +5 -0
package/src/component-elision.js
CHANGED
|
@@ -42,8 +42,9 @@ import {
|
|
|
42
42
|
matchClosingBrace,
|
|
43
43
|
redactStringsAndTemplates,
|
|
44
44
|
maskComments,
|
|
45
|
+
redactToPlaceholders,
|
|
45
46
|
} from './js-scan.js';
|
|
46
|
-
import { transitiveDeps } from './module-graph.js';
|
|
47
|
+
import { transitiveDeps, expandImportAlias } from './module-graph.js';
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* Named imports from a `@webjsdev/core` specifier that imply the
|
|
@@ -215,8 +216,30 @@ const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|
|
|
|
215
216
|
*
|
|
216
217
|
* @param {string} src raw module source
|
|
217
218
|
*/
|
|
218
|
-
|
|
219
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Constructors that produce inert DATA with no side effect, so a module-scope
|
|
221
|
+
* `export const X = new Set([...])` (a lookup table, a compiled RegExp, a
|
|
222
|
+
* parsed URL) is not client work and must not pin an importing page/layout
|
|
223
|
+
* (#623). Any constructor NOT in this set (`new WebSocket()`, `new Worker()`,
|
|
224
|
+
* `new EventSource()`, `new Audio()`) IS a side effect and still ships.
|
|
225
|
+
*/
|
|
226
|
+
const PURE_DATA_CONSTRUCTORS = new Set([
|
|
227
|
+
'Set', 'Map', 'WeakSet', 'WeakMap', 'Date', 'RegExp', 'Array', 'Object',
|
|
228
|
+
'Number', 'String', 'Boolean', 'BigInt', 'Symbol',
|
|
229
|
+
'Error', 'TypeError', 'RangeError', 'SyntaxError',
|
|
230
|
+
'URL', 'URLSearchParams', 'ArrayBuffer', 'DataView',
|
|
231
|
+
'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array',
|
|
232
|
+
'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array',
|
|
233
|
+
'BigInt64Array', 'BigUint64Array',
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
function hasModuleScopeSideEffect(src, literals) {
|
|
237
|
+
let redacted = src;
|
|
238
|
+
if (!literals) {
|
|
239
|
+
const r = redactToPlaceholders(src);
|
|
240
|
+
redacted = r.redacted;
|
|
241
|
+
literals = r.literals;
|
|
242
|
+
}
|
|
220
243
|
// Keep only depth-0 text (outside every `{}`). Skip quoted-string bodies so
|
|
221
244
|
// braces/parens inside a string neither desync the depth nor read as a call.
|
|
222
245
|
let depth = 0;
|
|
@@ -252,9 +275,16 @@ function hasModuleScopeSideEffect(src) {
|
|
|
252
275
|
// Optional-chaining call/index: `foo?.()`, `x?.[i]()` (the `?.` defeats the
|
|
253
276
|
// identifier-before-paren match below).
|
|
254
277
|
if (/\?\.\s*[([]/.test(frame)) return true;
|
|
255
|
-
// Top-level `new
|
|
256
|
-
//
|
|
257
|
-
|
|
278
|
+
// Top-level `new X`: a pure-data builtin constructor (Set / Map / Date /
|
|
279
|
+
// RegExp / typed array / URL / ...) is inert module data, not client work, so
|
|
280
|
+
// it stays elidable (#623); any other constructor is a side effect. Catches
|
|
281
|
+
// both `new X` and `new X(...)` (the constructor's own `(`, if present, is
|
|
282
|
+
// then skipped in the call scan below).
|
|
283
|
+
for (const nm of frame.matchAll(/(?<![.\w])new\s+([A-Za-z_$][\w$]*)/g)) {
|
|
284
|
+
if (!PURE_DATA_CONSTRUCTORS.has(nm[1])) return true;
|
|
285
|
+
}
|
|
286
|
+
// Top-level `await`.
|
|
287
|
+
if (/(?<![.\w])await\s/.test(frame)) return true;
|
|
258
288
|
// A call: `(` preceded (ignoring whitespace) by an identifier, `)`, or `]`.
|
|
259
289
|
const CALL_RE = /(?:([A-Za-z_$][\w$]*)|[)\]])\s*\(/g;
|
|
260
290
|
// Identifiers that precede a `(` WITHOUT it being a call (keywords + a
|
|
@@ -274,6 +304,19 @@ function hasModuleScopeSideEffect(src) {
|
|
|
274
304
|
if (ident && /\bfunction\s*\*?\s*$/.test(frame.slice(0, m.index))) continue;
|
|
275
305
|
// The component registration call is the one permitted top-level call.
|
|
276
306
|
if (ident === 'register' || ident === 'define') continue;
|
|
307
|
+
// `extends WebComponent({ … })` is the declare-free reactive-prop DX (#597
|
|
308
|
+
// / #599): a class-declaration construct, not an independent side-effecting
|
|
309
|
+
// statement. Exempt it so a display-only factory-form component stays
|
|
310
|
+
// elidable; a genuinely interactive one still ships via the per-class and
|
|
311
|
+
// module-level signal checks below (events, slots, shadow, lifecycle, a
|
|
312
|
+
// non-state factory prop, a reactive import). Scoped to the `extends`
|
|
313
|
+
// position, so a top-level `WebComponent(...)` call used anywhere else
|
|
314
|
+
// still counts as a side effect (#604).
|
|
315
|
+
if (ident === 'WebComponent' && /\bextends\s+$/.test(frame.slice(0, m.index))) continue;
|
|
316
|
+
// A `new X(...)` constructor call: the `new`-prefixed cases were already
|
|
317
|
+
// vetted above (a non-pure constructor returned true; a pure-data one is
|
|
318
|
+
// inert), so its `(` here is not an independent side-effecting call.
|
|
319
|
+
if (ident && /(?<![.\w])new\s+$/.test(frame.slice(0, m.index))) continue;
|
|
277
320
|
return true; // any other top-level call
|
|
278
321
|
}
|
|
279
322
|
return false;
|
|
@@ -290,26 +333,38 @@ const SIDE_EFFECT_BARE_IMPORT_RE = /^\s*import\s*(['"])([^'"]+)\1\s*;?\s*(?:\/\/
|
|
|
290
333
|
* a router/nav API from the core main entry). A page or layout that does so
|
|
291
334
|
* is enabling client-side navigation and must ship.
|
|
292
335
|
* @param {string} src
|
|
336
|
+
* @param {string[]} [literals]
|
|
293
337
|
* @returns {boolean}
|
|
294
338
|
*/
|
|
295
|
-
function importsClientRouter(src) {
|
|
296
|
-
|
|
297
|
-
|
|
339
|
+
function importsClientRouter(src, literals) {
|
|
340
|
+
let redacted = src;
|
|
341
|
+
if (!literals) {
|
|
342
|
+
const r = redactToPlaceholders(src);
|
|
343
|
+
redacted = r.redacted;
|
|
344
|
+
literals = r.literals;
|
|
345
|
+
}
|
|
346
|
+
if (literals.includes('@webjsdev/core/client-router')) return true;
|
|
347
|
+
for (const m of redacted.matchAll(CORE_IMPORT_RE)) {
|
|
298
348
|
const clause = m[1];
|
|
349
|
+
const spec = m[2];
|
|
350
|
+
const idx = m[3];
|
|
351
|
+
if (idx !== undefined) {
|
|
352
|
+
const resolved = literals[parseInt(idx, 10)];
|
|
353
|
+
if (!resolved || (!resolved.startsWith('@webjsdev/core') && !resolved.includes('/__webjs/core/'))) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
299
357
|
if (clause.startsWith('{')) {
|
|
300
358
|
const names = clause.slice(1, -1).split(',').map((s) => s.trim().split(/\s+as\s+/)[0].trim());
|
|
301
359
|
if (names.some((n) => CLIENT_ROUTER_IMPORTS.includes(n))) return true;
|
|
302
360
|
} else if (clause.startsWith('*')) {
|
|
303
|
-
// Namespace import: a router/nav member reached through `ns.member`,
|
|
304
|
-
// a destructure of `ns`, or computed access. Mirrors the reactive
|
|
305
|
-
// primitive detection so the two stay symmetric.
|
|
306
361
|
const ns = clause.replace(/^\*\s+as\s+/, '').trim();
|
|
307
362
|
if (!ns || !/^\w+$/.test(ns)) continue;
|
|
308
363
|
for (const name of CLIENT_ROUTER_IMPORTS) {
|
|
309
|
-
if (new RegExp(`\\b${ns}\\.${name}\\b`).test(
|
|
364
|
+
if (new RegExp(`\\b${ns}\\.${name}\\b`).test(redacted)) return true;
|
|
310
365
|
}
|
|
311
|
-
if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(
|
|
312
|
-
if (new RegExp(`\\b${ns}\\s*\\[`).test(
|
|
366
|
+
if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(redacted)) return true;
|
|
367
|
+
if (new RegExp(`\\b${ns}\\s*\\[`).test(redacted)) return true;
|
|
313
368
|
}
|
|
314
369
|
}
|
|
315
370
|
return false;
|
|
@@ -336,13 +391,46 @@ function importsClientRouter(src) {
|
|
|
336
391
|
* the fix is `.server.{js,ts}` for genuinely server-only deps, or an
|
|
337
392
|
* interactivity signal on the consumer. (It is not caught by an SSR crash:
|
|
338
393
|
* the SSR `customElements` shim makes `define` a no-op server-side.)
|
|
394
|
+
*
|
|
395
|
+
* A `#` PATH-ALIAS specifier (#555, e.g. `import '#components/x.ts'`, the
|
|
396
|
+
* idiomatic way a page/layout registers a component) is NOT a bare npm
|
|
397
|
+
* package: Node's `package.json` "imports" resolves it, in the scaffold's
|
|
398
|
+
* catch-all `"#*": "./*"` to a LOCAL file. Expanding it through the same
|
|
399
|
+
* `expandImportAlias` the module graph uses, a local-resolving alias is
|
|
400
|
+
* treated like a relative import (skipped); only an alias mapped to a real
|
|
401
|
+
* bare package (`"#crypto": "crypto-browserify"`) falls through to the
|
|
402
|
+
* package check. Without this, every `#`-imported component side effect
|
|
403
|
+
* wrongly pinned its importing page/layout to the browser (#623). An
|
|
404
|
+
* unexpandable `#` (no `appDir`, or no matching key) is assumed local,
|
|
405
|
+
* since `#` is the local-subpath sigil and an unmapped `#` is a Node error,
|
|
406
|
+
* not a package.
|
|
339
407
|
* @param {string} src
|
|
408
|
+
* @param {string} [appDir] app root, used to expand `#` path aliases
|
|
409
|
+
* @param {string[]} [literals]
|
|
340
410
|
* @returns {boolean}
|
|
341
411
|
*/
|
|
342
|
-
function importsSideEffectNonCorePackage(src) {
|
|
343
|
-
|
|
344
|
-
|
|
412
|
+
function importsSideEffectNonCorePackage(src, appDir, literals) {
|
|
413
|
+
let redacted = src;
|
|
414
|
+
if (!literals) {
|
|
415
|
+
const r = redactToPlaceholders(src);
|
|
416
|
+
redacted = r.redacted;
|
|
417
|
+
literals = r.literals;
|
|
418
|
+
}
|
|
419
|
+
for (const m of redacted.matchAll(SIDE_EFFECT_BARE_IMPORT_RE)) {
|
|
420
|
+
let spec = m[2];
|
|
421
|
+
const match = /^__STR_(\d+)__$/.exec(spec);
|
|
422
|
+
if (match) {
|
|
423
|
+
const idx = parseInt(match[1], 10);
|
|
424
|
+
spec = literals[idx];
|
|
425
|
+
}
|
|
426
|
+
if (!spec) continue;
|
|
345
427
|
if (spec.startsWith('.') || spec.startsWith('/')) continue; // relative / absolute
|
|
428
|
+
if (spec.startsWith('#')) {
|
|
429
|
+
const expanded = appDir ? expandImportAlias(spec, appDir) : null;
|
|
430
|
+
if (expanded == null) continue; // unmapped alias: local
|
|
431
|
+
if (expanded.startsWith('.') || expanded.startsWith('/')) continue; // resolves to a local file
|
|
432
|
+
spec = expanded; // mapped to a bare spec: check it below
|
|
433
|
+
}
|
|
346
434
|
if (spec === '@webjsdev/core' || spec.startsWith('@webjsdev/core/')) continue; // inert framework / router handled separately
|
|
347
435
|
if (spec.startsWith('node:')) continue; // server-only builtins
|
|
348
436
|
return true;
|
|
@@ -350,9 +438,9 @@ function importsSideEffectNonCorePackage(src) {
|
|
|
350
438
|
return false;
|
|
351
439
|
}
|
|
352
440
|
|
|
353
|
-
/** Match a named-import clause from a `@webjsdev/core` specifier. */
|
|
441
|
+
/** Match a named-import clause from a `@webjsdev/core` specifier (or its placeholder). */
|
|
354
442
|
const CORE_IMPORT_RE =
|
|
355
|
-
/import\s+(?:type\s+)?(\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"](@webjsdev\/core[^'"]*|[^'"]*\/__webjs\/core\/[^'"]
|
|
443
|
+
/import\s+(?:type\s+)?(\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"`](@webjsdev\/core[^'"`]*|[^'"`]*\/__webjs\/core\/[^'"`]*|__STR_(\d+)__)['"`]/g;
|
|
356
444
|
|
|
357
445
|
/**
|
|
358
446
|
* Decide whether a component module is interactive (must ship) or
|
|
@@ -416,7 +504,7 @@ export function analyzeComponentSource(src) {
|
|
|
416
504
|
return { interactive: true, reason: 'no parseable WebComponent class body' };
|
|
417
505
|
}
|
|
418
506
|
|
|
419
|
-
for (const body of bodies) {
|
|
507
|
+
for (const { body, factoryArg } of bodies) {
|
|
420
508
|
// Shadow DOM always ships (#474). Declarative Shadow DOM attaches ONLY
|
|
421
509
|
// during HTML parsing; an elided component that arrives via a client DOM
|
|
422
510
|
// insertion (a soft-nav swap, a streamed <webjs-suspense> boundary's
|
|
@@ -447,7 +535,7 @@ export function analyzeComponentSource(src) {
|
|
|
447
535
|
return { interactive: true, reason: `calls '${call}'` };
|
|
448
536
|
}
|
|
449
537
|
}
|
|
450
|
-
if (hasNonStateReactiveProperty(body)) {
|
|
538
|
+
if (hasNonStateReactiveProperty(body) || hasNonStateFactoryProperty(factoryArg)) {
|
|
451
539
|
return {
|
|
452
540
|
interactive: true,
|
|
453
541
|
reason: 'declares a reactive property that is not { state: true }',
|
|
@@ -483,11 +571,26 @@ function declaresStaticTrue(classBody, name) {
|
|
|
483
571
|
|
|
484
572
|
/**
|
|
485
573
|
* @param {string} src
|
|
574
|
+
* @param {string[]} [literals]
|
|
486
575
|
* @returns {string | null} the offending imported name, or null
|
|
487
576
|
*/
|
|
488
|
-
function importsReactivePrimitive(src) {
|
|
489
|
-
|
|
577
|
+
function importsReactivePrimitive(src, literals) {
|
|
578
|
+
let redacted = src;
|
|
579
|
+
if (!literals) {
|
|
580
|
+
const r = redactToPlaceholders(src);
|
|
581
|
+
redacted = r.redacted;
|
|
582
|
+
literals = r.literals;
|
|
583
|
+
}
|
|
584
|
+
for (const m of redacted.matchAll(CORE_IMPORT_RE)) {
|
|
490
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
|
+
}
|
|
491
594
|
if (clause.startsWith('{')) {
|
|
492
595
|
const names = clause
|
|
493
596
|
.slice(1, -1)
|
|
@@ -505,14 +608,14 @@ function importsReactivePrimitive(src) {
|
|
|
505
608
|
const ns = clause.replace(/^\*\s+as\s+/, '').trim();
|
|
506
609
|
if (!ns || !/^\w+$/.test(ns)) continue;
|
|
507
610
|
for (const name of REACTIVE_IMPORTS) {
|
|
508
|
-
if (new RegExp(`\\b${ns}\\.${name}\\b`).test(
|
|
611
|
+
if (new RegExp(`\\b${ns}\\.${name}\\b`).test(redacted)) return name;
|
|
509
612
|
}
|
|
510
613
|
// Destructuring the namespace (`const { signal } = core`) or computed
|
|
511
614
|
// access (`core['signal']`) hides which members are pulled. Ship.
|
|
512
|
-
if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(
|
|
615
|
+
if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(redacted)) {
|
|
513
616
|
return `${ns} (destructured namespace)`;
|
|
514
617
|
}
|
|
515
|
-
if (new RegExp(`\\b${ns}\\s*\\[`).test(
|
|
618
|
+
if (new RegExp(`\\b${ns}\\s*\\[`).test(redacted)) {
|
|
516
619
|
return `${ns} (computed namespace access)`;
|
|
517
620
|
}
|
|
518
621
|
}
|
|
@@ -551,18 +654,11 @@ function hasNonStateReactiveProperty(classBody) {
|
|
|
551
654
|
// see; ship rather than guess they are all { state: true }.
|
|
552
655
|
if (/\.\.\./.test(obj)) return true;
|
|
553
656
|
for (const entry of topLevelPropertyValues(obj)) {
|
|
554
|
-
// Object-literal descriptor: inert only when it carries state: true
|
|
657
|
+
// Object-literal descriptor: inert only when it carries `state: true` at
|
|
658
|
+
// its top level (descriptorDeclaresState blanks strings and ignores a
|
|
659
|
+
// nested state flag, e.g. inside a converter, so it cannot be forged).
|
|
555
660
|
if (entry.startsWith('{')) {
|
|
556
|
-
|
|
557
|
-
// string contents verbatim (so register('tag') stays readable), so
|
|
558
|
-
// a descriptor like `{ attribute: 'data-state: true' }` would
|
|
559
|
-
// otherwise forge the state flag. The real `state: true` is code,
|
|
560
|
-
// not a string, so it survives this blanking.
|
|
561
|
-
const code = entry
|
|
562
|
-
.replace(/'[^'\n]*'/g, "''")
|
|
563
|
-
.replace(/"[^"\n]*"/g, '""')
|
|
564
|
-
.replace(/`[^`]*`/g, '``');
|
|
565
|
-
if (!/\bstate\s*:\s*true\b/.test(code)) return true;
|
|
661
|
+
if (!descriptorDeclaresState(entry)) return true;
|
|
566
662
|
} else {
|
|
567
663
|
// Shorthand like `count: Number` rides an attribute, not state.
|
|
568
664
|
return true;
|
|
@@ -571,6 +667,73 @@ function hasNonStateReactiveProperty(classBody) {
|
|
|
571
667
|
return false;
|
|
572
668
|
}
|
|
573
669
|
|
|
670
|
+
/**
|
|
671
|
+
* True if the class factory argument (`extends WebComponent({ … })`) declares
|
|
672
|
+
* any reactive property that is NOT `{ state: true }`. A non-state property
|
|
673
|
+
* rides an HTML attribute or a `.prop` hydration binding, the channel that
|
|
674
|
+
* forces the component to ship; a `state: true` property is component-local
|
|
675
|
+
* reactive state with no such channel, so a component whose only signal is
|
|
676
|
+
* state props stays elidable.
|
|
677
|
+
*
|
|
678
|
+
* A property value is treated as state ONLY when its descriptor declares
|
|
679
|
+
* `state: true` at the TOP LEVEL (see `descriptorDeclaresState`), which covers
|
|
680
|
+
* the bare descriptor `{ state: true }` and the `prop()` helper forms
|
|
681
|
+
* `prop({ state: true })` / `prop(Type, { state: true })`. Anything else (a bare
|
|
682
|
+
* type `Number`, `prop(Number)`, an options object without `state: true`) is
|
|
683
|
+
* non-state and ships. Conservative on a spread or an unbalanced brace (cannot
|
|
684
|
+
* prove every entry is state, so ship).
|
|
685
|
+
*
|
|
686
|
+
* @param {string} factoryArg
|
|
687
|
+
* @returns {boolean}
|
|
688
|
+
*/
|
|
689
|
+
function hasNonStateFactoryProperty(factoryArg) {
|
|
690
|
+
if (!factoryArg) return false;
|
|
691
|
+
const objStart = factoryArg.indexOf('{');
|
|
692
|
+
if (objStart === -1) return false;
|
|
693
|
+
// matchClosingBrace starts at depth 1, so pass the index AFTER the brace.
|
|
694
|
+
const objEnd = matchClosingBrace(factoryArg, objStart + 1);
|
|
695
|
+
if (objEnd === -1) return true;
|
|
696
|
+
const obj = factoryArg.slice(objStart + 1, objEnd);
|
|
697
|
+
if (/\.\.\./.test(obj)) return true; // spread: cannot prove all entries state
|
|
698
|
+
for (const entry of topLevelPropertyValues(obj)) {
|
|
699
|
+
if (!descriptorDeclaresState(entry)) return true;
|
|
700
|
+
}
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* True if a reactive-property descriptor declares `state: true` at the TOP
|
|
706
|
+
* LEVEL of its options object (brace-depth 1). Restricting to depth 1 is a
|
|
707
|
+
* direction-of-safety fix: a `state: true` buried deeper (a converter /
|
|
708
|
+
* hasChanged body that happens to return `{ state: true }`) must NOT forge the
|
|
709
|
+
* flag, because wrongly treating an attribute-riding property as state would
|
|
710
|
+
* ELIDE an interactive component and break the page. Strings / templates are
|
|
711
|
+
* blanked first, so `attribute: 'data-state: true'` does not match, and the
|
|
712
|
+
* `\b` word boundary keeps a key like `firstate: true` from matching. In every
|
|
713
|
+
* legitimate shape (`{ state: true }`, `prop({ state: true })`,
|
|
714
|
+
* `prop(Type, { state: true })`) the descriptor object is the only brace group,
|
|
715
|
+
* so its `state` key sits at depth 1.
|
|
716
|
+
*
|
|
717
|
+
* @param {string} entry the property VALUE text
|
|
718
|
+
* @returns {boolean}
|
|
719
|
+
*/
|
|
720
|
+
function descriptorDeclaresState(entry) {
|
|
721
|
+
const code = entry
|
|
722
|
+
.replace(/'[^'\n]*'/g, "''")
|
|
723
|
+
.replace(/"[^"\n]*"/g, '""')
|
|
724
|
+
.replace(/`[^`]*`/g, '``');
|
|
725
|
+
// Keep only text at brace-depth <= 1 (the descriptor's own level); blank
|
|
726
|
+
// deeper nesting so a nested `state: true` cannot count.
|
|
727
|
+
let depth = 0;
|
|
728
|
+
let shallow = '';
|
|
729
|
+
for (const c of code) {
|
|
730
|
+
if (c === '{') { depth++; if (depth <= 1) shallow += c; continue; }
|
|
731
|
+
if (c === '}') { if (depth <= 1) shallow += c; if (depth > 0) depth--; continue; }
|
|
732
|
+
if (depth <= 1) shallow += c;
|
|
733
|
+
}
|
|
734
|
+
return /\bstate\s*:\s*true\b/.test(shallow);
|
|
735
|
+
}
|
|
736
|
+
|
|
574
737
|
/**
|
|
575
738
|
* Yield the trimmed VALUE text of each top-level `key: value` entry in a
|
|
576
739
|
* properties object body, splitting on depth-0 commas and respecting
|
|
@@ -680,7 +843,7 @@ export async function computeElidableComponents(components, moduleGraph, readFil
|
|
|
680
843
|
* @param {import('./module-graph.js').ModuleGraph} moduleGraph
|
|
681
844
|
* @param {(file: string) => Promise<string>} readFileFn
|
|
682
845
|
* @param {string} [appDir]
|
|
683
|
-
* @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string> }>}
|
|
846
|
+
* @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string>, importOnlyRouteModules: Map<string, string[]> }>}
|
|
684
847
|
*/
|
|
685
848
|
export async function analyzeElision(components, routeModules, moduleGraph, readFileFn, appDir) {
|
|
686
849
|
/** @type {Set<string>} */
|
|
@@ -713,6 +876,8 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
713
876
|
|
|
714
877
|
/** @type {Set<string>} */
|
|
715
878
|
const allFiles = new Set(componentFiles);
|
|
879
|
+
/** @type {Set<string>} page/layout modules: never hydrate, so template content is SSR output. */
|
|
880
|
+
const routeModuleSet = new Set(routeModules);
|
|
716
881
|
for (const f of routeModules) allFiles.add(f);
|
|
717
882
|
for (const [k, vs] of moduleGraph) {
|
|
718
883
|
if (!appDir || k.startsWith(appDir)) allFiles.add(k);
|
|
@@ -740,11 +905,22 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
740
905
|
// them on the comment-masked source just additionally drops comment prose.)
|
|
741
906
|
const masked = maskComments(src);
|
|
742
907
|
fileTags.set(file, extractRenderedTags(masked));
|
|
743
|
-
|
|
744
|
-
if (
|
|
745
|
-
if (
|
|
746
|
-
|
|
747
|
-
|
|
908
|
+
const { redacted, literals } = redactToPlaceholders(src);
|
|
909
|
+
if (importsReactivePrimitive(redacted, literals)) reactiveFiles.add(file);
|
|
910
|
+
if (importsClientRouter(redacted, literals)) clientRouterFiles.add(file);
|
|
911
|
+
// A page/layout NEVER hydrates (#605), so its `html` TEMPLATE content is
|
|
912
|
+
// SSR output, not module client work: an inline `<script>`'s browser
|
|
913
|
+
// globals run from the rendered HTML, and a page-template `@event` is
|
|
914
|
+
// dropped at SSR. For a route module, scan those two template-borne signals
|
|
915
|
+
// on the template-redacted source so they do not pin the module, while a
|
|
916
|
+
// genuine module-scope `document.x` OUTSIDE any template still flags (#623).
|
|
917
|
+
// The import-based checks stay on `redacted`: a real `import 'pkg'` side
|
|
918
|
+
// effect DOES run when a page/layout module loads in the browser, and
|
|
919
|
+
// `importsSideEffectNonCorePackage` itself skips local `#`-alias imports.
|
|
920
|
+
const templateScan = routeModuleSet.has(file) ? redacted : masked;
|
|
921
|
+
if (EVENT_BINDING_RE.test(templateScan) || EVENT_PROP_RE.test(templateScan) ||
|
|
922
|
+
importsSideEffectNonCorePackage(redacted, appDir, literals) || CLIENT_GLOBAL_RE.test(templateScan) ||
|
|
923
|
+
hasModuleScopeSideEffect(redacted, literals)) {
|
|
748
924
|
clientGlobalOrBareFiles.add(file);
|
|
749
925
|
}
|
|
750
926
|
if (componentFiles.has(file) && analyzeComponentSource(masked).interactive) {
|
|
@@ -893,22 +1069,53 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
893
1069
|
clientRouterFiles.has(file) ||
|
|
894
1070
|
clientGlobalOrBareFiles.has(file);
|
|
895
1071
|
|
|
896
|
-
// Route modules
|
|
897
|
-
//
|
|
898
|
-
//
|
|
1072
|
+
// Route modules fall into three classes by their effective client closure
|
|
1073
|
+
// (skipping elided components and server stubs, which never load on the
|
|
1074
|
+
// client):
|
|
1075
|
+
// - INERT: neither the module nor its closure does any client work. Dropped
|
|
1076
|
+
// from the boot script entirely (#179).
|
|
1077
|
+
// - IMPORT-ONLY (#605): the module itself does no client work, and the ONLY
|
|
1078
|
+
// client work its closure reaches is SHIPPING COMPONENTS. Such a page /
|
|
1079
|
+
// layout module is just the import-graph carrier for its components; the
|
|
1080
|
+
// boot can emit those component modules directly and drop the module. The
|
|
1081
|
+
// condition is a positive subset test (every client-effecting closure
|
|
1082
|
+
// member is a component), NOT a hand-listed block list: any client-
|
|
1083
|
+
// effecting NON-component in the closure (a self-executing helper, a
|
|
1084
|
+
// client-router import, a reactive helper) keeps the whole module, since
|
|
1085
|
+
// dropping it would lose that side effect.
|
|
1086
|
+
// - SHIP: anything else (the module itself is client-effecting, or its
|
|
1087
|
+
// closure reaches a client-effecting non-component). Unchanged behaviour.
|
|
899
1088
|
/** @type {Set<string>} */
|
|
900
1089
|
const skip = new Set([...elidableComponents, ...serverFiles]);
|
|
901
1090
|
/** @type {Set<string>} */
|
|
902
1091
|
const inertRouteModules = new Set();
|
|
1092
|
+
/** @type {Map<string, string[]>} route file -> component files to emit in its place */
|
|
1093
|
+
const importOnlyRouteModules = new Map();
|
|
903
1094
|
for (const file of routeModules) {
|
|
904
|
-
if (!fileTags.has(file)) continue; // unreadable / not analysed: ship (omit from
|
|
905
|
-
if (isClientEffecting(file)) continue;
|
|
1095
|
+
if (!fileTags.has(file)) continue; // unreadable / not analysed: ship (omit from both sets)
|
|
1096
|
+
if (isClientEffecting(file)) continue; // the module itself ships whole
|
|
906
1097
|
const closure = appDir ? transitiveDeps(moduleGraph, [file], appDir, skip) : [];
|
|
907
|
-
|
|
908
|
-
inertRouteModules.add(file);
|
|
1098
|
+
const effecting = closure.filter(isClientEffecting);
|
|
1099
|
+
if (effecting.length === 0) { inertRouteModules.add(file); continue; }
|
|
1100
|
+
// Import-only iff EVERY client-effecting closure member is a (shipping)
|
|
1101
|
+
// component. isClientEffecting for a component already implies mustShip, so
|
|
1102
|
+
// `effecting` is exactly the shipping components reachable from this module:
|
|
1103
|
+
// re-emit that STATIC set (what loading the module would have registered, so
|
|
1104
|
+
// a component imported but only conditionally rendered still registers).
|
|
1105
|
+
//
|
|
1106
|
+
// A `static lazy` component is NOT special-cased here: it only appears in
|
|
1107
|
+
// this STATIC closure when the route statically imports it, and in that case
|
|
1108
|
+
// loading the module already eager-loaded it before elision, so emitting it
|
|
1109
|
+
// directly preserves that exact behaviour. A normally-used lazy component is
|
|
1110
|
+
// tag-referenced (never statically imported), so it is absent from this
|
|
1111
|
+
// closure and still loads via the IntersectionObserver `observeLazy` path.
|
|
1112
|
+
if (effecting.every((f) => componentFiles.has(f))) {
|
|
1113
|
+
importOnlyRouteModules.set(file, effecting);
|
|
1114
|
+
}
|
|
1115
|
+
// else: a client-effecting non-component is reachable; ship the whole module.
|
|
909
1116
|
}
|
|
910
1117
|
|
|
911
|
-
return { elidableComponents, inertRouteModules };
|
|
1118
|
+
return { elidableComponents, inertRouteModules, importOnlyRouteModules };
|
|
912
1119
|
}
|
|
913
1120
|
|
|
914
1121
|
/** Match a whole-line side-effect import: `import './x.js';` (no bindings). */
|
package/src/component-scanner.js
CHANGED
|
@@ -22,6 +22,7 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
22
22
|
import { sep } from 'node:path';
|
|
23
23
|
import { walk } from './fs-walk.js';
|
|
24
24
|
import { primeModuleUrl } from '@webjsdev/core';
|
|
25
|
+
import { redactToPlaceholders } from './js-scan.js';
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* mtime-keyed cache of extracted components per file, so a rebuild re-reads
|
|
@@ -50,22 +51,26 @@ export function _scanCacheHas(file) { return SCAN_CACHE.has(file); }
|
|
|
50
51
|
export function extractComponents(src) {
|
|
51
52
|
/** @type {Array<{ className: string, tag: string }>} */
|
|
52
53
|
const results = [];
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
const { redacted, literals } = redactToPlaceholders(src);
|
|
55
|
+
|
|
56
|
+
// Pattern A: Class.register('tag') -> matches Class.register('__STR_idx__')
|
|
57
|
+
const registerRe = /\b([A-Z][A-Za-z0-9_$]*)\.register\s*\(\s*['"`]__STR_(\d+)__['"`]\s*\)/g;
|
|
55
58
|
let m;
|
|
56
|
-
while ((m = registerRe.exec(
|
|
59
|
+
while ((m = registerRe.exec(redacted)) !== null) {
|
|
57
60
|
const className = m[1];
|
|
58
|
-
const
|
|
59
|
-
|
|
61
|
+
const idx = parseInt(m[2], 10);
|
|
62
|
+
const tag = literals[idx];
|
|
63
|
+
if (tag && tag.includes('-')) {
|
|
60
64
|
results.push({ className, tag });
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
|
-
// Pattern B: customElements.define('tag', Class)
|
|
64
|
-
const defineRe = /\bcustomElements\.define\s*\(\s*['"](
|
|
65
|
-
while ((m = defineRe.exec(
|
|
66
|
-
const
|
|
67
|
+
// Pattern B: customElements.define('tag', Class) -> matches customElements.define('__STR_idx__', Class)
|
|
68
|
+
const defineRe = /\bcustomElements\.define\s*\(\s*['"`]__STR_(\d+)__['"`]\s*,\s*([A-Z][A-Za-z0-9_$]*)\b/g;
|
|
69
|
+
while ((m = defineRe.exec(redacted)) !== null) {
|
|
70
|
+
const idx = parseInt(m[1], 10);
|
|
71
|
+
const tag = literals[idx];
|
|
67
72
|
const className = m[2];
|
|
68
|
-
if (tag.includes('-')) {
|
|
73
|
+
if (tag && tag.includes('-')) {
|
|
69
74
|
results.push({ className, tag });
|
|
70
75
|
}
|
|
71
76
|
}
|
package/src/dev.js
CHANGED
|
@@ -13,7 +13,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
13
13
|
|
|
14
14
|
import { buildRouteTable, matchPage, matchApi } from './router.js';
|
|
15
15
|
import { generateRouteTypes } from './route-types.js';
|
|
16
|
-
import { ssrPage, ssrNotFound } from './ssr.js';
|
|
16
|
+
import { ssrPage, ssrNotFound, setClientRouterEnabled } from './ssr.js';
|
|
17
17
|
import { loadPageAction, runPageAction } from './page-action.js';
|
|
18
18
|
import { handleApi } from './api.js';
|
|
19
19
|
import {
|
|
@@ -267,6 +267,28 @@ export async function readSeedEnabled(appDir) {
|
|
|
267
267
|
return true;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Read the client-router switch (`webjs.clientRouter`) from the app's
|
|
272
|
+
* package.json (#629). Default `true`: the client router auto-enables in the
|
|
273
|
+
* browser whenever `@webjsdev/core` loads (the automatic-navigation thesis).
|
|
274
|
+
* `{ "webjs": { "clientRouter": false } }` opts the WHOLE app out (pure MPA,
|
|
275
|
+
* full-page navigation), which the render path signals to the browser so the
|
|
276
|
+
* core bundle's module-end auto-enable is skipped. Any other value, or an
|
|
277
|
+
* absent key, keeps the router on. Re-read on every rebuild (alongside elide)
|
|
278
|
+
* so toggling it takes effect without a server restart.
|
|
279
|
+
* @param {string} appDir
|
|
280
|
+
* @returns {Promise<boolean>}
|
|
281
|
+
*/
|
|
282
|
+
export async function readClientRouterEnabled(appDir) {
|
|
283
|
+
try {
|
|
284
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
285
|
+
if (pkg && pkg.webjs && pkg.webjs.clientRouter === false) return false;
|
|
286
|
+
} catch {
|
|
287
|
+
// No package.json, malformed JSON, or unreadable. Keep the default.
|
|
288
|
+
}
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
270
292
|
/**
|
|
271
293
|
* Read the per-path response-header config (`webjs.headers`) from the
|
|
272
294
|
* app's package.json and compile it to URLPattern rules. A missing,
|
|
@@ -473,6 +495,12 @@ export async function createRequestHandler(opts) {
|
|
|
473
495
|
const basePathValue = await readBasePathFromApp(appDir);
|
|
474
496
|
await setBasePath(basePathValue);
|
|
475
497
|
|
|
498
|
+
// Client-router opt-out (#629): bind it eagerly at handler construction (the
|
|
499
|
+
// same timing as setBasePath above), so a handler's module-global is set
|
|
500
|
+
// before any render even if a request arrives before the first warm. The
|
|
501
|
+
// analysis pass re-reads it each rebuild so a dev toggle takes effect live.
|
|
502
|
+
setClientRouterEnabled(await readClientRouterEnabled(appDir));
|
|
503
|
+
|
|
476
504
|
const coreDir = locateCoreDir(appDir);
|
|
477
505
|
// Switch the importmap between dist/ bundles and src/ per-file
|
|
478
506
|
// URLs depending on whether the resolved @webjsdev/core install
|
|
@@ -638,6 +666,7 @@ export async function createRequestHandler(opts) {
|
|
|
638
666
|
moduleGraph: null,
|
|
639
667
|
elidableComponents: new Set(),
|
|
640
668
|
inertRouteModules: new Set(),
|
|
669
|
+
importOnlyRouteModules: new Map(),
|
|
641
670
|
browserBoundFiles: null,
|
|
642
671
|
// Transformed-source cache (stripped TS + applied elision). Per-handler,
|
|
643
672
|
// NOT module-global: the cached bytes bake in THIS handler's elision
|
|
@@ -742,12 +771,16 @@ export async function createRequestHandler(opts) {
|
|
|
742
771
|
t.actions = now() - m; m = now();
|
|
743
772
|
state.middleware = await loadMiddleware(appDir, dev, logger);
|
|
744
773
|
t.middleware = now() - m; m = now();
|
|
774
|
+
// Client-router opt-out (#629): re-read each pass so toggling
|
|
775
|
+
// `webjs.clientRouter` takes effect on rebuild without a restart.
|
|
776
|
+
setClientRouterEnabled(await readClientRouterEnabled(appDir));
|
|
745
777
|
const r = (await readElideEnabled(appDir))
|
|
746
778
|
? await analyzeElision(components, collectRouteModules(state.routeTable),
|
|
747
779
|
state.moduleGraph, (f) => readFile(f, 'utf8'), appDir)
|
|
748
|
-
: { elidableComponents: new Set(), inertRouteModules: new Set() };
|
|
780
|
+
: { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map() };
|
|
749
781
|
state.elidableComponents = r.elidableComponents;
|
|
750
782
|
state.inertRouteModules = r.inertRouteModules;
|
|
783
|
+
state.importOnlyRouteModules = r.importOnlyRouteModules;
|
|
751
784
|
// Fold the elision verdict into app-module content hashes (#243): an
|
|
752
785
|
// app module's served body is elision-transformed, so a verdict flip
|
|
753
786
|
// must bust its `?v` even when its source is byte-identical. A stable
|
|
@@ -764,6 +797,10 @@ export async function createRequestHandler(opts) {
|
|
|
764
797
|
const elidedPaths = [
|
|
765
798
|
...state.elidableComponents,
|
|
766
799
|
...state.inertRouteModules,
|
|
800
|
+
// An import-only module is dropped from the boot (replaced by its
|
|
801
|
+
// component imports), so a flip in / out of that class must bust
|
|
802
|
+
// the importer's `?v` too.
|
|
803
|
+
...state.importOnlyRouteModules.keys(),
|
|
767
804
|
].map(rel).sort();
|
|
768
805
|
setElisionFingerprint(elidedPaths.length ? elidedPaths.join('\n') : '');
|
|
769
806
|
}
|
|
@@ -871,7 +908,7 @@ export async function createRequestHandler(opts) {
|
|
|
871
908
|
if (vendorResolveInFlight) return vendorResolveInFlight;
|
|
872
909
|
vendorResolveInFlight = (async () => {
|
|
873
910
|
try {
|
|
874
|
-
const scan = () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules]));
|
|
911
|
+
const scan = () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules, ...state.importOnlyRouteModules.keys()]));
|
|
875
912
|
const v = await resolveVendorImports(appDir, scan);
|
|
876
913
|
let { imports, integrity } = v;
|
|
877
914
|
if (bootVendorPinned) {
|
|
@@ -1303,7 +1340,21 @@ export async function createRequestHandler(opts) {
|
|
|
1303
1340
|
if (matchPathname === null) return null;
|
|
1304
1341
|
const page = matchPage(state.routeTable, matchPathname);
|
|
1305
1342
|
if (!page) return null;
|
|
1306
|
-
|
|
1343
|
+
// Mirror ssr.js's boot assembly EXACTLY: drop inert route modules, and
|
|
1344
|
+
// splice an import-only module's component imports in place of the module
|
|
1345
|
+
// (#605). Otherwise the 103 Early Hints would preload the page / layout
|
|
1346
|
+
// modules the body no longer imports (a wasted hint) and miss the component
|
|
1347
|
+
// modules it actually fetches (a double-fetch on the real module).
|
|
1348
|
+
const inert = state.inertRouteModules;
|
|
1349
|
+
const importOnly = state.importOnlyRouteModules;
|
|
1350
|
+
const files = [];
|
|
1351
|
+
const seenFiles = new Set();
|
|
1352
|
+
for (const f of [page.route.file, ...page.route.layouts]) {
|
|
1353
|
+
if (inert && inert.has(f)) continue;
|
|
1354
|
+
const emit = importOnly && importOnly.get(f);
|
|
1355
|
+
for (const t of emit || [f]) if (!seenFiles.has(t)) { seenFiles.add(t); files.push(t); }
|
|
1356
|
+
}
|
|
1357
|
+
const moduleUrls = files.map((f) => {
|
|
1307
1358
|
let rel = f.startsWith(appDir) ? f.slice(appDir.length) : f;
|
|
1308
1359
|
const url = rel.split('\\').join('/').replace(/^\/?/, '/');
|
|
1309
1360
|
// Mirror ssr.js's emit (basePath THEN `?v`) so the 103 Early Hints preload
|
|
@@ -1880,6 +1931,7 @@ async function handleCore(req, ctx) {
|
|
|
1880
1931
|
serverFiles: state.actionIndex.fileToHash,
|
|
1881
1932
|
elidableComponents: state.elidableComponents,
|
|
1882
1933
|
inertRouteModules: state.inertRouteModules,
|
|
1934
|
+
importOnlyRouteModules: state.importOnlyRouteModules,
|
|
1883
1935
|
notFoundFile: state.routeTable.notFound,
|
|
1884
1936
|
// Server HTML cache (#241): a CSP-enabled page emits a fresh
|
|
1885
1937
|
// per-request nonce into its body, so its bytes vary per request and
|