@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 +111 -5
- package/index.js +5 -0
- package/package.json +1 -1
- package/src/auth.js +7 -0
- package/src/check.js +260 -68
- package/src/module-graph.js +16 -7
- package/src/page-action.js +5 -3
- package/src/ssr.js +6 -1
- package/src/vendor.js +839 -96
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
|
-
/**
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
/**
|
|
727
|
-
|
|
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
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: '
|
|
101
|
+
name: 'no-browser-globals-in-render',
|
|
98
102
|
description:
|
|
99
|
-
'
|
|
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-
|
|
106
|
+
name: 'no-server-import-in-browser-module',
|
|
103
107
|
description:
|
|
104
|
-
'
|
|
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:
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
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
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
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
|
|
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
|
*
|
package/src/module-graph.js
CHANGED
|
@@ -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
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
// specifier
|
|
269
|
-
// the mask
|
|
270
|
-
// redaction, i.e. sits in code
|
|
271
|
-
|
|
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)) {
|