@webjsdev/server 0.8.20 → 0.8.21

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/index.d.ts CHANGED
@@ -312,6 +312,11 @@ export declare function resolveVendorImports(
312
312
  export declare function clearVendorCache(): void;
313
313
  /** Read a package's installed version from `node_modules`, or null. */
314
314
  export declare function getPackageVersion(pkgName: string, appDir: string): string | null;
315
+ /** Read a package's installed `dependencies` + `peerDependencies` (hoist-aware), or null. */
316
+ export declare function getPackageManifest(
317
+ pkgName: string,
318
+ appDir: string,
319
+ ): { dependencies: Record<string, string>; peerDependencies: Record<string, string> } | null;
315
320
  /** Resolve a list of `pkg@version` installs to importmap entries via a provider. */
316
321
  export declare function jspmGenerate(installs: string[], provider?: string): Promise<Record<string, string>>;
317
322
  /** Pin every (or the named) bare imports to `.webjs/vendor/importmap.json`. */
@@ -340,6 +345,14 @@ export declare function updatePinned(appDir: string, opts?: { from?: string }):
340
345
  export declare function readPinFile(
341
346
  appDir: string,
342
347
  ): Promise<{ imports: Record<string, string>; integrity?: Record<string, string>; provider?: string } | null>;
348
+ /**
349
+ * Make the opt-in `webjs vendor pin` output committable. Heals an app's
350
+ * `.gitignore` when a `.webjs` exclusion would swallow the pins, and is a
351
+ * no-op when they are already committable. Called only from `vendor pin`.
352
+ */
353
+ export declare function ensureVendorCommittable(
354
+ appDir: string,
355
+ ): Promise<{ ignored: boolean; patched: boolean; gitignorePath: string | null }>;
343
356
  /** Serve a downloaded `/__webjs/vendor/*` bundle file (`--download` mode). */
344
357
  export declare function serveDownloadedBundle(
345
358
  appDir: string,
@@ -349,6 +362,50 @@ export declare function serveDownloadedBundle(
349
362
  export declare const SUPPORTED_PROVIDERS: Set<string>;
350
363
  /** Normalize / validate a provider name. */
351
364
  export declare function normalizeProvider(name: string): string;
365
+ /**
366
+ * Validate a produced importmap's pinned dependency graph for coherence
367
+ * (issue #450). For each resolved package, checks that the version pinned for
368
+ * every OTHER resolved package it depends on satisfies the declared range.
369
+ * Pure in `(imports, getManifest)`, so the same dep set yields the same verdict
370
+ * over a live importmap and a vendored one. Degrades to `unverified` when a
371
+ * manifest is unavailable.
372
+ */
373
+ export declare function checkImportmapCoherence(
374
+ imports: Record<string, string>,
375
+ opts: {
376
+ getManifest: (
377
+ pkg: string,
378
+ version: string,
379
+ ) =>
380
+ | { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> }
381
+ | null
382
+ | Promise<{ dependencies?: Record<string, string>; peerDependencies?: Record<string, string> } | null>;
383
+ },
384
+ ): Promise<{
385
+ conflicts: Array<{
386
+ pkg: string;
387
+ version: string;
388
+ dependsOn: string;
389
+ kind: 'dependency' | 'peerDependency';
390
+ requiredRange: string;
391
+ pinnedVersion: string;
392
+ }>;
393
+ unverified: Array<{ pkg: string; reason: string }>;
394
+ checked: number;
395
+ }>;
396
+ /** Extract `{ basePackage -> pinned version }` from an importmap's `imports` map. */
397
+ export declare function extractPinnedVersions(
398
+ imports: Record<string, string>,
399
+ ): Map<string, string>;
400
+ /**
401
+ * Does `version` satisfy the npm `range`? Pragmatic, dependency-free. Returns
402
+ * `null` for a range shape it cannot statically evaluate (the "could not
403
+ * verify" signal).
404
+ */
405
+ export declare function satisfiesSemverRange(
406
+ version: string,
407
+ range: string,
408
+ ): boolean | null;
352
409
 
353
410
  // ---------------------------------------------------------------------------
354
411
  // module-graph.js
@@ -701,6 +758,43 @@ export interface ProviderConfig {
701
758
  [key: string]: unknown;
702
759
  }
703
760
 
761
+ /**
762
+ * The augmentable session-user interface (#451), NextAuth/Auth.js style.
763
+ *
764
+ * It is EMPTY by default. An app declares the fields its `session`/`jwt`
765
+ * callbacks set (e.g. crisp sets `session.user.id = token.uid`) by augmenting
766
+ * it, which then types every `auth()` call globally with no cast and catches
767
+ * typos:
768
+ *
769
+ * ```ts
770
+ * declare module '@webjsdev/server' {
771
+ * interface AuthUser {
772
+ * id: string;
773
+ * username: string;
774
+ * }
775
+ * }
776
+ * ```
777
+ *
778
+ * Left un-augmented, `auth().user` falls back to `Record<string, unknown>` (see
779
+ * {@link ResolvedAuthUser}), so every pre-#451 app that reads `user.<field>`
780
+ * without declaring a shape keeps compiling unchanged. For per-instance typing
781
+ * instead of a global augmentation, parameterise the factory:
782
+ * `createAuth<MyUser>(...)`, whose `auth()` returns `{ user: MyUser }`.
783
+ */
784
+ export interface AuthUser {}
785
+
786
+ /**
787
+ * The resolved session-user shape used when `createAuth` is called WITHOUT an
788
+ * explicit type argument: the augmented {@link AuthUser} when an app declared
789
+ * one, else the open `Record<string, unknown>` (the pre-#451 default). The
790
+ * `keyof` probe is how the un-augmented empty interface degrades to the loose
791
+ * record so existing untyped code is unaffected, while an augmented interface
792
+ * gives precise, typo-catching fields.
793
+ */
794
+ export type ResolvedAuthUser = keyof AuthUser extends never
795
+ ? Record<string, unknown>
796
+ : AuthUser;
797
+
704
798
  /** Auth configuration for `createAuth`. */
705
799
  export interface AuthConfig {
706
800
  providers: ProviderConfig[];
@@ -711,9 +805,15 @@ export interface AuthConfig {
711
805
  pages?: { signIn?: string; signOut?: string; error?: string };
712
806
  }
713
807
 
714
- /** The auth system created by `createAuth`. */
715
- export interface AuthInstance {
716
- auth: (req?: Request) => Promise<{ user: Record<string, unknown> } | null>;
808
+ /**
809
+ * The auth system created by `createAuth`. `TUser` is the resolved session-user
810
+ * shape; it defaults to {@link ResolvedAuthUser} (the augmented {@link AuthUser}
811
+ * if an app declared one, else `Record<string, unknown>`) so a module
812
+ * augmentation flows to every `auth()` call, and can be overridden per instance
813
+ * via `createAuth<MyUser>(...)`.
814
+ */
815
+ export interface AuthInstance<TUser = ResolvedAuthUser> {
816
+ auth: (req?: Request) => Promise<{ user: TUser } | null>;
717
817
  signIn: (
718
818
  provider: string,
719
819
  data?: Record<string, unknown>,
@@ -723,8 +823,14 @@ export interface AuthInstance {
723
823
  handlers: { GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> };
724
824
  }
725
825
 
726
- /** Create the auth system. */
727
- export declare function createAuth(config: AuthConfig): AuthInstance;
826
+ /**
827
+ * Create the auth system. Parameterise with the session-user shape
828
+ * (`createAuth<MyUser>(...)`) for per-instance typing, or augment the
829
+ * {@link AuthUser} interface for global typing; both make `auth().user` typed
830
+ * with no cast. Defaults to the open {@link AuthUser}, so existing untyped code
831
+ * keeps compiling unchanged.
832
+ */
833
+ export declare function createAuth<TUser = ResolvedAuthUser>(config: AuthConfig): AuthInstance<TUser>;
728
834
  /** The credentials (email/password) provider. */
729
835
  export declare function Credentials(opts: Record<string, unknown>): ProviderConfig;
730
836
  /** The Google OAuth provider. */
package/index.js CHANGED
@@ -21,6 +21,7 @@ export {
21
21
  resolveVendorImports,
22
22
  clearVendorCache,
23
23
  getPackageVersion,
24
+ getPackageManifest,
24
25
  jspmGenerate,
25
26
  pinAll,
26
27
  unpinPackage,
@@ -30,9 +31,13 @@ export {
30
31
  hasVendorPin,
31
32
  updatePinned,
32
33
  readPinFile,
34
+ ensureVendorCommittable,
33
35
  serveDownloadedBundle,
34
36
  SUPPORTED_PROVIDERS,
35
37
  normalizeProvider,
38
+ checkImportmapCoherence,
39
+ extractPinnedVersions,
40
+ satisfiesSemverRange,
36
41
  } from './src/vendor.js';
37
42
  export { buildModuleGraph, transitiveDeps } from './src/module-graph.js';
38
43
  export { scanComponents, primeComponentRegistry, extractComponents, findOrphanComponents } from './src/component-scanner.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.20",
3
+ "version": "0.8.21",
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/auth.js CHANGED
@@ -192,6 +192,13 @@ export function GitHub(opts = {}) {
192
192
 
193
193
  /**
194
194
  * Create the auth system.
195
+ *
196
+ * The session-user shape is typed via the `@webjsdev/server` overlay: augment
197
+ * its `AuthUser` interface (NextAuth/Auth.js style) for global typing, or call
198
+ * `createAuth<MyUser>(...)` from TypeScript for per-instance typing. See #451
199
+ * and the auth recipe. This JSDoc keeps the loose runtime shape; the precise
200
+ * typing lives in `index.d.ts` (types-only, no runtime effect).
201
+ *
195
202
  * @param {AuthConfig} config
196
203
  * @returns {{
197
204
  * auth: (req?: Request) => Promise<{ user: Record<string,unknown> }|null>,
package/src/check.js CHANGED
@@ -6,6 +6,10 @@ import {
6
6
  extractWebComponentClassBodies,
7
7
  matchClosingBrace,
8
8
  } from './js-scan.js';
9
+ import { buildModuleGraph, transitiveDeps } from './module-graph.js';
10
+ import { scanComponents } from './component-scanner.js';
11
+ import { buildRouteTable } from './router.js';
12
+ import { analyzeElision } from './component-elision.js';
9
13
 
10
14
  /**
11
15
  * Convention validator for webjs apps.
@@ -94,14 +98,14 @@ export const RULES = [
94
98
  'Scans .ts / .mts source for the four non-erasable TypeScript constructs (enum declarations, namespace blocks with value statements, constructor parameter properties, and `import = require`) that the framework\'s type-stripper rejects at request time. Companion to `erasable-typescript-only`: that rule checks the tsconfig flag, this rule checks the actual source. Both run by default so the flag check catches violations early in the editor while the source scan catches violations even if the tsconfig flag is missing or the rule is bypassed. Skips node_modules, dist, build, .git, .next, and _private folders.',
95
99
  },
96
100
  {
97
- name: 'gitignore-vendor-not-ignored',
101
+ name: 'no-browser-globals-in-render',
98
102
  description:
99
- 'Verifies the `.gitignore` exception for `.webjs/vendor/` is structurally correct via `git check-ignore`. The intended pattern is `**/.webjs/*` (NOT `.webjs/`) plus `!**/.webjs/vendor/` plus `!**/.webjs/vendor/**`. The `**/` prefix matches `.webjs/` at any depth so a nested / monorepo app does not leak its generated `.webjs/routes.d.ts`; the older root-anchored `.webjs/*` also passes this rule (the probe is run from the app root). The common-looking pattern `.webjs/` excludes the directory itself, after which git cannot re-include children (gitignore semantics: a parent exclusion blocks child negations). Without this rule, an AI agent or human editor would silently break `webjs vendor pin` by simplifying the pattern; the failure is invisible until production. Rule fires when the working directory is a git repo and a `.gitignore` exists; skipped when neither is true.',
103
+ 'Flags genuinely browser-only APIs used in a WebComponent constructor, willUpdate, or render() method. The SSR pipeline instantiates the component, runs willUpdate plus controllers\' hostUpdate, reflects properties, and calls render() to produce HTML, on a server element shim that backs the attribute methods but has no real DOM. So a browser global (document, window, localStorage, sessionStorage, navigator, location, matchMedia, screen, history) or an unshimmed HTMLElement member on `this` (attachShadow, shadowRoot, classList, querySelector, querySelectorAll, getBoundingClientRect, focus, blur, scrollIntoView) touched there throws at SSR time (the isomorphic footgun). The attribute methods (getAttribute/setAttribute/hasAttribute/removeAttribute/toggleAttribute), the event methods (addEventListener/removeEventListener/dispatchEvent), and attachInternals are shim-backed and run server-side, so they are NOT flagged. The flagged APIs belong in connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor (or derive them in willUpdate) only from server-known inputs (attributes, props). Conservative: only the constructor, willUpdate, and render bodies are scanned, and only direct references, so helper indirection is not flagged (the runtime SSR error covers that case).',
100
104
  },
101
105
  {
102
- name: 'no-browser-globals-in-render',
106
+ name: 'no-server-import-in-browser-module',
103
107
  description:
104
- 'Flags genuinely browser-only APIs used in a WebComponent constructor, willUpdate, or render() method. The SSR pipeline instantiates the component, runs willUpdate plus controllers\' hostUpdate, reflects properties, and calls render() to produce HTML, on a server element shim that backs the attribute methods but has no real DOM. So a browser global (document, window, localStorage, sessionStorage, navigator, location, matchMedia, screen, history) or an unshimmed HTMLElement member on `this` (attachShadow, shadowRoot, classList, querySelector, querySelectorAll, getBoundingClientRect, focus, blur, scrollIntoView) touched there throws at SSR time (the isomorphic footgun). The attribute methods (getAttribute/setAttribute/hasAttribute/removeAttribute/toggleAttribute), the event methods (addEventListener/removeEventListener/dispatchEvent), and attachInternals are shim-backed and run server-side, so they are NOT flagged. The flagged APIs belong in connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor (or derive them in willUpdate) only from server-known inputs (attributes, props). Conservative: only the constructor, willUpdate, and render bodies are scanned, and only direct references, so helper indirection is not flagged (the runtime SSR error covers that case).',
108
+ 'A page / layout / component module that SHIPS to the browser (the build does NOT elide it) must not transitively import a server-only `.server.{ts,js}` module. The server module is replaced by a stub in the browser, so the import is fine while the page is display-only and elided (its server import is stripped), but the moment the page also does client work (it imports a component to register, enables the client router, uses a reactive primitive, …) it stops being elided and must load in the browser, dragging the server import with it. The stub then throws (or a server-only export like `auth` is missing) the instant the module loads, a runtime browser crash that `webjs typecheck` and the rest of `webjs check` miss. The rule reuses the build\'s own elision verdict, so it ONLY fires on modules that genuinely ship; a display-only page the framework elides is never flagged. The fix is to keep the server call off the browser-shipped module: gate the route in `middleware.ts`, call the server through a `\'use server\'` action, or register the component in a layout so the page elides again. Server-to-server imports (`.server.ts` importing `.server.ts`) and `middleware.ts` / `route.ts` (never shipped) are never flagged.',
105
109
  },
106
110
  {
107
111
  name: 'no-scaffold-placeholder',
@@ -812,74 +816,261 @@ export async function checkConventions(appDir) {
812
816
  }
813
817
  }
814
818
 
815
- // --- Rule: gitignore-vendor-not-ignored ---
816
- // The .gitignore pattern for .webjs/vendor/ is subtle: `.webjs/`
817
- // alone excludes the directory entirely and git can't re-include
818
- // children of an excluded parent. The correct pattern is `**/.webjs/*`
819
- // plus `!**/.webjs/vendor/` plus `!**/.webjs/vendor/**` (the `**/`
820
- // prefix ignores `.webjs/` at any depth so a nested app does not leak
821
- // its generated routes.d.ts; the older root-anchored `.webjs/*` also
822
- // passes, since this probe runs from the app root). AI agents and
823
- // human reviewers frequently "simplify" this back to `.webjs/`,
824
- // silently breaking `webjs vendor pin`.
819
+ // --- Rule: no-server-import-in-browser-module ---
820
+ // A page / layout / component module that SHIPS to the browser must not
821
+ // transitively import a server-only `.server.{ts,js}` module. The browser
822
+ // gets a stub for the server file, so the import is harmless while the page
823
+ // is display-only and the framework ELIDES it (its server import is stripped
824
+ // from the served source). But the moment the page also does client work
825
+ // (imports a component to register, enables the client router, uses a
826
+ // reactive primitive, …) it stops being elided, must load in the browser,
827
+ // and drags the server import with it: the stub throws (or a server-only
828
+ // export like `auth` is missing) the instant the module loads. That crash
829
+ // only surfaces at runtime; typecheck and every other check pass.
825
830
  //
826
- // This rule verifies the actual gitignore behavior by spawning
827
- // `git check-ignore` against a representative pin-file path. If
828
- // git reports the file as ignored, the pattern is broken.
829
- //
830
- // Skipped when the directory isn't a git repo or has no .gitignore
831
- // (the user hasn't opted into version control yet).
832
- {
833
- const hasGit = await pathExists(join(appDir, '.git'));
834
- const hasGitignore = await pathExists(join(appDir, '.gitignore'));
835
- if (hasGit && hasGitignore) {
836
- const { spawnSync } = await import('node:child_process');
837
- // Strip inherited git env vars so `cwd` is the sole authority on
838
- // which repo `git check-ignore` consults. Git exports GIT_DIR /
839
- // GIT_WORK_TREE / GIT_INDEX_FILE / GIT_PREFIX into hook processes
840
- // (notably a pre-commit hook run from a linked worktree exports
841
- // GIT_WORK_TREE), and those OVERRIDE cwd-based discovery, so
842
- // without this the probe would consult the outer repo instead of
843
- // `appDir`. See the gitignore-vendor-not-ignored regression test.
844
- const {
845
- GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
846
- ...gitEnv
847
- } = process.env;
848
- // Check two representative paths: the pin manifest AND a sample
849
- // downloaded bundle. A `.gitignore` that allows the manifest
850
- // but blocks bundles (e.g. `*.js` higher up) would still break
851
- // `webjs vendor pin --download`. `git check-ignore -q` exits 0
852
- // when ignored, 1 when not ignored.
853
- const probes = [
854
- '.webjs/vendor/importmap.json',
855
- '.webjs/vendor/sample-pkg@1.0.0.js',
856
- ];
857
- for (const probe of probes) {
858
- const result = spawnSync('git', ['check-ignore', '-q', probe], {
859
- cwd: appDir,
860
- stdio: 'pipe',
861
- env: gitEnv,
862
- });
863
- if (result.status === 0) {
864
- violations.push({
865
- rule: 'gitignore-vendor-not-ignored',
866
- file: '.gitignore',
867
- message:
868
- `${probe} is gitignored, but \`webjs vendor pin\` writes files under .webjs/vendor/ and they MUST be committed for production deploys to use the pin (instead of calling api.jspm.io on every cold start). The most common cause: a \`.webjs/\` line in .gitignore that excludes the parent directory before the \`!.webjs/vendor/\` exception can take effect (git semantics: a parent exclusion blocks child negations). A second possible cause is a broader rule (e.g. \`*.js\` at root) that hides bundle files added by \`webjs vendor pin --download\`.`,
869
- fix:
870
- 'Replace `.webjs/` in your .gitignore with this three-line pattern:\n' +
871
- ' **/.webjs/*\n' +
872
- ' !**/.webjs/vendor/\n' +
873
- ' !**/.webjs/vendor/**\n' +
874
- 'The `**/` prefix ignores `.webjs/` at any depth (so a nested / monorepo app does not leak its generated `.webjs/routes.d.ts`) while still re-including the committed vendor pin. ' +
875
- 'Verify with `git check-ignore -q .webjs/vendor/importmap.json` (exit 1 means correctly un-ignored).',
876
- });
877
- }
831
+ // The rule reuses the BUILD'S elision verdict (analyzeElision) instead of
832
+ // re-deriving it, so it fires ONLY on modules that genuinely ship: a
833
+ // display-only page the framework elides is never flagged (that is the
834
+ // legitimate pattern). The motivating case (crisp dogfood): a page that does
835
+ // `await auth()` (import from `lib/auth.server.ts`) AND imports a component
836
+ // directly, so it is not elided and ships the server import.
837
+ await checkServerImportInBrowserModule(appDir, violations);
838
+
839
+ return violations;
840
+ }
841
+
842
+ /**
843
+ * Implements `no-server-import-in-browser-module`. Factored into its own
844
+ * function (rather than an inline block) because it does the heavier
845
+ * whole-app analysis the other rules avoid: it builds the module graph,
846
+ * scans components, builds the route table, and runs the framework's own
847
+ * elision analysis so the rule's notion of "ships to the browser" is
848
+ * byte-for-byte the build's.
849
+ *
850
+ * A module is flagged when BOTH hold:
851
+ * 1. It SHIPS to the browser. For a component that means it is NOT in the
852
+ * elidable set; for a page / layout that means it is NOT in the inert
853
+ * route-module set. (Pages and layouts that do real client work are not
854
+ * inert and therefore ship.)
855
+ * 2. Its transitive import closure reaches a `.server.{ts,js}` module.
856
+ * `transitiveDeps` stops AT a server file (it is included but not walked
857
+ * into), so a server file pulled in only through another server file is
858
+ * not attributed to a browser module that never reaches it directly.
859
+ *
860
+ * Also flagged: error / loading / not-found modules. These ship to the browser
861
+ * too (the dev server's `computeBrowserBoundFiles` adds them unconditionally)
862
+ * and are never elided, so a server import reaching one of them is the same
863
+ * throw-at-load crash.
864
+ *
865
+ * Never flagged: a `.server.ts` importing another `.server.ts` (server-to-
866
+ * server, and `.server.*` modules are not components nor route modules), and
867
+ * `middleware.ts` / `route.ts` (server-only, never page/layout/component
868
+ * entries, so they are not in the candidate set to begin with).
869
+ *
870
+ * Known limitation: a DYNAMIC `import('./x.server.ts')` is invisible to this
871
+ * rule. The framework's import scanner (`IMPORT_RE` in module-graph.js) matches
872
+ * only static `import`/`export … from`, not the `import(` call form, so a
873
+ * dynamic server import is not a graph edge here. This is deliberate and
874
+ * consistent with the framework-wide graph: a dynamic import is also not elided
875
+ * framework-wide, and its crash is deferred to call time (when the chunk is
876
+ * fetched), not module load. Catching it would mean teaching the shared scanner
877
+ * about `import(`, which is out of scope for this rule.
878
+ *
879
+ * @param {string} appDir
880
+ * @param {Violation[]} violations appended to in place
881
+ */
882
+ async function checkServerImportInBrowserModule(appDir, violations) {
883
+ // No `app/` directory means this is not a routable webjs app (e.g. a bare
884
+ // component library, or a fixture with only `lib/`); nothing ships, so the
885
+ // rule has nothing to police. Skip rather than do the heavy analysis.
886
+ if (!(await pathExists(join(appDir, 'app')))) return;
887
+
888
+ let moduleGraph, components, routeTable;
889
+ try {
890
+ moduleGraph = await buildModuleGraph(appDir);
891
+ components = await scanComponents(appDir);
892
+ routeTable = await buildRouteTable(appDir);
893
+ } catch {
894
+ // A malformed app the analysis can't process is left to the other rules
895
+ // (and the dev server) to surface; this rule degrades to a no-op.
896
+ return;
897
+ }
898
+
899
+ // Page + layout modules that the router treats as route modules, exactly the
900
+ // set the dev server feeds to analyzeElision (so the inert verdict matches).
901
+ /** @type {Set<string>} */
902
+ const routeModuleSet = new Set();
903
+ for (const page of routeTable.pages || []) {
904
+ if (page.file) routeModuleSet.add(page.file);
905
+ for (const f of page.layouts || []) routeModuleSet.add(f);
906
+ }
907
+ const routeModules = [...routeModuleSet];
908
+
909
+ // error / loading / not-found modules ALSO ship to the browser, but unlike
910
+ // pages + layouts they are never elided: the dev server's
911
+ // `computeBrowserBoundFiles` adds them to the browser-bound entry set
912
+ // unconditionally (only ELIDABLE-COMPONENT imports are ever stripped, and
913
+ // these modules have no component to strip). So a personalized 404 that does
914
+ // `await auth()` is a real throw-at-load crash the page+layout-only candidate
915
+ // set would miss. Collect them here and add them to the candidate set as
916
+ // always-shipping (no elision verdict to consult).
917
+ /** @type {Map<string, string>} abs file -> kind */
918
+ const alwaysShipRouteModules = new Map();
919
+ for (const page of routeTable.pages || []) {
920
+ for (const f of page.errors || []) alwaysShipRouteModules.set(f, 'error boundary');
921
+ for (const f of page.loadings || []) alwaysShipRouteModules.set(f, 'loading boundary');
922
+ }
923
+ if (routeTable.notFound) alwaysShipRouteModules.set(routeTable.notFound, 'not-found page');
924
+ if (routeTable.notFounds) {
925
+ for (const f of routeTable.notFounds.values()) {
926
+ alwaysShipRouteModules.set(f, 'not-found page');
878
927
  }
928
+ }
929
+
930
+ // The elision flag mirrors `dev.js`: respect `webjs.elide === false` and the
931
+ // WEBJS_ELIDE override. When elision is OFF, the build ships EVERY component
932
+ // and route module, so the verdict is "nothing is elidable / inert" and the
933
+ // rule treats every candidate as shipping (which is correct: with elision
934
+ // off, a display-only page really does ship its server import too).
935
+ const elideEnabled = await readElideEnabledForCheck(appDir);
936
+ const { elidableComponents, inertRouteModules } = elideEnabled
937
+ ? await analyzeElision(components, routeModules, moduleGraph, (f) => readFile(f, 'utf8'), appDir)
938
+ : { elidableComponents: new Set(), inertRouteModules: new Set() };
939
+
940
+ // Candidate browser-shipped modules: components that are NOT elided, plus
941
+ // route modules that are NOT inert. A `.server.*` file is never a component
942
+ // (the scanner skips it) nor a route module the browser loads, so it cannot
943
+ // enter this set; server-to-server imports are excluded by construction.
944
+ /** @type {Map<string, { kind: string }>} relFile is keyed by ABS path */
945
+ const candidates = new Map();
946
+ for (const c of components) {
947
+ if (!elidableComponents.has(c.file)) candidates.set(c.file, { kind: 'component' });
948
+ }
949
+ for (const file of routeModules) {
950
+ if (inertRouteModules.has(file)) continue;
951
+ const base = basename(file);
952
+ const kind = /^layout\./.test(base) ? 'layout' : 'page';
953
+ candidates.set(file, { kind });
954
+ }
955
+ // error / loading / not-found modules always ship (never elided), so they are
956
+ // candidates unconditionally. A page/layout entry already in `candidates`
957
+ // wins (it is the more specific kind); these only add files not already seen.
958
+ for (const [file, kind] of alwaysShipRouteModules) {
959
+ if (!candidates.has(file)) candidates.set(file, { kind });
960
+ }
961
+
962
+ // Report at most once per module (a page importing two server modules is one
963
+ // finding, naming the first reached). Sorted for deterministic output.
964
+ for (const file of [...candidates.keys()].sort()) {
965
+ // `transitiveDeps` skips nothing here, so it includes (but does not walk
966
+ // into) any `.server.*` file reachable from this module. The module itself
967
+ // is not in the result. A direct OR indirect server import both surface,
968
+ // because the closure walks every non-server edge until it hits one.
969
+ const closure = transitiveDeps(moduleGraph, [file], appDir);
970
+ // Of the reachable server files, find one that is a genuine throw-at-load
971
+ // crash in the browser. TWO kinds of `.server.*` import are NOT crashes and
972
+ // must be skipped, or the rule false-positives on legitimate code:
973
+ // - A `'use server'` ACTION. The browser receives a working RPC stub
974
+ // whose exports POST to the server, so calling it from a shipping
975
+ // module is the intended pattern (the issue even lists it as a fix).
976
+ // Only a bare `.server.*` utility (no directive) gets the
977
+ // throw-at-module-load stub that crashes the page.
978
+ // - A PHANTOM edge to a file that does not exist on disk. The module
979
+ // graph keeps quoted-string CONTENT verbatim, so an `import` written
980
+ // inside a code-example string (the docs / website `<pre>` samples)
981
+ // resolves to a non-existent path. That import never runs, so it is
982
+ // not a crash; require the server file to actually exist.
983
+ let serverDep = null;
984
+ for (const d of closure) {
985
+ if (!/\.server\.m?[jt]s$/.test(d)) continue;
986
+ if (await isUseServerActionFile(d)) continue; // working RPC stub, not a crash
987
+ if (!(await pathExists(d))) continue; // phantom edge from a string sample
988
+ serverDep = d;
989
+ break;
879
990
  }
991
+ if (!serverDep) continue;
992
+
993
+ const { kind } = candidates.get(file);
994
+ const relFile = relative(appDir, file);
995
+ const relServer = relative(appDir, serverDep);
996
+ // Name the import chain: if the server file is a DIRECT import of this
997
+ // module, the chain is just the two; otherwise show one intermediate hop
998
+ // so the diagnostic points at where the edge enters (the full path is
999
+ // recoverable from the graph, but one hop is enough to locate it).
1000
+ const directDeps = moduleGraph.get(file);
1001
+ const directlyImported = directDeps && directDeps.has(serverDep);
1002
+ const chain = directlyImported
1003
+ ? `${relFile} -> ${relServer}`
1004
+ : `${relFile} -> … -> ${relServer}`;
1005
+
1006
+ // The "register the component in a layout so it elides again" remedy only
1007
+ // applies to a page / layout, which CAN elide (it became browser-bound by
1008
+ // importing a component). The error / loading / not-found boundaries always
1009
+ // ship and are never elided, so offering them an "elides again" fix is
1010
+ // wrong. Branch the fix text on whether the kind can elide.
1011
+ const canElide = kind === 'page' || kind === 'layout';
1012
+ const fixText = canElide
1013
+ ? `Keep the server call off this browser-shipped ${kind}. Options: (1) gate the route in \`middleware.ts\` (runs server-side, never ships); (2) move the server-only call behind a \`'use server'\` action in a \`.server.{ts,js}\` file and call it as an RPC; or (3) if this ${kind} only became browser-bound because it imports a component to register, register that component in a \`layout.{ts,js}\` instead so the ${kind} elides again and its server import is stripped.`
1014
+ : `Keep the server call off this browser-shipped ${kind} (it always ships and is never elided). Options: (1) gate the route in \`middleware.ts\` (runs server-side, never ships); or (2) move the server-only call behind a \`'use server'\` action in a \`.server.{ts,js}\` file and call it as an RPC.`;
1015
+
1016
+ violations.push({
1017
+ rule: 'no-server-import-in-browser-module',
1018
+ file: relFile,
1019
+ message:
1020
+ `This ${kind} ships to the browser (the build does not elide it) but transitively imports the server-only module ${relServer} (${chain}). In the browser that import resolves to a stub, so the module crashes at load (the stub throws, or a server-only export such as \`auth\` is missing). \`webjs typecheck\` and the rest of \`webjs check\` pass; only the running ${kind} fails.`,
1021
+ fix: fixText,
1022
+ });
880
1023
  }
1024
+ }
881
1025
 
882
- return violations;
1026
+ /**
1027
+ * Read whether component elision is enabled for `appDir`, mirroring
1028
+ * `dev.js`'s `readElideEnabled` so the check's notion of "ships" matches the
1029
+ * dev server's. Elision is ON unless `webjs.elide === false` in package.json or
1030
+ * the `WEBJS_ELIDE` env var forces it off (`0` / `false` / `off` / `no`). A
1031
+ * missing or malformed package.json keeps the default (on). Inlined rather
1032
+ * than imported from `dev.js` so the check tool does not pull the whole dev
1033
+ * server module just for this flag.
1034
+ *
1035
+ * @param {string} appDir
1036
+ * @returns {Promise<boolean>}
1037
+ */
1038
+ /**
1039
+ * True if `file` is a `'use server'` action: a `.server.{ts,js}` module that
1040
+ * declares the `'use server'` directive. The dev server rewrites its browser
1041
+ * import into a working RPC stub (exports POST to the server), so importing it
1042
+ * from a shipping module is legitimate, NOT the throw-at-load crash the
1043
+ * no-server-import-in-browser-module rule catches. A bare `.server.*` utility
1044
+ * (no directive) instead gets a stub that throws when the module loads, which
1045
+ * IS the crash. Returns false on any read failure (treat an unreadable server
1046
+ * file as a potential crash, the conservative direction for this rule).
1047
+ *
1048
+ * @param {string} file absolute path to a `.server.*` file
1049
+ * @returns {Promise<boolean>}
1050
+ */
1051
+ async function isUseServerActionFile(file) {
1052
+ try {
1053
+ const content = await readFile(file, 'utf8');
1054
+ return hasUseServerDirective(content);
1055
+ } catch {
1056
+ return false;
1057
+ }
1058
+ }
1059
+
1060
+ async function readElideEnabledForCheck(appDir) {
1061
+ const raw = process.env.WEBJS_ELIDE;
1062
+ if (raw != null) {
1063
+ const v = raw.trim().toLowerCase();
1064
+ if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
1065
+ if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
1066
+ }
1067
+ try {
1068
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
1069
+ if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
1070
+ } catch {
1071
+ // No package.json, malformed JSON, or unreadable: keep the default.
1072
+ }
1073
+ return true;
883
1074
  }
884
1075
 
885
1076
  /**
@@ -906,7 +1097,8 @@ async function pathExists(p) {
906
1097
  * `cwd: appDir` and the inherited GIT_* env stripped so cwd is the sole
907
1098
  * authority on which repo + .gitignore stack is consulted (a pre-commit
908
1099
  * hook from a linked worktree exports GIT_WORK_TREE, which would otherwise
909
- * override cwd-based discovery; same reason as gitignore-vendor-not-ignored).
1100
+ * override cwd-based discovery; same reason the doctor vendor-gitignore check
1101
+ * strips them).
910
1102
  * Works for an in-repo sub-package with no nested `.git` too: git walks up
911
1103
  * to the monorepo root and resolves the relative paths against cwd.
912
1104
  *
@@ -262,13 +262,22 @@ async function parseFile(file, appDir, graph, seen) {
262
262
  // Mask of `src` with all string / template-literal / comment / regex
263
263
  // CONTENT blanked to spaces (positions preserved). Used to reject an
264
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);
265
+ // literal (e.g. example code shown in a `<pre>` inside an `html\`\``
266
+ // template, as the docs site does, OR a code-example written as a plain
267
+ // quoted string) rather than as a real statement. We still read the
268
+ // specifier from the RAW `src` (the specifier is itself a string,
269
+ // blanked in the mask), and only consult the mask to confirm the
270
+ // `import` / `export` KEYWORD survived redaction, i.e. sits in code
271
+ // position and not inside a literal.
272
+ //
273
+ // `blankStrings: true` is load-bearing: the default mask keeps PLAIN
274
+ // string + verbatim-template bodies verbatim (so `register('tag')` stays
275
+ // readable for other scanners), which would leave an `import` written
276
+ // inside a plain string looking like a real keyword and create a phantom
277
+ // graph edge to whatever path that string names. Since this caller only
278
+ // checks keyword-in-code-position, blank every literal body so a
279
+ // code-example `import` string never becomes an edge.
280
+ const masked = redactStringsAndTemplates(src, true);
272
281
  const deps = new Set();
273
282
  for (const re of [IMPORT_RE, EXPORT_FROM_RE]) {
274
283
  for (const m of src.matchAll(re)) {