@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
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({
@@ -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.
@@ -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;