@webjsdev/server 0.8.62 → 0.8.64
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 +32 -3
- package/package.json +2 -2
- package/src/browser-entries.js +64 -0
- package/src/check/helpers.js +365 -0
- package/src/check/rules-actions.js +174 -0
- package/src/check/rules-components.js +275 -0
- package/src/check/rules-imports.js +166 -0
- package/src/check/rules-registry.js +128 -0
- package/src/check/rules-routing.js +224 -0
- package/src/check/rules-typescript.js +177 -0
- package/src/check/rules.js +127 -0
- package/src/check/runner-support.js +405 -0
- package/src/check/runner.js +113 -0
- package/src/check.js +4 -1774
- package/src/component-elision.js +1 -1
- package/src/dev/config.js +338 -0
- package/src/dev/handler.js +1372 -0
- package/src/dev/helpers.js +542 -0
- package/src/dev/serve.js +840 -0
- package/src/dev/server.js +322 -0
- package/src/dev-classify.js +174 -0
- package/src/dev-reload-worker.js +133 -7
- package/src/dev-styles.js +97 -0
- package/src/dev.js +22 -3077
- package/src/form-dispatch.js +12 -3
- package/src/importmap.js +6 -3
- package/src/listener-core.js +16 -2
- package/src/module-graph.js +112 -7
- package/src/rate-limit.js +50 -5
- package/src/router.js +11 -1
- package/src/ssr/client-router-flag.js +42 -0
- package/src/ssr/document.js +361 -0
- package/src/ssr/env-shim.js +51 -0
- package/src/ssr/escape.js +29 -0
- package/src/ssr/head.js +857 -0
- package/src/ssr/preloads.js +195 -0
- package/src/ssr/render.js +1481 -0
- package/src/ssr/responses.js +201 -0
- package/src/ssr.js +28 -2445
- package/src/vendor/audit.js +400 -0
- package/src/vendor/integrity.js +245 -0
- package/src/vendor/jspm.js +348 -0
- package/src/vendor/manifest.js +96 -0
- package/src/vendor/pins.js +892 -0
- package/src/vendor/providers.js +35 -0
- package/src/vendor/resolver.js +225 -0
- package/src/vendor/scanner.js +132 -0
- package/src/vendor.js +29 -2371
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{
|
|
3
|
+
* rule: string,
|
|
4
|
+
* file: string,
|
|
5
|
+
* message: string,
|
|
6
|
+
* fix: string,
|
|
7
|
+
* }} Violation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{
|
|
12
|
+
* name: string,
|
|
13
|
+
* description: string,
|
|
14
|
+
* }} RuleDescriptor
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* All available rule names with descriptions. Useful for help text and
|
|
19
|
+
* documentation generators.
|
|
20
|
+
*
|
|
21
|
+
* @type {RuleDescriptor[]}
|
|
22
|
+
*/
|
|
23
|
+
export const RULES = [
|
|
24
|
+
{
|
|
25
|
+
name: 'components-have-register',
|
|
26
|
+
description:
|
|
27
|
+
'Component files that define a class extending WebComponent must register the class with ClassName.register(\'tag\') (or customElements.define). The server-side scanner derives the module URL from the file path.',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'no-server-env-in-components',
|
|
31
|
+
description:
|
|
32
|
+
'Component files (under components/ or modules/*/components/) must not read non-public environment variables. process.env.X is allowed when X starts with WEBJS_PUBLIC_ (exposed to the browser via the SSR shim) or equals NODE_ENV (also defined in the browser). Any other process.env read in a component would leak the server-side value into the SSR\'d HTML, then read as undefined after hydration. Read server-only env vars in a page function, server action, or middleware (which never reach the browser as source) and pass derived values to the component as attributes.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'tag-name-has-hyphen',
|
|
36
|
+
description:
|
|
37
|
+
'Static tag = \'...\' in component files must contain a hyphen (HTML custom element spec).',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'no-duplicate-tag',
|
|
41
|
+
description:
|
|
42
|
+
'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.',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'no-static-properties',
|
|
46
|
+
description:
|
|
47
|
+
'Reactive properties must be declared via the `extends WebComponent({ … })` factory, never a hand-written `static properties = { … }` field in the class body. The factory types each field for you (no `declare` needed) and the runtime throws on a direct `static properties`. Migrate `class X extends WebComponent { static properties = { count: { type: Number } } }` to `class X extends WebComponent({ count: Number })`; use the `prop()` helper for options (`prop(Number, { reflect: true })`) and set defaults in the constructor after super().',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'reactive-props-no-class-field',
|
|
51
|
+
description:
|
|
52
|
+
'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field declaration (`count = 0`, `count: number = 0`, `count!: number`, or `count?: number`) in the class body. Under modern class-field semantics (including `erasableSyntaxOnly: true`) every class-field declaration compiles to Object.defineProperty *after* super(), clobbering the framework\'s reactive accessor and silently breaking re-renders. Set the default by assigning in the constructor after super().',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'array-prop-uses-array-type',
|
|
56
|
+
description:
|
|
57
|
+
'An array-typed reactive property declared via the `extends WebComponent({ … })` factory must pass the `Array` runtime constructor, not `Object`: `count: prop<Tag[]>(Array)`, never `prop<Tag[]>(Object)`. The two share one converter (both JSON-encode the value), so the wrong one does not crash, but `Object` misstates the prop contract to the next reader and diverges from the documented built-in set (String/Number/Boolean/Object/Array). Fires only when the factory generic is itself an array type (`T[]`, `readonly T[]`, `Array<T>`, `ReadonlyArray<T>`) AND the constructor argument is `Object`; a bare `foo: Object` with no generic is never flagged. Fix: change the constructor to `Array`.',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'shell-in-non-root-layout',
|
|
61
|
+
description:
|
|
62
|
+
'Only the root layout (app/layout.{js,ts}) may write a <!doctype>/<html>/<head>/<body> shell to override default <html lang>, <body class>, etc. Non-root layouts (app/<segment>/layout.{js,ts}) and pages (app/**/page.{js,ts}) must not: the framework auto-emits the wrapper around the whole composition, so a nested shell ends up nested inside <body> where browsers drop it. Triggers on any of <!doctype>, <html, <head, <body in a non-root layout or page.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'erasable-typescript-only',
|
|
66
|
+
description:
|
|
67
|
+
'Apps must opt into TypeScript\'s `erasableSyntaxOnly: true` so the compiler rejects non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators with emitDecoratorMetadata, import = require) at edit time. webjs strips types via Node\'s built-in `module.stripTypeScriptTypes`, which only supports erasable TypeScript and produces byte-exact position preservation (no sourcemap overhead). Files using non-erasable syntax fail at strip time and the dev server returns a 500 pointing at the no-non-erasable-typescript rule; webjs is buildless end-to-end and has no bundler fallback. The rule checks the project\'s tsconfig.json and warns when `erasableSyntaxOnly` is missing or set to false. Set `compilerOptions.erasableSyntaxOnly: true` in tsconfig.json to comply.',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'use-server-needs-extension',
|
|
71
|
+
description:
|
|
72
|
+
'Files that declare the `\'use server\'` directive at the top must also have the `.server.{js,ts,mts,mjs}` extension. The two markers are complementary, not interchangeable: `.server.ts` is the path-level boundary that triggers source protection by the file router; `\'use server\'` is the semantic opt-in that registers exports as RPC-callable from client code. A `\'use server\'` directive without the extension is silently ignored: the file is served to the browser as plain source, exports are NOT registered as RPC, and code the developer expects to run on the server actually runs in the browser. Rename the file to add the `.server.` infix.',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'use-server-exports-callable',
|
|
76
|
+
description:
|
|
77
|
+
'A `.server.{js,ts}` file that declares the `\'use server\'` directive registers its function exports as RPC-callable server actions, but only its FUNCTION exports: `buildActionIndex` / the stub generator register an export only when `typeof export === \'function\'`, so a `\'use server\'` file that exports zero functions (or only a non-function `const` / a type / only verb config like `method` / `cache`) registers NOTHING and gives no signal. The developer believes they exposed an action; nothing did, and the failure only surfaces as a 404 / undefined at the first call site. This is the complement of `use-server-needs-extension` (the directive without the extension) and of `one-action-per-configured-file` (more than one action in a configured file); this rule catches the directive-present-but-nothing-callable case. The rule asserts only that the file exports at least one callable, NOT that the action returns a value (a server action may be a void side-effect or throw `redirect()` / `notFound()` and never return). Conservative: a re-export (`export ... from`, `export *`) or an `export const NAME = <identifier-or-call>` (a possible factory-produced function such as `export const get = cache(fetch)`) is treated as a possible callable and NOT flagged, so the rule fires only when every export is provably non-callable. Fix: export an `async function` action, or drop the `\'use server\'` directive if the file is a plain server-only utility.',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'no-non-erasable-typescript',
|
|
81
|
+
description:
|
|
82
|
+
'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.',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'no-browser-globals-in-render',
|
|
86
|
+
description:
|
|
87
|
+
'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).',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'no-shadowed-native-member',
|
|
91
|
+
description:
|
|
92
|
+
'Flags a WebComponent class method whose name collides with a native DOM mutation method that WebJs relies on and INSTRUMENTS on every light-DOM host for the slot API (#1021): append, prepend, before, after, replaceWith, replaceChildren, remove, appendChild, insertBefore, removeChild, replaceChild. A method of the same name is SHADOWED at runtime (the instrumented native method wins), so the component method silently never runs, while TypeScript stays green because a zero/one-arg override is assignable to the native signature. Found dogfooding the stream demo (#248): a component named its button handler `append()`, so clicking Append called the slot-append no-op instead of the handler. Rename the handler to a non-native name (appendRow / prependRow / removeItem). The `render` / lifecycle hooks are MEANT to be overridden and are not flagged; only the native DOM mutation members are.',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'no-server-import-in-browser-module',
|
|
96
|
+
description:
|
|
97
|
+
'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 module never loads client-side: a display-only page is elided, and a page whose only client relevance is importing shipping components is import-only (#605/#963), dropped from the boot in favour of its components. But the moment the page does its OWN client work (the client router, a reactive primitive, a module-scope browser-global access, a client-effecting non-component util) it ships whole, 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; an elided, inert, or import-only page 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 move the module\'s own client work into a component so the page is dropped again. Server-to-server imports (`.server.ts` importing `.server.ts`) and `middleware.ts` / `route.ts` (never shipped) are never flagged.',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'one-action-per-configured-file',
|
|
101
|
+
description:
|
|
102
|
+
'A `\'use server\'` action file that declares HTTP-verb config (any of `method` / `cache` / `tags` / `invalidates` / `validate`, #488) must export exactly ONE callable action function. The config is file-level (it applies to the action in the file), so a second exported function would silently inherit the same verb / cache, which is almost never intended and makes the contract ambiguous. Move the extra function to its own `.server.ts` file (the one-function-per-file convention), or, if it is a private helper, do not export it. Files with no verb config are unaffected.',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'form-action-not-a-get-action',
|
|
106
|
+
description:
|
|
107
|
+
'Flags `<form action=${someAction}>` bound to a `\'use server\'` action whose file declares `export const method = \'GET\'` (#488). A GET action is a READ: it is CSRF-exempt and rides its arguments in the url, while a form submission is a CSRF-checked POST carrying a body, so the two contracts contradict each other and the submission is answered with a 405 at runtime. The rule reads the imported action\'s own file, so it only fires when the binding really does resolve to a GET-declared action. Fix by dropping the `method` export (an action with no `method` is a POST, which is what a form wants) or by binding a different action; if the form really is a read, use a plain `<form method="get" action="/search">` with no bound action.',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'no-redirect-in-api-route',
|
|
111
|
+
description:
|
|
112
|
+
'API route handlers (`route.{js,ts}`) must NOT call `redirect()` from `@webjsdev/core`. That function throws a control-flow signal designed for the SSR page renderer; in a route handler it goes uncaught and produces a 500. Use `Response.redirect(url, 303)` for external redirects or return a 3xx Response directly. Page functions, layouts, and server actions may still use `redirect()` (caught by the SSR pipeline).',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'no-interpolation-in-raw-text-element',
|
|
116
|
+
description:
|
|
117
|
+
'Flags a template interpolation (`${...}`) placed as a child of a `<style>` or `<script>` element inside a COMPONENT `html` template. Raw-text elements are an SSR/client asymmetry trap: the server renderer emits the interpolated content, but the client renderer drops it (a raw-text hole is a `noop`, since the compile cache is keyed on the static strings), so the element renders correctly on the server and then wipes to empty on hydration. Scoped to components (files with a `WebComponent` class), which hydrate; pages and layouts render server-only and never hydrate, so a page interpolating a `css` result into a `<style>` is a legitimate pattern and is not flagged. In a component, author scoped CSS with `static styles` (shadow DOM) or a `css` template. Found dogfooding a tic-tac-toe app (#845): a `<style>${STYLE}</style>` painted at SSR then vanished on hydrate.',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'no-missing-local-import',
|
|
121
|
+
description:
|
|
122
|
+
'Flags a NAMED value import of a symbol that a resolvable app-internal module does not export (`import { todos } from \'#db/schema.server.ts\'` when the schema no longer exports `todos`). The binding is `undefined` at runtime and crashes on first use, yet the rest of `webjs check` (elision-based, it does not compile types) misses it, so a schema swap that orphans a gallery module can pass `check` while `typecheck` is red. This is the runtime-crash class `check` exists to catch, filling that gap so an agent running only `check` cannot ship a broken import. Deliberately conservative to never false-positive on a valid app: it only inspects app-internal specifiers (a relative `./` path or a `#` alias) that resolve to a file in the app, only NAMED value imports (a `type` import, a default import, a namespace import, and any bare / `node:` / npm-package specifier are skipped), and it treats a module as UNKNOWABLE (never flags imports from it) when its exports are not fully enumerable: a `export * from ...` star re-export, a destructuring `export const { a } = ...`, or a multi-declarator `export const a = .., b = ..`. It reads names from `export function/class/const/let/var/type/interface/enum` and from `export { a, b as c }` / `export { x } from ...` clauses (the alias after `as` is the exported name). A re-export barrel therefore resolves correctly, and a `\'use server\'` action file exports its function names normally. Found dogfooding a tic-tac-toe app: dropping the example `todos` table left a gallery module importing it, green under `check` but red under `typecheck`.',
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
/** Set of all known rule names for fast lookup. */
|
|
127
|
+
export const RULE_NAMES = new Set(RULES.map((r) => r.name));
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared machinery behind the check rules: the module-graph walk for the
|
|
3
|
+
* server-import rule, the git-ignore probe, and the small fs predicates.
|
|
4
|
+
*
|
|
5
|
+
* Its own module rather than private to runner.js, because the rules call it
|
|
6
|
+
* and runner.js calls the rules. Parked in runner.js it made that a cycle.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
9
|
+
import { join, relative, basename } from 'node:path';
|
|
10
|
+
import { walk } from '../fs-walk.js';
|
|
11
|
+
import { buildModuleGraph, transitiveDeps } from '../module-graph.js';
|
|
12
|
+
import { scanComponents } from '../component-scanner.js';
|
|
13
|
+
import { buildRouteTable } from '../router.js';
|
|
14
|
+
import { analyzeElision } from '../component-elision.js';
|
|
15
|
+
import {
|
|
16
|
+
hasUseServerDirective,
|
|
17
|
+
} from './helpers.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {import('./rules.js').Violation} Violation
|
|
21
|
+
* @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* BFS the module graph for the shortest import path from `from` to `to`,
|
|
26
|
+
* returning every hop `[from, ..., to]` so the `no-server-import-in-browser-module`
|
|
27
|
+
* message can print the FULL chain instead of an opaque `… ->` truncation (#804).
|
|
28
|
+
* Falls back to `[from, to]` if no path is found (defensive; the caller only
|
|
29
|
+
* calls this once `to` is known reachable).
|
|
30
|
+
*
|
|
31
|
+
* @param {Map<string, Set<string>>} graph
|
|
32
|
+
* @param {string} from
|
|
33
|
+
* @param {string} to
|
|
34
|
+
* @returns {string[]}
|
|
35
|
+
*/
|
|
36
|
+
export function findImportChain(graph, from, to) {
|
|
37
|
+
const prev = new Map();
|
|
38
|
+
const seen = new Set([from]);
|
|
39
|
+
const queue = [from];
|
|
40
|
+
while (queue.length) {
|
|
41
|
+
const cur = queue.shift();
|
|
42
|
+
const deps = graph.get(cur);
|
|
43
|
+
if (!deps) continue;
|
|
44
|
+
for (const dep of deps) {
|
|
45
|
+
if (seen.has(dep)) continue;
|
|
46
|
+
seen.add(dep);
|
|
47
|
+
prev.set(dep, cur);
|
|
48
|
+
if (dep === to) {
|
|
49
|
+
const path = [];
|
|
50
|
+
for (let n = to; n !== undefined; n = prev.get(n)) path.unshift(n);
|
|
51
|
+
return path;
|
|
52
|
+
}
|
|
53
|
+
queue.push(dep);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [from, to];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Implements `no-server-import-in-browser-module`. Factored into its own
|
|
61
|
+
* function (rather than an inline block) because it does the heavier
|
|
62
|
+
* whole-app analysis the other rules avoid: it builds the module graph,
|
|
63
|
+
* scans components, builds the route table, and runs the framework's own
|
|
64
|
+
* elision analysis so the rule's notion of "ships to the browser" is
|
|
65
|
+
* byte-for-byte the build's.
|
|
66
|
+
*
|
|
67
|
+
* A module is flagged when BOTH hold:
|
|
68
|
+
* 1. It SHIPS to the browser. For a component that means it is NOT in the
|
|
69
|
+
* elidable set; for a page / layout that means it is NOT in the inert
|
|
70
|
+
* route-module set. (Pages and layouts that do real client work are not
|
|
71
|
+
* inert and therefore ship.)
|
|
72
|
+
* 2. Its transitive import closure reaches a `.server.{ts,js}` module.
|
|
73
|
+
* `transitiveDeps` stops AT a server file (it is included but not walked
|
|
74
|
+
* into), so a server file pulled in only through another server file is
|
|
75
|
+
* not attributed to a browser module that never reaches it directly.
|
|
76
|
+
*
|
|
77
|
+
* Also flagged: error / loading / not-found modules. These ship to the browser
|
|
78
|
+
* too (the dev server's `computeBrowserBoundFiles` adds them unconditionally)
|
|
79
|
+
* and are never elided, so a server import reaching one of them is the same
|
|
80
|
+
* throw-at-load crash.
|
|
81
|
+
*
|
|
82
|
+
* Never flagged: a `.server.ts` importing another `.server.ts` (server-to-
|
|
83
|
+
* server, and `.server.*` modules are not components nor route modules), and
|
|
84
|
+
* `middleware.ts` / `route.ts` (server-only, never page/layout/component
|
|
85
|
+
* entries, so they are not in the candidate set to begin with).
|
|
86
|
+
*
|
|
87
|
+
* Scope note for dynamic imports: a string-literal `import('./widget.ts')` IS
|
|
88
|
+
* tracked by the module graph now (#751), but only as a GATE edge (so the
|
|
89
|
+
* lazily-imported module is servable); this rule's server-import detection
|
|
90
|
+
* still runs over STATIC edges only (`transitiveDeps`), so a dynamic
|
|
91
|
+
* `import('./x.server.ts')` of a no-`'use server'` utility is not flagged here.
|
|
92
|
+
* That is deliberate: the throw-at-load crash is deferred to call time (when
|
|
93
|
+
* the module is actually fetched), not module load, and a dynamic import is
|
|
94
|
+
* also not elided framework-wide. A computed `import(expr)` cannot be resolved
|
|
95
|
+
* statically at all; rather than a false-positive-prone check rule (a computed
|
|
96
|
+
* import of an npm specifier or an otherwise-reachable app module is perfectly
|
|
97
|
+
* valid, so it fails the check-is-correctness-only dividing line), the dev
|
|
98
|
+
* server surfaces it with a 404 hint when the target 404s (see dev.js #751).
|
|
99
|
+
*
|
|
100
|
+
* @param {string} appDir
|
|
101
|
+
* @param {Violation[]} violations appended to in place
|
|
102
|
+
*/
|
|
103
|
+
// --- Rule: no-server-import-in-browser-module ---
|
|
104
|
+
// A page / layout / component module that SHIPS to the browser must not
|
|
105
|
+
// transitively import a server-only `.server.{ts,js}` module. The browser
|
|
106
|
+
// gets a stub for the server file, so the import is harmless while the
|
|
107
|
+
// module never loads client-side: a display-only page is elided, and an
|
|
108
|
+
// import-only page (#605/#963) is dropped from the boot in favour of its
|
|
109
|
+
// components. But the moment the page does its OWN client work (the client
|
|
110
|
+
// router, a reactive primitive, module-scope code, a client-effecting util
|
|
111
|
+
// on a component-free path) it ships whole, must load in the browser,
|
|
112
|
+
// and drags the server import with it: the stub throws (or a server-only
|
|
113
|
+
// export like `auth` is missing) the instant the module loads. That crash
|
|
114
|
+
// only surfaces at runtime; typecheck and every other check pass.
|
|
115
|
+
//
|
|
116
|
+
// The rule reuses the BUILD'S elision verdict (analyzeElision) instead of
|
|
117
|
+
// re-deriving it, so it fires ONLY on modules that genuinely ship: a
|
|
118
|
+
// display-only page the framework elides is never flagged (that is the
|
|
119
|
+
// legitimate pattern). The motivating case (crisp dogfood): a page that does
|
|
120
|
+
// `await auth()` (import from `lib/auth.server.ts`) AND imports a component
|
|
121
|
+
// directly, so it is not elided and ships the server import.
|
|
122
|
+
export async function checkServerImportInBrowserModule(appDir, violations) {
|
|
123
|
+
// No `app/` directory means this is not a routable WebJs app (e.g. a bare
|
|
124
|
+
// component library, or a fixture with only `lib/`); nothing ships, so the
|
|
125
|
+
// rule has nothing to police. Skip rather than do the heavy analysis.
|
|
126
|
+
if (!(await pathExists(join(appDir, 'app')))) return;
|
|
127
|
+
|
|
128
|
+
let moduleGraph, components, routeTable;
|
|
129
|
+
try {
|
|
130
|
+
moduleGraph = await buildModuleGraph(appDir);
|
|
131
|
+
components = await scanComponents(appDir);
|
|
132
|
+
routeTable = await buildRouteTable(appDir);
|
|
133
|
+
} catch {
|
|
134
|
+
// A malformed app the analysis can't process is left to the other rules
|
|
135
|
+
// (and the dev server) to surface; this rule degrades to a no-op.
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Page + layout modules that the router treats as route modules, exactly the
|
|
140
|
+
// set the dev server feeds to analyzeElision (so the inert verdict matches).
|
|
141
|
+
/** @type {Set<string>} */
|
|
142
|
+
const routeModuleSet = new Set();
|
|
143
|
+
for (const page of routeTable.pages || []) {
|
|
144
|
+
if (page.file) routeModuleSet.add(page.file);
|
|
145
|
+
for (const f of page.layouts || []) routeModuleSet.add(f);
|
|
146
|
+
}
|
|
147
|
+
const routeModules = [...routeModuleSet];
|
|
148
|
+
|
|
149
|
+
// error / loading / not-found modules ALSO ship to the browser, but unlike
|
|
150
|
+
// pages + layouts they are never elided: the dev server's
|
|
151
|
+
// `computeBrowserBoundFiles` adds them to the browser-bound entry set
|
|
152
|
+
// unconditionally (only ELIDABLE-COMPONENT imports are ever stripped, and
|
|
153
|
+
// these modules have no component to strip). So a personalized 404 that does
|
|
154
|
+
// `await auth()` is a real throw-at-load crash the page+layout-only candidate
|
|
155
|
+
// set would miss. Collect them here and add them to the candidate set as
|
|
156
|
+
// always-shipping (no elision verdict to consult).
|
|
157
|
+
/** @type {Map<string, string>} abs file -> kind */
|
|
158
|
+
const alwaysShipRouteModules = new Map();
|
|
159
|
+
for (const page of routeTable.pages || []) {
|
|
160
|
+
for (const f of page.errors || []) alwaysShipRouteModules.set(f, 'error boundary');
|
|
161
|
+
for (const f of page.loadings || []) alwaysShipRouteModules.set(f, 'loading boundary');
|
|
162
|
+
// forbidden / unauthorized joined this set with #1298. They used to render
|
|
163
|
+
// with an empty boot set, so their modules never reached the browser and
|
|
164
|
+
// omitting them here was harmless. They now render inside their layout
|
|
165
|
+
// chain AND ship, so a `forbidden.ts` doing `import { auth } from
|
|
166
|
+
// '#lib/auth.server.ts'` (a natural thing on a 403: "signed in as X, but
|
|
167
|
+
// not permitted") is a real throw-at-load crash that takes every sibling
|
|
168
|
+
// registration in the same module script with it.
|
|
169
|
+
for (const f of page.forbiddens || []) alwaysShipRouteModules.set(f, 'forbidden boundary');
|
|
170
|
+
for (const f of page.unauthorizeds || []) alwaysShipRouteModules.set(f, 'unauthorized boundary');
|
|
171
|
+
}
|
|
172
|
+
if (routeTable.notFound) alwaysShipRouteModules.set(routeTable.notFound, 'not-found page');
|
|
173
|
+
if (routeTable.notFounds) {
|
|
174
|
+
for (const f of routeTable.notFounds.values()) {
|
|
175
|
+
alwaysShipRouteModules.set(f, 'not-found page');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// global-not-found ships for the same reason: a thrown notFound() with no
|
|
179
|
+
// nearer boundary renders it against the matched route, so it wraps and
|
|
180
|
+
// boots. global-ERROR is deliberately absent: it returns its own document
|
|
181
|
+
// verbatim with no importmap and no boot script, so nothing of it ever
|
|
182
|
+
// reaches the browser.
|
|
183
|
+
if (routeTable.globalNotFound) {
|
|
184
|
+
alwaysShipRouteModules.set(routeTable.globalNotFound, 'global-not-found page');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The elision flag mirrors `dev.js`: respect `webjs.elide === false` and the
|
|
188
|
+
// WEBJS_ELIDE override. When elision is OFF, the build ships EVERY component
|
|
189
|
+
// and route module, so the verdict is "nothing is elidable / inert" and the
|
|
190
|
+
// rule treats every candidate as shipping (which is correct: with elision
|
|
191
|
+
// off, a display-only page really does ship its server import too).
|
|
192
|
+
const elideEnabled = await readElideEnabledForCheck(appDir);
|
|
193
|
+
const { elidableComponents, inertRouteModules, importOnlyRouteModules } = elideEnabled
|
|
194
|
+
? await analyzeElision(components, routeModules, moduleGraph, (f) => readFile(f, 'utf8'), appDir)
|
|
195
|
+
: { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map() };
|
|
196
|
+
|
|
197
|
+
// Candidate browser-shipped modules: components that are NOT elided, plus
|
|
198
|
+
// route modules that are NOT inert and NOT import-only (an import-only
|
|
199
|
+
// module is dropped from the boot in favour of its component frontier,
|
|
200
|
+
// #605/#963, so its own imports never load in a browser and a bare
|
|
201
|
+
// server-only import in it is harmless). A `.server.*` file is never a
|
|
202
|
+
// component (the scanner skips it) nor a route module the browser loads, so
|
|
203
|
+
// it cannot enter this set; server-to-server imports are excluded by
|
|
204
|
+
// construction.
|
|
205
|
+
/** @type {Map<string, { kind: string }>} relFile is keyed by ABS path */
|
|
206
|
+
const candidates = new Map();
|
|
207
|
+
for (const c of components) {
|
|
208
|
+
if (!elidableComponents.has(c.file)) candidates.set(c.file, { kind: 'component' });
|
|
209
|
+
}
|
|
210
|
+
for (const file of routeModules) {
|
|
211
|
+
if (inertRouteModules.has(file) || importOnlyRouteModules.has(file)) continue;
|
|
212
|
+
const base = basename(file);
|
|
213
|
+
const kind = /^layout\./.test(base) ? 'layout' : 'page';
|
|
214
|
+
candidates.set(file, { kind });
|
|
215
|
+
}
|
|
216
|
+
// error / loading / not-found modules always ship (never elided), so they are
|
|
217
|
+
// candidates unconditionally. A page/layout entry already in `candidates`
|
|
218
|
+
// wins (it is the more specific kind); these only add files not already seen.
|
|
219
|
+
for (const [file, kind] of alwaysShipRouteModules) {
|
|
220
|
+
if (!candidates.has(file)) candidates.set(file, { kind });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Report at most once per module (a page importing two server modules is one
|
|
224
|
+
// finding, naming the first reached). Sorted for deterministic output.
|
|
225
|
+
for (const file of [...candidates.keys()].sort()) {
|
|
226
|
+
// `transitiveDeps` skips nothing here, so it includes (but does not walk
|
|
227
|
+
// into) any `.server.*` file reachable from this module. The module itself
|
|
228
|
+
// is not in the result. A direct OR indirect server import both surface,
|
|
229
|
+
// because the closure walks every non-server edge until it hits one.
|
|
230
|
+
const closure = transitiveDeps(moduleGraph, [file], appDir);
|
|
231
|
+
// Of the reachable server files, find one that is a genuine throw-at-load
|
|
232
|
+
// crash in the browser. TWO kinds of `.server.*` import are NOT crashes and
|
|
233
|
+
// must be skipped, or the rule false-positives on legitimate code:
|
|
234
|
+
// - A `'use server'` ACTION. The browser receives a working RPC stub
|
|
235
|
+
// whose exports POST to the server, so calling it from a shipping
|
|
236
|
+
// module is the intended pattern (the issue even lists it as a fix).
|
|
237
|
+
// Only a bare `.server.*` utility (no directive) gets the
|
|
238
|
+
// throw-at-module-load stub that crashes the page.
|
|
239
|
+
// - A PHANTOM edge to a file that does not exist on disk. The module
|
|
240
|
+
// graph keeps quoted-string CONTENT verbatim, so an `import` written
|
|
241
|
+
// inside a code-example string (the docs / website `<pre>` samples)
|
|
242
|
+
// resolves to a non-existent path. That import never runs, so it is
|
|
243
|
+
// not a crash; require the server file to actually exist.
|
|
244
|
+
let serverDep = null;
|
|
245
|
+
for (const d of closure) {
|
|
246
|
+
if (!/\.server\.m?[jt]s$/.test(d)) continue;
|
|
247
|
+
if (await isUseServerActionFile(d)) continue; // working RPC stub, not a crash
|
|
248
|
+
if (!(await pathExists(d))) continue; // phantom edge from a string sample
|
|
249
|
+
serverDep = d;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
if (!serverDep) continue;
|
|
253
|
+
|
|
254
|
+
const { kind } = candidates.get(file);
|
|
255
|
+
const relFile = relative(appDir, file);
|
|
256
|
+
const relServer = relative(appDir, serverDep);
|
|
257
|
+
// Name the import chain: if the server file is a DIRECT import of this
|
|
258
|
+
// module, the chain is just the two; otherwise show one intermediate hop
|
|
259
|
+
// so the diagnostic points at where the edge enters (the full path is
|
|
260
|
+
// recoverable from the graph, but one hop is enough to locate it).
|
|
261
|
+
const chainFiles = findImportChain(moduleGraph, file, serverDep);
|
|
262
|
+
const chain = chainFiles.map((f) => relative(appDir, f)).join(' -> ');
|
|
263
|
+
// If the edge into the server file comes from a types-shaped module, the
|
|
264
|
+
// idiomatic fix is to relocate that type to a browser-safe typedef (#804).
|
|
265
|
+
const importer = chainFiles.length >= 2 ? chainFiles[chainFiles.length - 2] : null;
|
|
266
|
+
const viaTypesModule = importer && /(^|\/)types(\.m?[jt]s$|\/)/.test(relative(appDir, importer));
|
|
267
|
+
|
|
268
|
+
// The "elides again" remedy only applies to a page / layout: since the
|
|
269
|
+
// path-aware import-only verdict (#963), a page importing a component to
|
|
270
|
+
// register is dropped from the boot, so a page/layout on this rule ships
|
|
271
|
+
// because of its OWN client work (or a client-effecting non-component in
|
|
272
|
+
// its closure); moving that work into a component makes it a dropped
|
|
273
|
+
// carrier again and the server import never loads. The error / loading /
|
|
274
|
+
// not-found boundaries always ship and are never elided, so offering
|
|
275
|
+
// them an "elides again" fix is wrong. Branch the fix text on kind.
|
|
276
|
+
const canElide = kind === 'page' || kind === 'layout';
|
|
277
|
+
const typesHint = viaTypesModule
|
|
278
|
+
? `The edge enters via a types-shaped module (${relative(appDir, importer)}); if it re-exports a runtime VALUE from a \`.server.{ts,js}\` file, relocate that to a browser-safe typedef (a plain \`interface\` / JSDoc, or an \`import type\` which the stripper erases) so the type is shared without pinning the module. `
|
|
279
|
+
: '';
|
|
280
|
+
const fixText = canElide
|
|
281
|
+
? `${typesHint}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) move this ${kind}'s own client work (the module-scope call, browser-global access, or client-effecting util import that pins it) into a component, so the ${kind} elides again as a dropped carrier and its server import never loads.`
|
|
282
|
+
: `${typesHint}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.`;
|
|
283
|
+
|
|
284
|
+
violations.push({
|
|
285
|
+
rule: 'no-server-import-in-browser-module',
|
|
286
|
+
file: relFile,
|
|
287
|
+
message:
|
|
288
|
+
`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.`,
|
|
289
|
+
fix: fixText,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Read whether component elision is enabled for `appDir`, mirroring
|
|
296
|
+
* `dev.js`'s `readElideEnabled` so the check's notion of "ships" matches the
|
|
297
|
+
* dev server's. Elision is ON unless `webjs.elide === false` in package.json or
|
|
298
|
+
* the `WEBJS_ELIDE` env var forces it off (`0` / `false` / `off` / `no`). A
|
|
299
|
+
* missing or malformed package.json keeps the default (on). Inlined rather
|
|
300
|
+
* than imported from `dev.js` so the check tool does not pull the whole dev
|
|
301
|
+
* server module just for this flag.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} appDir
|
|
304
|
+
* @returns {Promise<boolean>}
|
|
305
|
+
*/
|
|
306
|
+
/**
|
|
307
|
+
* True if `file` is a `'use server'` action: a `.server.{ts,js}` module that
|
|
308
|
+
* declares the `'use server'` directive. The dev server rewrites its browser
|
|
309
|
+
* import into a working RPC stub (exports POST to the server), so importing it
|
|
310
|
+
* from a shipping module is legitimate, NOT the throw-at-load crash the
|
|
311
|
+
* no-server-import-in-browser-module rule catches. A bare `.server.*` utility
|
|
312
|
+
* (no directive) instead gets a stub that throws when the module loads, which
|
|
313
|
+
* IS the crash. Returns false on any read failure (treat an unreadable server
|
|
314
|
+
* file as a potential crash, the conservative direction for this rule).
|
|
315
|
+
*
|
|
316
|
+
* @param {string} file absolute path to a `.server.*` file
|
|
317
|
+
* @returns {Promise<boolean>}
|
|
318
|
+
*/
|
|
319
|
+
export async function isUseServerActionFile(file) {
|
|
320
|
+
try {
|
|
321
|
+
const content = await readFile(file, 'utf8');
|
|
322
|
+
return hasUseServerDirective(content);
|
|
323
|
+
} catch {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function readElideEnabledForCheck(appDir) {
|
|
329
|
+
const raw = process.env.WEBJS_ELIDE;
|
|
330
|
+
if (raw != null) {
|
|
331
|
+
const v = raw.trim().toLowerCase();
|
|
332
|
+
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
|
|
333
|
+
if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
337
|
+
if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
|
|
338
|
+
} catch {
|
|
339
|
+
// No package.json, malformed JSON, or unreadable: keep the default.
|
|
340
|
+
}
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Async fs.exists shim. Returns true if the path exists at all (file
|
|
346
|
+
* or directory), false on ENOENT or any other stat failure.
|
|
347
|
+
*
|
|
348
|
+
* @param {string} p absolute path
|
|
349
|
+
* @returns {Promise<boolean>}
|
|
350
|
+
*/
|
|
351
|
+
export async function pathExists(p) {
|
|
352
|
+
try {
|
|
353
|
+
await stat(p);
|
|
354
|
+
return true;
|
|
355
|
+
} catch {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The subset of `rels` (appDir-relative paths) that git reports as ignored,
|
|
362
|
+
* via a single batched `git check-ignore --stdin`. Best-effort: returns an
|
|
363
|
+
* empty Set when the directory is not a git repo, git is absent, or the
|
|
364
|
+
* spawn fails, so a non-git project scans every file as before. Runs with
|
|
365
|
+
* `cwd: appDir` and the inherited GIT_* env stripped so cwd is the sole
|
|
366
|
+
* authority on which repo + .gitignore stack is consulted (a pre-commit
|
|
367
|
+
* hook from a linked worktree exports GIT_WORK_TREE, which would otherwise
|
|
368
|
+
* override cwd-based discovery; same reason the doctor vendor-gitignore check
|
|
369
|
+
* strips them).
|
|
370
|
+
* Works for an in-repo sub-package with no nested `.git` too: git walks up
|
|
371
|
+
* to the monorepo root and resolves the relative paths against cwd.
|
|
372
|
+
*
|
|
373
|
+
* @param {string} appDir absolute app directory
|
|
374
|
+
* @param {string[]} rels appDir-relative file paths
|
|
375
|
+
* @returns {Promise<Set<string>>}
|
|
376
|
+
*/
|
|
377
|
+
export async function gitIgnoredSet(appDir, rels) {
|
|
378
|
+
/** @type {Set<string>} */
|
|
379
|
+
const out = new Set();
|
|
380
|
+
if (!rels.length) return out;
|
|
381
|
+
try {
|
|
382
|
+
const { spawnSync } = await import('node:child_process');
|
|
383
|
+
const {
|
|
384
|
+
GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
|
|
385
|
+
...gitEnv
|
|
386
|
+
} = process.env;
|
|
387
|
+
// `git check-ignore --stdin` exits 0 when ≥1 path is ignored (those
|
|
388
|
+
// paths are echoed on stdout), 1 when none are ignored, >1 on error.
|
|
389
|
+
const res = spawnSync('git', ['check-ignore', '--stdin'], {
|
|
390
|
+
cwd: appDir,
|
|
391
|
+
input: rels.join('\n'),
|
|
392
|
+
encoding: 'utf8',
|
|
393
|
+
env: gitEnv,
|
|
394
|
+
});
|
|
395
|
+
if (res.status === 0 && typeof res.stdout === 'string') {
|
|
396
|
+
for (const line of res.stdout.split('\n')) {
|
|
397
|
+
const p = line.trim();
|
|
398
|
+
if (p) out.add(p);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
// git missing or spawn failure: scan everything (no filter).
|
|
403
|
+
}
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { relative } from 'node:path';
|
|
3
|
+
import { walk } from '../fs-walk.js';
|
|
4
|
+
import {
|
|
5
|
+
redactStringsAndTemplates,
|
|
6
|
+
} from '../js-scan.js';
|
|
7
|
+
import {
|
|
8
|
+
checkComponentsHaveRegister,
|
|
9
|
+
checkNoStaticProperties,
|
|
10
|
+
checkReactivePropsNoClassField,
|
|
11
|
+
checkArrayPropUsesArrayType,
|
|
12
|
+
checkNoBrowserGlobalsInRender,
|
|
13
|
+
checkNoShadowedNativeMember,
|
|
14
|
+
} from './rules-components.js';
|
|
15
|
+
import {
|
|
16
|
+
checkNoRedirectInApiRoute,
|
|
17
|
+
checkNoInterpolationInRawTextElement,
|
|
18
|
+
checkNoServerEnvInComponents,
|
|
19
|
+
checkShellInNonRootLayout,
|
|
20
|
+
} from './rules-routing.js';
|
|
21
|
+
import {
|
|
22
|
+
checkErasableTypescriptOnly,
|
|
23
|
+
checkNoNonErasableTypescript,
|
|
24
|
+
} from './rules-typescript.js';
|
|
25
|
+
import {
|
|
26
|
+
checkUseServerNeedsExtension,
|
|
27
|
+
checkUseServerExportsCallable,
|
|
28
|
+
checkOneActionPerConfiguredFile,
|
|
29
|
+
} from './rules-actions.js';
|
|
30
|
+
import {
|
|
31
|
+
checkTagNameHasHyphen,
|
|
32
|
+
checkNoDuplicateTag,
|
|
33
|
+
} from './rules-registry.js';
|
|
34
|
+
import { checkServerImportInBrowserModule } from './runner-support.js';
|
|
35
|
+
import {
|
|
36
|
+
checkNoMissingLocalImport,
|
|
37
|
+
checkFormActionNotAGetAction,
|
|
38
|
+
} from './rules-imports.js';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {import('./rules.js').Violation} Violation
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Scan a WebJs app directory and report convention violations.
|
|
46
|
+
*
|
|
47
|
+
* Every rule is a correctness check (a crash, a security leak, a reactive
|
|
48
|
+
* prop that silently stops re-rendering, or a build/type-strip failure), so
|
|
49
|
+
* they all run unconditionally. There is no
|
|
50
|
+
* per-project disabling: project conventions (layout, style, process) live in
|
|
51
|
+
* CONVENTIONS.md as guidance, not in this tool.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} appDir absolute path to the app root (the directory
|
|
54
|
+
* containing `app/`, `modules/`, `components/`, etc.)
|
|
55
|
+
* @returns {Promise<Violation[]>}
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```js
|
|
59
|
+
* import { checkConventions } from '@webjsdev/server';
|
|
60
|
+
* const violations = await checkConventions('/path/to/myapp');
|
|
61
|
+
* for (const v of violations) {
|
|
62
|
+
* console.warn(`[${v.rule}] ${v.file}: ${v.message}`);
|
|
63
|
+
* }
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export async function checkConventions(appDir) {
|
|
67
|
+
/** @type {Violation[]} */
|
|
68
|
+
const violations = [];
|
|
69
|
+
|
|
70
|
+
// Collect all JS/TS files in the app directory. Each entry carries
|
|
71
|
+
// both the raw `content` (for rules that need verbatim source: the
|
|
72
|
+
// `'use server'` directive detector, the `.gitignore` reader, etc.)
|
|
73
|
+
// and a `scan` view with comments, string contents, and
|
|
74
|
+
// template-literal bodies redacted to whitespace. Rules that
|
|
75
|
+
// pattern-match across raw source should consume `scan` so docs-
|
|
76
|
+
// page code-block examples and JSDoc samples don't trigger false
|
|
77
|
+
// positives.
|
|
78
|
+
/** @type {{ abs: string, rel: string, content: string, scan: string }[]} */
|
|
79
|
+
const files = [];
|
|
80
|
+
for await (const abs of walk(appDir, (p) => /\.m?[jt]sx?$/.test(p))) {
|
|
81
|
+
const rel = relative(appDir, abs);
|
|
82
|
+
let content;
|
|
83
|
+
try {
|
|
84
|
+
content = await readFile(abs, 'utf8');
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
files.push({ abs, rel, content, scan: redactStringsAndTemplates(content) });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
checkComponentsHaveRegister(files, violations);
|
|
92
|
+
checkNoStaticProperties(files, violations);
|
|
93
|
+
checkReactivePropsNoClassField(files, violations);
|
|
94
|
+
checkArrayPropUsesArrayType(files, violations);
|
|
95
|
+
checkNoBrowserGlobalsInRender(files, violations);
|
|
96
|
+
checkNoShadowedNativeMember(files, violations);
|
|
97
|
+
checkNoRedirectInApiRoute(files, violations);
|
|
98
|
+
checkNoInterpolationInRawTextElement(files, violations);
|
|
99
|
+
checkNoServerEnvInComponents(files, violations);
|
|
100
|
+
checkShellInNonRootLayout(files, violations);
|
|
101
|
+
await checkErasableTypescriptOnly(appDir, violations);
|
|
102
|
+
await checkNoNonErasableTypescript(appDir, violations);
|
|
103
|
+
checkUseServerNeedsExtension(files, violations);
|
|
104
|
+
checkUseServerExportsCallable(files, violations);
|
|
105
|
+
checkOneActionPerConfiguredFile(files, violations);
|
|
106
|
+
checkTagNameHasHyphen(files, violations);
|
|
107
|
+
await checkNoDuplicateTag(appDir, files, violations);
|
|
108
|
+
await checkServerImportInBrowserModule(appDir, violations);
|
|
109
|
+
checkNoMissingLocalImport(appDir, files, violations);
|
|
110
|
+
checkFormActionNotAGetAction(appDir, files, violations);
|
|
111
|
+
|
|
112
|
+
return violations;
|
|
113
|
+
}
|