@webjsdev/server 0.8.19 → 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 +364 -65
- 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/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.
|
|
@@ -63,6 +67,11 @@ export const RULES = [
|
|
|
63
67
|
description:
|
|
64
68
|
'Static tag = \'...\' in component files must contain a hyphen (HTML custom element spec).',
|
|
65
69
|
},
|
|
70
|
+
{
|
|
71
|
+
name: 'no-duplicate-tag',
|
|
72
|
+
description:
|
|
73
|
+
'A custom-element tag name must be registered exactly once across the app. Two `Class.register(\'tag\')` / `customElements.define(\'tag\', …)` calls for the SAME tag resolve INCONSISTENTLY at runtime: SSR overwrites the registry (last registration wins) while the browser keeps the first native upgrade, so the rendered element and the webjs registry disagree. Rename one tag.',
|
|
74
|
+
},
|
|
66
75
|
{
|
|
67
76
|
name: 'reactive-props-use-declare',
|
|
68
77
|
description:
|
|
@@ -89,14 +98,14 @@ export const RULES = [
|
|
|
89
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.',
|
|
90
99
|
},
|
|
91
100
|
{
|
|
92
|
-
name: '
|
|
101
|
+
name: 'no-browser-globals-in-render',
|
|
93
102
|
description:
|
|
94
|
-
'
|
|
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).',
|
|
95
104
|
},
|
|
96
105
|
{
|
|
97
|
-
name: 'no-
|
|
106
|
+
name: 'no-server-import-in-browser-module',
|
|
98
107
|
description:
|
|
99
|
-
'
|
|
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.',
|
|
100
109
|
},
|
|
101
110
|
{
|
|
102
111
|
name: 'no-scaffold-placeholder',
|
|
@@ -751,76 +760,319 @@ export async function checkConventions(appDir) {
|
|
|
751
760
|
}
|
|
752
761
|
}
|
|
753
762
|
|
|
754
|
-
// --- Rule:
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
// `git check-ignore` against a representative pin-file path. If
|
|
767
|
-
// git reports the file as ignored, the pattern is broken.
|
|
768
|
-
//
|
|
769
|
-
// Skipped when the directory isn't a git repo or has no .gitignore
|
|
770
|
-
// (the user hasn't opted into version control yet).
|
|
763
|
+
// --- Rule: no-duplicate-tag ---
|
|
764
|
+
// Two registrations of the SAME tag string anywhere in the app resolve
|
|
765
|
+
// inconsistently at runtime (SSR last-wins, browser first-wins), so flag
|
|
766
|
+
// every colliding site naming the others. Scans EVERY source file, not just
|
|
767
|
+
// components/, because a duplicate is a runtime hazard regardless of where
|
|
768
|
+
// the register/define call lives (a page, a lib, a module can register a
|
|
769
|
+
// tag too); this keeps the rule in lockstep with the editor's 9004
|
|
770
|
+
// diagnostic, which is likewise project-wide. Reuses the same
|
|
771
|
+
// register/define extraction as tag-name-has-hyphen, over the redacted
|
|
772
|
+
// source so a tag in a docs-page tagged-template example does not count.
|
|
773
|
+
// Only hyphenated tags are considered (a non-hyphenated tag is already
|
|
774
|
+
// flagged by tag-name-has-hyphen / invariant 3), matching the 9004 filter.
|
|
771
775
|
{
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
];
|
|
796
|
-
for (const probe of probes) {
|
|
797
|
-
const result = spawnSync('git', ['check-ignore', '-q', probe], {
|
|
798
|
-
cwd: appDir,
|
|
799
|
-
stdio: 'pipe',
|
|
800
|
-
env: gitEnv,
|
|
801
|
-
});
|
|
802
|
-
if (result.status === 0) {
|
|
803
|
-
violations.push({
|
|
804
|
-
rule: 'gitignore-vendor-not-ignored',
|
|
805
|
-
file: '.gitignore',
|
|
806
|
-
message:
|
|
807
|
-
`${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\`.`,
|
|
808
|
-
fix:
|
|
809
|
-
'Replace `.webjs/` in your .gitignore with this three-line pattern:\n' +
|
|
810
|
-
' **/.webjs/*\n' +
|
|
811
|
-
' !**/.webjs/vendor/\n' +
|
|
812
|
-
' !**/.webjs/vendor/**\n' +
|
|
813
|
-
'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. ' +
|
|
814
|
-
'Verify with `git check-ignore -q .webjs/vendor/importmap.json` (exit 1 means correctly un-ignored).',
|
|
815
|
-
});
|
|
776
|
+
// Generated / gitignored files (e.g. a `webjs ui add`-regenerated
|
|
777
|
+
// `components/` dir) are not committed source the rule should police;
|
|
778
|
+
// counting them would flag a collision between a hand-written component
|
|
779
|
+
// and its generated copy. Skip anything git reports as ignored.
|
|
780
|
+
// Best-effort: a non-git project (or absent git) scans everything.
|
|
781
|
+
const ignored = await gitIgnoredSet(appDir, files.map((f) => f.rel));
|
|
782
|
+
/** @type {Map<string, string[]>} tag -> rel files that register it (with repeats) */
|
|
783
|
+
const tagSites = new Map();
|
|
784
|
+
const patterns = [
|
|
785
|
+
/\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*(['"`])([^'"`]+)\1/g,
|
|
786
|
+
/\bcustomElements\.define\s*\(\s*(['"`])([^'"`]+)\1/g,
|
|
787
|
+
];
|
|
788
|
+
for (const { scan, rel } of files) {
|
|
789
|
+
if (ignored.has(rel)) continue;
|
|
790
|
+
for (const re of patterns) {
|
|
791
|
+
let match;
|
|
792
|
+
while ((match = re.exec(scan)) !== null) {
|
|
793
|
+
const tagName = match[2];
|
|
794
|
+
if (!tagName.includes('-')) continue;
|
|
795
|
+
const arr = tagSites.get(tagName) || [];
|
|
796
|
+
arr.push(rel);
|
|
797
|
+
tagSites.set(tagName, arr);
|
|
798
|
+
}
|
|
816
799
|
}
|
|
817
800
|
}
|
|
801
|
+
for (const [tagName, sites] of tagSites) {
|
|
802
|
+
if (sites.length < 2) continue;
|
|
803
|
+
// Report once per DISTINCT file, naming the others.
|
|
804
|
+
for (const file of new Set(sites)) {
|
|
805
|
+
const others = [...new Set(sites)].filter((f) => f !== file);
|
|
806
|
+
const where = others.length
|
|
807
|
+
? `also registered in ${others.join(', ')}`
|
|
808
|
+
: 'registered more than once in this file';
|
|
809
|
+
violations.push({
|
|
810
|
+
rule: 'no-duplicate-tag',
|
|
811
|
+
file,
|
|
812
|
+
message: `Custom element tag "${tagName}" is registered more than once (${where}). A tag must be registered exactly once; the runtime resolves a duplicate inconsistently (SSR keeps the last registration, the browser keeps the first).`,
|
|
813
|
+
fix: `Rename one registration so each "${tagName}" is unique, e.g. "${tagName}-2".`,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
818
816
|
}
|
|
819
817
|
}
|
|
820
818
|
|
|
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.
|
|
830
|
+
//
|
|
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
|
+
|
|
821
839
|
return violations;
|
|
822
840
|
}
|
|
823
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');
|
|
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;
|
|
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
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
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;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
824
1076
|
/**
|
|
825
1077
|
* Async fs.exists shim. Returns true if the path exists at all (file
|
|
826
1078
|
* or directory), false on ENOENT or any other stat failure.
|
|
@@ -836,3 +1088,50 @@ async function pathExists(p) {
|
|
|
836
1088
|
return false;
|
|
837
1089
|
}
|
|
838
1090
|
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* The subset of `rels` (appDir-relative paths) that git reports as ignored,
|
|
1094
|
+
* via a single batched `git check-ignore --stdin`. Best-effort: returns an
|
|
1095
|
+
* empty Set when the directory is not a git repo, git is absent, or the
|
|
1096
|
+
* spawn fails, so a non-git project scans every file as before. Runs with
|
|
1097
|
+
* `cwd: appDir` and the inherited GIT_* env stripped so cwd is the sole
|
|
1098
|
+
* authority on which repo + .gitignore stack is consulted (a pre-commit
|
|
1099
|
+
* hook from a linked worktree exports GIT_WORK_TREE, which would otherwise
|
|
1100
|
+
* override cwd-based discovery; same reason the doctor vendor-gitignore check
|
|
1101
|
+
* strips them).
|
|
1102
|
+
* Works for an in-repo sub-package with no nested `.git` too: git walks up
|
|
1103
|
+
* to the monorepo root and resolves the relative paths against cwd.
|
|
1104
|
+
*
|
|
1105
|
+
* @param {string} appDir absolute app directory
|
|
1106
|
+
* @param {string[]} rels appDir-relative file paths
|
|
1107
|
+
* @returns {Promise<Set<string>>}
|
|
1108
|
+
*/
|
|
1109
|
+
async function gitIgnoredSet(appDir, rels) {
|
|
1110
|
+
/** @type {Set<string>} */
|
|
1111
|
+
const out = new Set();
|
|
1112
|
+
if (!rels.length) return out;
|
|
1113
|
+
try {
|
|
1114
|
+
const { spawnSync } = await import('node:child_process');
|
|
1115
|
+
const {
|
|
1116
|
+
GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
|
|
1117
|
+
...gitEnv
|
|
1118
|
+
} = process.env;
|
|
1119
|
+
// `git check-ignore --stdin` exits 0 when ≥1 path is ignored (those
|
|
1120
|
+
// paths are echoed on stdout), 1 when none are ignored, >1 on error.
|
|
1121
|
+
const res = spawnSync('git', ['check-ignore', '--stdin'], {
|
|
1122
|
+
cwd: appDir,
|
|
1123
|
+
input: rels.join('\n'),
|
|
1124
|
+
encoding: 'utf8',
|
|
1125
|
+
env: gitEnv,
|
|
1126
|
+
});
|
|
1127
|
+
if (res.status === 0 && typeof res.stdout === 'string') {
|
|
1128
|
+
for (const line of res.stdout.split('\n')) {
|
|
1129
|
+
const p = line.trim();
|
|
1130
|
+
if (p) out.add(p);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
} catch {
|
|
1134
|
+
// git missing or spawn failure: scan everything (no filter).
|
|
1135
|
+
}
|
|
1136
|
+
return out;
|
|
1137
|
+
}
|
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)) {
|
package/src/page-action.js
CHANGED
|
@@ -196,9 +196,11 @@ export async function runPageAction(route, params, url, loaded, req, ssrOpts) {
|
|
|
196
196
|
} catch (err) {
|
|
197
197
|
if (isRedirect(err)) {
|
|
198
198
|
const e = /** @type any */ (err);
|
|
199
|
-
// A thrown redirect from an action
|
|
200
|
-
//
|
|
201
|
-
//
|
|
199
|
+
// A thrown redirect from an action (a POST) defaults to 307 Temporary
|
|
200
|
+
// Redirect, which is method-preserving so the action's intent survives
|
|
201
|
+
// the bounce; an explicit `redirect(url, status)` overrides it. This is
|
|
202
|
+
// deliberately NOT the GET gate's 302 default (see ssr.js). PRG (303) is
|
|
203
|
+
// the SUCCESS-result path below.
|
|
202
204
|
return new Response(null, { status: e.status || 307, headers: { location: e.url } });
|
|
203
205
|
}
|
|
204
206
|
if (isNotFound(err)) {
|
package/src/ssr.js
CHANGED
|
@@ -198,7 +198,12 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
198
198
|
} catch (err) {
|
|
199
199
|
if (isRedirect(err)) {
|
|
200
200
|
const e = /** @type any */ (err);
|
|
201
|
-
|
|
201
|
+
// A redirect thrown during a GET page/layout render is a GET-to-GET
|
|
202
|
+
// navigation (an auth bounce, a gate). 302 Found is the conventional
|
|
203
|
+
// code there, so it is the default when the caller did not pick one. An
|
|
204
|
+
// explicit `redirect(url, status)` overrides it. (Action redirects, a
|
|
205
|
+
// POST, default to 307 in page-action.js so the method is preserved.)
|
|
206
|
+
return new Response(null, { status: e.status || 302, headers: { location: e.url } });
|
|
202
207
|
}
|
|
203
208
|
if (isNotFound(err)) {
|
|
204
209
|
const html = await ssrNotFoundHtml(null, opts);
|