@webjsdev/server 0.8.2 → 0.8.4
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 +12 -0
- package/src/component-elision.js +42 -0
- package/src/module-graph.js +26 -0
package/package.json
CHANGED
package/src/check.js
CHANGED
|
@@ -957,6 +957,17 @@ export async function checkConventions(appDir, opts) {
|
|
|
957
957
|
const hasGitignore = await pathExists(join(appDir, '.gitignore'));
|
|
958
958
|
if (hasGit && hasGitignore) {
|
|
959
959
|
const { spawnSync } = await import('node:child_process');
|
|
960
|
+
// Strip inherited git env vars so `cwd` is the sole authority on
|
|
961
|
+
// which repo `git check-ignore` consults. Git exports GIT_DIR /
|
|
962
|
+
// GIT_WORK_TREE / GIT_INDEX_FILE / GIT_PREFIX into hook processes
|
|
963
|
+
// (notably a pre-commit hook run from a linked worktree exports
|
|
964
|
+
// GIT_WORK_TREE), and those OVERRIDE cwd-based discovery, so
|
|
965
|
+
// without this the probe would consult the outer repo instead of
|
|
966
|
+
// `appDir`. See the gitignore-vendor-not-ignored regression test.
|
|
967
|
+
const {
|
|
968
|
+
GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
|
|
969
|
+
...gitEnv
|
|
970
|
+
} = process.env;
|
|
960
971
|
// Check two representative paths: the pin manifest AND a sample
|
|
961
972
|
// downloaded bundle. A `.gitignore` that allows the manifest
|
|
962
973
|
// but blocks bundles (e.g. `*.js` higher up) would still break
|
|
@@ -970,6 +981,7 @@ export async function checkConventions(appDir, opts) {
|
|
|
970
981
|
const result = spawnSync('git', ['check-ignore', '-q', probe], {
|
|
971
982
|
cwd: appDir,
|
|
972
983
|
stdio: 'pipe',
|
|
984
|
+
env: gitEnv,
|
|
973
985
|
});
|
|
974
986
|
if (result.status === 0) {
|
|
975
987
|
violations.push({
|
package/src/component-elision.js
CHANGED
|
@@ -132,6 +132,21 @@ const CLIENT_ROUTER_IMPORTS = ['navigate', 'enableClientRouter', 'disableClientR
|
|
|
132
132
|
|
|
133
133
|
/** Identifiers that only exist in a browser; their presence means client work. */
|
|
134
134
|
const CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|customElements|matchMedia|addEventListener)\b/;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Cross-module observation of a component's registration. A module that
|
|
138
|
+
* observes another component's tag forces that component to register on the
|
|
139
|
+
* client, so the observed component cannot be elided even when its own render
|
|
140
|
+
* is display-only (eliding it drops its `customElements.define`, after which
|
|
141
|
+
* the observation silently fails). These scan for the three statically visible
|
|
142
|
+
* forms; the captured group is the observed tag (or class) name.
|
|
143
|
+
* - `customElements.whenDefined('my-tag')` / `whenDefined("my-tag")`
|
|
144
|
+
* - a CSS `my-tag:defined { … }` selector
|
|
145
|
+
* - `x instanceof MyClass` (mapped back to a tag via the component's className)
|
|
146
|
+
*/
|
|
147
|
+
const WHEN_DEFINED_RE = /\bwhenDefined\s*\(\s*['"`]([a-z][a-z0-9]*-[a-z0-9-]*)['"`]/g;
|
|
148
|
+
const TAG_DEFINED_RE = /\b([a-z][a-z0-9]*-[a-z0-9-]*):defined\b/g;
|
|
149
|
+
const INSTANCEOF_RE = /\binstanceof\s+([A-Z][A-Za-z0-9_$]*)/g;
|
|
135
150
|
/** Same, for component source, minus `customElements` (the registration call
|
|
136
151
|
* `customElements.define(...)` legitimately uses it and must not force ship). */
|
|
137
152
|
const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|matchMedia|addEventListener)\b/;
|
|
@@ -598,9 +613,12 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
598
613
|
const componentFiles = new Set();
|
|
599
614
|
/** @type {Map<string, string>} */
|
|
600
615
|
const tagToFile = new Map();
|
|
616
|
+
/** @type {Map<string, string>} className -> file, for instanceof observation */
|
|
617
|
+
const classToFile = new Map();
|
|
601
618
|
for (const c of components) {
|
|
602
619
|
componentFiles.add(c.file);
|
|
603
620
|
tagToFile.set(c.tag, c.file);
|
|
621
|
+
if (c.className) classToFile.set(c.className, c.file);
|
|
604
622
|
}
|
|
605
623
|
|
|
606
624
|
/** @type {Set<string>} */
|
|
@@ -615,6 +633,9 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
615
633
|
const clientGlobalOrBareFiles = new Set();
|
|
616
634
|
/** @type {Set<string>} */
|
|
617
635
|
const serverFiles = new Set();
|
|
636
|
+
/** @type {Set<string>} component files forced to ship because some module
|
|
637
|
+
* observes their registration (whenDefined / :defined / instanceof). */
|
|
638
|
+
const observedComponentFiles = new Set();
|
|
618
639
|
|
|
619
640
|
/** @type {Set<string>} */
|
|
620
641
|
const allFiles = new Set(componentFiles);
|
|
@@ -646,8 +667,29 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
646
667
|
if (componentFiles.has(file) && analyzeComponentSource(src).interactive) {
|
|
647
668
|
mustShip.add(file);
|
|
648
669
|
}
|
|
670
|
+
// Cross-module registration observation (#169): if THIS module observes
|
|
671
|
+
// another component's tag, that component must register client-side, so
|
|
672
|
+
// it cannot be elided. Map each observed tag/class back to its component
|
|
673
|
+
// file. Resolution against tagToFile / classToFile happens after the loop
|
|
674
|
+
// (all components are known up front, but we collect here while we hold
|
|
675
|
+
// each source). Verdict-safe: only ever forces MORE components to ship.
|
|
676
|
+
for (const m of src.matchAll(WHEN_DEFINED_RE)) {
|
|
677
|
+
const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);
|
|
678
|
+
}
|
|
679
|
+
for (const m of src.matchAll(TAG_DEFINED_RE)) {
|
|
680
|
+
const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);
|
|
681
|
+
}
|
|
682
|
+
for (const m of src.matchAll(INSTANCEOF_RE)) {
|
|
683
|
+
const f = classToFile.get(m[1]); if (f) observedComponentFiles.add(f);
|
|
684
|
+
}
|
|
649
685
|
}
|
|
650
686
|
|
|
687
|
+
// Force every observed component to ship before the fixpoint runs, so the
|
|
688
|
+
// render/import rules propagate from it too. Dynamic tag strings and external
|
|
689
|
+
// (non graph-reachable) stylesheets remain an author-facing caveat, since
|
|
690
|
+
// static analysis cannot see them.
|
|
691
|
+
for (const f of observedComponentFiles) mustShip.add(f);
|
|
692
|
+
|
|
651
693
|
// Reverse import edges (who imports each file), built once from the graph.
|
|
652
694
|
// Drives both the closure-client-work reachability below and the fixpoint's
|
|
653
695
|
// import rule, each in O(N+E) rather than a per-component closure walk.
|
package/src/module-graph.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
15
15
|
import { existsSync } from 'node:fs';
|
|
16
16
|
import { join, resolve, dirname, extname, sep } from 'node:path';
|
|
17
|
+
import { redactStringsAndTemplates } from './js-scan.js';
|
|
17
18
|
|
|
18
19
|
/** @type {RegExp} match static `import … from '…'` and `import '…'` */
|
|
19
20
|
const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
@@ -104,6 +105,17 @@ export function transitiveDeps(graph, entryFiles, appDir, skip) {
|
|
|
104
105
|
if (dep.startsWith(appDir)) {
|
|
105
106
|
result.push(dep);
|
|
106
107
|
}
|
|
108
|
+
// Stop at server-file boundaries, exactly like reachableFromEntries
|
|
109
|
+
// (the authorization gate). The browser fetches a `.server.*` URL as
|
|
110
|
+
// an RPC or throw-at-load stub, never its source, so the server
|
|
111
|
+
// file's own imports are never fetched. Following them would emit
|
|
112
|
+
// modulepreload hints for server-only modules that the gate then
|
|
113
|
+
// 404s (a preload set wider than the servable set). The `.server.*`
|
|
114
|
+
// file itself stays in the result; the preload emitter filters it via
|
|
115
|
+
// the server-file index. A file imported through BOTH a server file
|
|
116
|
+
// and a real client path is still reached via the client path, so it
|
|
117
|
+
// is not wrongly dropped.
|
|
118
|
+
if (SERVER_FILE_RE.test(dep)) continue;
|
|
107
119
|
queue.push(dep);
|
|
108
120
|
}
|
|
109
121
|
}
|
|
@@ -247,9 +259,23 @@ async function parseFile(file, appDir, graph, seen) {
|
|
|
247
259
|
try { src = await readFile(file, 'utf8'); }
|
|
248
260
|
catch { return; }
|
|
249
261
|
|
|
262
|
+
// Mask of `src` with all string / template-literal / comment / regex
|
|
263
|
+
// CONTENT blanked to spaces (positions preserved). Used to reject an
|
|
264
|
+
// `import '…'` / `export … from '…'` that appears as TEXT inside a
|
|
265
|
+
// template literal (e.g. example code shown in a `<pre>` inside an
|
|
266
|
+
// `html\`\`` template, as the docs site does) rather than as a real
|
|
267
|
+
// statement. We still read the specifier from the RAW `src` (the
|
|
268
|
+
// specifier is itself a string, blanked in the mask), and only consult
|
|
269
|
+
// the mask to confirm the `import` / `export` KEYWORD survived
|
|
270
|
+
// redaction, i.e. sits in code position and not inside a literal.
|
|
271
|
+
const masked = redactStringsAndTemplates(src);
|
|
250
272
|
const deps = new Set();
|
|
251
273
|
for (const re of [IMPORT_RE, EXPORT_FROM_RE]) {
|
|
252
274
|
for (const m of src.matchAll(re)) {
|
|
275
|
+
// m.index is the keyword start (`\bimport` / `\bexport`). If that
|
|
276
|
+
// position is blanked in the mask, the match lives inside a literal
|
|
277
|
+
// and is not a real import edge.
|
|
278
|
+
if (masked[m.index] === ' ') continue;
|
|
253
279
|
const spec = m[1];
|
|
254
280
|
// Only resolve relative imports within the project.
|
|
255
281
|
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
|