@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,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-action rules: where `'use server'` is honoured, what its exports must
|
|
3
|
+
* look like, and what a configured action file may contain.
|
|
4
|
+
*
|
|
5
|
+
* Lifted verbatim out of checkConventions, which held all twenty rules inline
|
|
6
|
+
* in one 900-line function. Each rule keeps its original comment header, its
|
|
7
|
+
* logic, and its position in the run order.
|
|
8
|
+
*/
|
|
9
|
+
import { join, basename } from 'node:path';
|
|
10
|
+
import { RESERVED_CONFIG } from '../action-config.js';
|
|
11
|
+
import {
|
|
12
|
+
hasUseServerDirective,
|
|
13
|
+
} from './helpers.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {import('./rules.js').Violation} Violation
|
|
17
|
+
* @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Rule: `use-server-needs-extension`.
|
|
23
|
+
*
|
|
24
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
25
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
26
|
+
* @returns {void}
|
|
27
|
+
*/
|
|
28
|
+
export function checkUseServerNeedsExtension(files, violations) {
|
|
29
|
+
// --- Rule: use-server-needs-extension ---
|
|
30
|
+
// Catch files that declare `'use server'` at the top but lack the
|
|
31
|
+
// `.server.{js,ts}` extension. Under the two-marker convention the
|
|
32
|
+
// directive alone does nothing (the file is served to the browser as
|
|
33
|
+
// plain source and exports are not registered as RPC), which is a
|
|
34
|
+
// silent footgun. The fix is mechanical: rename the file.
|
|
35
|
+
{
|
|
36
|
+
for (const { rel, content } of files) {
|
|
37
|
+
if (!hasUseServerDirective(content)) continue;
|
|
38
|
+
if (/\.server\.m?[jt]s$/.test(rel)) continue; // OK: has both markers
|
|
39
|
+
const fileBase = basename(rel);
|
|
40
|
+
const renamedBase = fileBase.replace(/\.(m?[jt]sx?)$/, '.server.$1');
|
|
41
|
+
violations.push({
|
|
42
|
+
rule: 'use-server-needs-extension',
|
|
43
|
+
file: rel,
|
|
44
|
+
message:
|
|
45
|
+
"File declares `'use server'` but its name does not match `.server.{js,ts,mts,mjs}`. The directive is silently ignored: the file is served to the browser as plain source and its exports are not RPC-callable. Code the developer expects to run on the server actually runs in the browser.",
|
|
46
|
+
fix: `Rename to ${renamedBase} (add the .server. infix before the extension)`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Rule: `use-server-exports-callable`.
|
|
54
|
+
*
|
|
55
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
56
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
57
|
+
* @returns {void}
|
|
58
|
+
*/
|
|
59
|
+
export function checkUseServerExportsCallable(files, violations) {
|
|
60
|
+
// --- Rule: use-server-exports-callable (#464) ---
|
|
61
|
+
// A `.server.{js,ts}` file with the `'use server'` directive registers only
|
|
62
|
+
// its FUNCTION exports as RPC actions (the registrar checks `typeof === 'function'`).
|
|
63
|
+
// A file that declares the directive but exports no callable registers nothing,
|
|
64
|
+
// silently: the developer thinks they exposed an action, and the only signal is
|
|
65
|
+
// a 404 / undefined at the first call site. Flag it. The complement of
|
|
66
|
+
// use-server-needs-extension (the directive without the extension) and of
|
|
67
|
+
// one-action-per-configured-file (more than one action).
|
|
68
|
+
{
|
|
69
|
+
for (const { rel, content, scan } of files) {
|
|
70
|
+
// Only properly-marked action files (the extension boundary). A `'use
|
|
71
|
+
// server'` file WITHOUT the .server. extension is the use-server-needs-
|
|
72
|
+
// extension rule's job; do not double-flag it here.
|
|
73
|
+
if (!/\.server\.m?[jt]s$/.test(rel)) continue;
|
|
74
|
+
if (!hasUseServerDirective(content)) continue;
|
|
75
|
+
// Count function-shaped EXPORTED callables, the SAME way the action
|
|
76
|
+
// registrar sees them: function declarations + arrow / function-expression
|
|
77
|
+
// consts (with an optional `: Type` annotation, #495). Reserved verb-config
|
|
78
|
+
// names (method/cache/...) are config, never a callable action.
|
|
79
|
+
const names = new Set();
|
|
80
|
+
let m;
|
|
81
|
+
const reFn = /\bexport\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
|
|
82
|
+
while ((m = reFn.exec(scan))) names.add(m[1]);
|
|
83
|
+
const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=(?!>)\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
|
|
84
|
+
while ((m = reArrow.exec(scan))) names.add(m[1]);
|
|
85
|
+
const callables = [...names].filter((n) => !RESERVED_CONFIG.has(n));
|
|
86
|
+
if (callables.length > 0) continue; // exports at least one callable -> fine
|
|
87
|
+
// A default export is assumed callable (an action commonly default-exports).
|
|
88
|
+
if (/\bexport\s+default\b/.test(scan)) continue;
|
|
89
|
+
// Conservative, avoid a FALSE POSITIVE: the runtime registrar
|
|
90
|
+
// (`actionFunctionNames`) keeps EVERY export whose value is a function at
|
|
91
|
+
// load time, regardless of the export syntax, so any export shape these
|
|
92
|
+
// patterns cannot prove is non-callable must NOT be flagged. Skip when the
|
|
93
|
+
// file has:
|
|
94
|
+
// - a named-export clause `export { a, b as c }` (with or WITHOUT `from`):
|
|
95
|
+
// it can surface a local function (`function getX(){}; export { getX }`)
|
|
96
|
+
// or a re-exported / imported function, neither matched above;
|
|
97
|
+
// - a star re-export `export * from ...`;
|
|
98
|
+
// - a destructuring export `export const { x } = obj` / `export const [x] = arr`,
|
|
99
|
+
// which may bind a function;
|
|
100
|
+
// - an `export const NAME = <identifier-or-call>`, a factory-produced
|
|
101
|
+
// function (`export const get = cache(fetch)`).
|
|
102
|
+
// A sole `export { aConst }` of a non-function is then a tolerated FALSE
|
|
103
|
+
// NEGATIVE, which is the right bias for a non-overridable correctness rule.
|
|
104
|
+
if (/\bexport\s*\{/.test(scan)) continue;
|
|
105
|
+
if (/\bexport\s*\*/.test(scan)) continue;
|
|
106
|
+
if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(scan)) continue;
|
|
107
|
+
// A factory / identifier-valued const could be a callable ACTION, so skip,
|
|
108
|
+
// BUT only when its name is NOT a reserved config key: the registrar
|
|
109
|
+
// excludes reserved names from actions (`actionFunctionNames`), so a file
|
|
110
|
+
// whose only ambiguous const is `validate` / `tags` / ... (config produced
|
|
111
|
+
// by an arrow or a factory) still has zero actions and must flag. Skip only
|
|
112
|
+
// for a NON-reserved ambiguous const (a real possible action).
|
|
113
|
+
const reFactory = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=\s*[A-Za-z_$(]/g;
|
|
114
|
+
let ambiguousAction = false;
|
|
115
|
+
while ((m = reFactory.exec(scan))) { if (!RESERVED_CONFIG.has(m[1])) { ambiguousAction = true; break; } }
|
|
116
|
+
if (ambiguousAction) continue;
|
|
117
|
+
// Every export is provably non-callable (a literal const, a type) or there
|
|
118
|
+
// are none: the directive exposes nothing.
|
|
119
|
+
violations.push({
|
|
120
|
+
rule: 'use-server-exports-callable',
|
|
121
|
+
file: rel,
|
|
122
|
+
message:
|
|
123
|
+
"File declares `'use server'` but exports no callable action. The `'use server'` directive registers FUNCTION exports as RPC-callable; a file exporting only a non-function (a `const` / a type / only verb config) registers nothing, so a client import resolves to nothing and the call 404s.",
|
|
124
|
+
fix: "Export an `async function` action from this file, or remove the `'use server'` directive if it is a plain server-only utility.",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Rule: `one-action-per-configured-file`.
|
|
132
|
+
*
|
|
133
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
134
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
135
|
+
* @returns {void}
|
|
136
|
+
*/
|
|
137
|
+
export function checkOneActionPerConfiguredFile(files, violations) {
|
|
138
|
+
// --- Rule: one-action-per-configured-file (#488) ---
|
|
139
|
+
// A `'use server'` file that declares HTTP-verb config (method/cache/tags/
|
|
140
|
+
// invalidates/validate) must export exactly one callable action; the config
|
|
141
|
+
// is file-level, so a second exported function would silently inherit it.
|
|
142
|
+
{
|
|
143
|
+
for (const { rel, content, scan } of files) {
|
|
144
|
+
if (!/\.server\.m?[jt]s$/.test(rel)) continue;
|
|
145
|
+
if (!hasUseServerDirective(content)) continue;
|
|
146
|
+
if (!/\bexport\s+const\s+(?:method|cache|tags|invalidates|validate|middleware)\b/.test(scan)) continue;
|
|
147
|
+
const names = new Set();
|
|
148
|
+
let m;
|
|
149
|
+
const reFn = /\bexport\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
|
|
150
|
+
while ((m = reFn.exec(scan))) names.add(m[1]);
|
|
151
|
+
// An arrow-const action: `export const x = (...) => ...`, the paren-less
|
|
152
|
+
// `export const x = id => ...`, or a function expression. The `=>` /
|
|
153
|
+
// `function` anchor keeps a plain `export const N = 5` from counting.
|
|
154
|
+
// An OPTIONAL `: Type` annotation may sit between the name and the `=`
|
|
155
|
+
// (#495); the type itself can contain a function-type `=>`, so the
|
|
156
|
+
// annotation matcher consumes any non-`=` char OR a literal `=>`, and the
|
|
157
|
+
// assignment is the first `=` NOT followed by `>` (`=(?!>)`). The
|
|
158
|
+
// alternation is unambiguous at each position (a `=` can only start `=>`),
|
|
159
|
+
// so there is no catastrophic backtracking.
|
|
160
|
+
const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=(?!>)\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
|
|
161
|
+
while ((m = reArrow.exec(scan))) names.add(m[1]);
|
|
162
|
+
if (/\bexport\s+default\b/.test(scan)) names.add('default');
|
|
163
|
+
const actions = [...names].filter((n) => !RESERVED_CONFIG.has(n));
|
|
164
|
+
if (actions.length > 1) {
|
|
165
|
+
violations.push({
|
|
166
|
+
rule: 'one-action-per-configured-file',
|
|
167
|
+
file: rel,
|
|
168
|
+
message: `Configured action file exports ${actions.length} callable functions (${actions.join(', ')}); the verb/cache config is file-level, so only one action per file is allowed.`,
|
|
169
|
+
fix: 'Move the extra function to its own .server.{js,ts} file, or keep it private (do not export it).',
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component-authoring rules: registration, reactive-property declaration, and
|
|
3
|
+
* the SSR-safety of a component body.
|
|
4
|
+
*
|
|
5
|
+
* Lifted verbatim out of checkConventions, which held all twenty rules inline
|
|
6
|
+
* in one 900-line function. Each rule keeps its original comment header, its
|
|
7
|
+
* logic, and its position in the run order.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
extractWebComponentClassBodies, matchClosingBrace, parsePropEntries,
|
|
11
|
+
} from '../js-scan.js';
|
|
12
|
+
import {
|
|
13
|
+
isComponentFile, arrayPropUsesObject, findFieldInitializers, methodBodyOf,
|
|
14
|
+
findBrowserMemberUses,
|
|
15
|
+
} from './helpers.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {import('./rules.js').Violation} Violation
|
|
19
|
+
* @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Rule: `components-have-register`.
|
|
25
|
+
*
|
|
26
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
27
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
28
|
+
* @returns {void}
|
|
29
|
+
*/
|
|
30
|
+
export function checkComponentsHaveRegister(files, violations) {
|
|
31
|
+
// --- Rule: components-have-register ---
|
|
32
|
+
{
|
|
33
|
+
for (const { rel, scan } of files) {
|
|
34
|
+
if (!isComponentFile(rel)) continue;
|
|
35
|
+
// Use redacted source so a code-example string like
|
|
36
|
+
// `Foo.register('bar')` inside a tagged template literal does
|
|
37
|
+
// not falsely satisfy the rule for a sibling unregistered
|
|
38
|
+
// class. Real register() calls live at top level where the
|
|
39
|
+
// redactor leaves them alone.
|
|
40
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
41
|
+
// Accept either registration pattern:
|
|
42
|
+
// Counter.register('tag') (WebJs idiom)
|
|
43
|
+
// customElements.define('tag', Counter) (native)
|
|
44
|
+
if (/\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*['"`]/.test(scan)) continue;
|
|
45
|
+
if (/\bcustomElements\.define\s*\(/.test(scan)) continue;
|
|
46
|
+
violations.push({
|
|
47
|
+
rule: 'components-have-register',
|
|
48
|
+
file: rel,
|
|
49
|
+
message: "Component extends WebComponent but is never registered. Call ClassName.register('tag-name') at the bottom of the file.",
|
|
50
|
+
fix: "Add `ClassName.register('tag-name')` after the class definition",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Rule: `no-static-properties`.
|
|
58
|
+
*
|
|
59
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
60
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
61
|
+
* @returns {void}
|
|
62
|
+
*/
|
|
63
|
+
export function checkNoStaticProperties(files, violations) {
|
|
64
|
+
// --- Rule: no-static-properties ---
|
|
65
|
+
// A hand-written `static properties = { … }` in a WebComponent class body is
|
|
66
|
+
// no longer supported: reactive properties are declared via the
|
|
67
|
+
// `extends WebComponent({ … })` factory (the runtime throws on a direct
|
|
68
|
+
// `static properties`). Flag it statically so the editor catches it before
|
|
69
|
+
// the page 500s.
|
|
70
|
+
{
|
|
71
|
+
for (const { rel, scan } of files) {
|
|
72
|
+
// Use redacted source so fixture-style strings like
|
|
73
|
+
// `class X extends WebComponent { static properties = {…} }` inside
|
|
74
|
+
// template literals don't trip the rule. Real declarations live at
|
|
75
|
+
// top-level code where the redactor leaves them alone.
|
|
76
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
77
|
+
for (const { body } of extractWebComponentClassBodies(scan)) {
|
|
78
|
+
if (!/static\s+properties\s*=\s*\{/.test(body)) continue;
|
|
79
|
+
violations.push({
|
|
80
|
+
rule: 'no-static-properties',
|
|
81
|
+
file: rel,
|
|
82
|
+
message:
|
|
83
|
+
'`static properties = { … }` is no longer supported; declare reactive properties via the `extends WebComponent({ … })` factory instead.',
|
|
84
|
+
fix: 'Move the properties into the factory call: `class X extends WebComponent({ count: Number })`. Use `prop(Number, { reflect: true })` for options and set defaults in the constructor after super(). Delete the `static properties` block and any `declare` fields for those props.',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Rule: `reactive-props-no-class-field`.
|
|
93
|
+
*
|
|
94
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
95
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
96
|
+
* @returns {void}
|
|
97
|
+
*/
|
|
98
|
+
export function checkReactivePropsNoClassField(files, violations) {
|
|
99
|
+
// --- Rule: reactive-props-no-class-field ---
|
|
100
|
+
// A reactive property declared via the factory must not also carry a plain
|
|
101
|
+
// class-field declaration (initializer OR type-only): under modern class-field
|
|
102
|
+
// semantics (including `erasableSyntaxOnly: true`) every class-field declaration
|
|
103
|
+
// compiles to Object.defineProperty after super() and clobbers the framework's
|
|
104
|
+
// reactive accessor (silent broken re-renders). Catches `count = 0`,
|
|
105
|
+
// `count: number = 0`, `count!: number`, and `count?: number`.
|
|
106
|
+
{
|
|
107
|
+
for (const { rel, scan } of files) {
|
|
108
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
109
|
+
for (const { body, factoryProps } of extractWebComponentClassBodies(scan)) {
|
|
110
|
+
if (factoryProps.size === 0) continue;
|
|
111
|
+
for (const bad of findFieldInitializers(body, factoryProps)) {
|
|
112
|
+
violations.push({
|
|
113
|
+
rule: 'reactive-props-no-class-field',
|
|
114
|
+
file: rel,
|
|
115
|
+
message: `Reactive prop \`${bad}\` uses a class-field declaration (initializer or type-only); this clobbers the framework's reactive accessor under modern class-field semantics.`,
|
|
116
|
+
fix: `Delete the class-field declaration and set the default by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Rule: `array-prop-uses-array-type`.
|
|
126
|
+
*
|
|
127
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
128
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
129
|
+
* @returns {void}
|
|
130
|
+
*/
|
|
131
|
+
export function checkArrayPropUsesArrayType(files, violations) {
|
|
132
|
+
// --- Rule: array-prop-uses-array-type ---
|
|
133
|
+
// An array-typed reactive prop declared via the factory should pass the
|
|
134
|
+
// `Array` runtime constructor, not `Object`. Both share one converter
|
|
135
|
+
// (JSON encode/decode), so `Object` does not crash, but it misstates the
|
|
136
|
+
// prop contract and diverges from the documented built-in set. Fires only
|
|
137
|
+
// when the factory generic is itself an array type AND the constructor is
|
|
138
|
+
// `Object`; a bare `foo: Object` (no generic to prove array-ness) is left
|
|
139
|
+
// alone to avoid false positives. Uses the redacted `scan`, so a
|
|
140
|
+
// `prop<X[]>(Object)` shown inside an html`` example string never fires.
|
|
141
|
+
{
|
|
142
|
+
for (const { rel, scan } of files) {
|
|
143
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
144
|
+
for (const { factoryArg } of extractWebComponentClassBodies(scan)) {
|
|
145
|
+
const objStart = factoryArg.indexOf('{');
|
|
146
|
+
if (objStart === -1) continue;
|
|
147
|
+
const objEnd = matchClosingBrace(factoryArg, objStart + 1);
|
|
148
|
+
if (objEnd === -1) continue;
|
|
149
|
+
const objContent = factoryArg.slice(objStart + 1, objEnd);
|
|
150
|
+
for (const { key, value } of parsePropEntries(objContent)) {
|
|
151
|
+
if (!arrayPropUsesObject(value)) continue;
|
|
152
|
+
violations.push({
|
|
153
|
+
rule: 'array-prop-uses-array-type',
|
|
154
|
+
file: rel,
|
|
155
|
+
message: `Array-typed reactive prop \`${key}\` is declared with the \`Object\` constructor (\`prop<…[]>(Object)\`); use \`Array\` so the runtime converter matches the declared shape.`,
|
|
156
|
+
fix: `Change the constructor to \`Array\`: \`${key}: prop<…[]>(Array)\`. Object and Array share one converter so behaviour is unchanged, but \`Array\` states the prop's shape correctly.`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Rule: `no-browser-globals-in-render`.
|
|
166
|
+
*
|
|
167
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
168
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
169
|
+
* @returns {void}
|
|
170
|
+
*/
|
|
171
|
+
export function checkNoBrowserGlobalsInRender(files, violations) {
|
|
172
|
+
// --- Rule: no-browser-globals-in-render ---
|
|
173
|
+
// The SSR pipeline runs the constructor (`new Cls()`), willUpdate, and
|
|
174
|
+
// render() on the server element shim (attribute methods backed, but no real
|
|
175
|
+
// DOM). A genuinely browser-only global or an unshimmed HTMLElement member on
|
|
176
|
+
// `this` touched in any of those throws at SSR time. Those belong in
|
|
177
|
+
// connectedCallback / post-render hooks, which SSR never calls. willUpdate is
|
|
178
|
+
// scanned because it now runs at SSR (issue #217).
|
|
179
|
+
{
|
|
180
|
+
for (const { rel, scan } of files) {
|
|
181
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
182
|
+
for (const { body } of extractWebComponentClassBodies(scan)) {
|
|
183
|
+
for (const method of ['constructor', 'willUpdate', 'render']) {
|
|
184
|
+
const code = methodBodyOf(body, method);
|
|
185
|
+
if (!code) continue;
|
|
186
|
+
for (const { member, kind } of findBrowserMemberUses(code)) {
|
|
187
|
+
violations.push({
|
|
188
|
+
rule: 'no-browser-globals-in-render',
|
|
189
|
+
file: rel,
|
|
190
|
+
message: `\`${member}\` (${kind}) is used in ${method}(), which runs during SSR where it is not available, so it throws and the component fails to server-render.`,
|
|
191
|
+
fix: `Move browser-only work to connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls. Seed first-paint defaults in the constructor only from server-known inputs (attributes / props), then refine in connectedCallback by writing to a signal.`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Rule: `no-shadowed-native-member`.
|
|
202
|
+
*
|
|
203
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
204
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
205
|
+
* @returns {void}
|
|
206
|
+
*/
|
|
207
|
+
export function checkNoShadowedNativeMember(files, violations) {
|
|
208
|
+
// --- Rule: no-shadowed-native-member ---
|
|
209
|
+
// A WebComponent method named after a native DOM mutation method WebJs
|
|
210
|
+
// instruments on every light-DOM host for the slot API (#1021) is SHADOWED at
|
|
211
|
+
// runtime (the native/instrumented method wins), so the component method never
|
|
212
|
+
// runs, while TypeScript stays green (a shorter override is assignable). Rename
|
|
213
|
+
// the handler. The lifecycle hooks are meant to be overridden, so only the DOM
|
|
214
|
+
// mutation members are reserved.
|
|
215
|
+
{
|
|
216
|
+
const NATIVE_MEMBERS = [
|
|
217
|
+
'append', 'prepend', 'before', 'after', 'replaceWith', 'replaceChildren', 'remove',
|
|
218
|
+
'appendChild', 'insertBefore', 'removeChild', 'replaceChild',
|
|
219
|
+
];
|
|
220
|
+
for (const { rel, scan } of files) {
|
|
221
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
222
|
+
for (const { body } of extractWebComponentClassBodies(scan)) {
|
|
223
|
+
// The slot interception installs only on LIGHT-DOM hosts (the shadow
|
|
224
|
+
// branch keeps the true native methods), so a shadow component's method
|
|
225
|
+
// is never shadowed. Skip the whole class.
|
|
226
|
+
if (/static\s+shadow\s*=\s*true/.test(body)) continue;
|
|
227
|
+
// Brace-depth map over the (already string/comment-masked) class body,
|
|
228
|
+
// so only TOP-LEVEL class members flag: a same-named object-literal
|
|
229
|
+
// shorthand or function expression nested inside a method body is a
|
|
230
|
+
// different object's property and shadows nothing.
|
|
231
|
+
const depth = new Int32Array(body.length);
|
|
232
|
+
{
|
|
233
|
+
let d = 0;
|
|
234
|
+
for (let i = 0; i < body.length; i++) {
|
|
235
|
+
if (body[i] === '{') { depth[i] = d; d++; }
|
|
236
|
+
else if (body[i] === '}') { d--; depth[i] = d; }
|
|
237
|
+
else depth[i] = d;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const name of NATIVE_MEMBERS) {
|
|
241
|
+
// Two shadowed-definition shapes, both instance-level: the method /
|
|
242
|
+
// accessor form (`remove() {`, `get remove() {`), and the class-field
|
|
243
|
+
// FUNCTION form (`remove = () => {}` / `= function () {}`), which the
|
|
244
|
+
// connect-time interception install overwrites just the same. A
|
|
245
|
+
// `static` member lives on the constructor and shadows nothing.
|
|
246
|
+
const methodRe = new RegExp(
|
|
247
|
+
`(?:^|[\\s;}])((?:static\\s+)?)(?:async\\s+)?(?:get\\s+|set\\s+)?(${name})\\s*\\([^)]*\\)\\s*(?::[^{]*)?\\{`,
|
|
248
|
+
'g',
|
|
249
|
+
);
|
|
250
|
+
const fieldRe = new RegExp(
|
|
251
|
+
`(?:^|[\\s;}])((?:static\\s+)?)(${name})\\s*=\\s*(?:async\\s*)?(?:function\\b|\\([^)]*\\)\\s*(?::[^=]*)?=>|[\\w$]+\\s*=>)`,
|
|
252
|
+
'g',
|
|
253
|
+
);
|
|
254
|
+
let flagged = false;
|
|
255
|
+
for (const re of [methodRe, fieldRe]) {
|
|
256
|
+
let m;
|
|
257
|
+
while (!flagged && (m = re.exec(body)) !== null) {
|
|
258
|
+
if (m[1]) continue; // static: not shadowed
|
|
259
|
+
const nameIdx = m.index + m[0].indexOf(m[2]);
|
|
260
|
+
if (depth[nameIdx] !== 0) continue; // nested: another object's property
|
|
261
|
+
flagged = true;
|
|
262
|
+
violations.push({
|
|
263
|
+
rule: 'no-shadowed-native-member',
|
|
264
|
+
file: rel,
|
|
265
|
+
message: `Component member \`${name}\` shadows the native DOM method WebJs instruments for the slot API, so it silently never runs (the native method wins) and TypeScript does not catch it.`,
|
|
266
|
+
fix: `Rename the member to a non-native name (e.g. \`${name}Row()\` / \`${name}Item()\`) and update its call sites.`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (flagged) break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import-graph rules: what a browser-bound module may reach, whether a local
|
|
3
|
+
* specifier resolves, and whether a bound form action can answer a POST.
|
|
4
|
+
*
|
|
5
|
+
* Lifted verbatim out of checkConventions, which held all twenty rules inline
|
|
6
|
+
* in one 900-line function. Each rule keeps its original comment header, its
|
|
7
|
+
* logic, and its position in the run order.
|
|
8
|
+
*/
|
|
9
|
+
import { relative } from 'node:path';
|
|
10
|
+
import {
|
|
11
|
+
redactStringsAndTemplates, redactToPlaceholders, classifyActionHole,
|
|
12
|
+
} from '../js-scan.js';
|
|
13
|
+
import { resolveImport } from '../module-graph.js';
|
|
14
|
+
import {
|
|
15
|
+
hasUseServerDirective, enumerableExports, importedLocalNames, importedValueNames,
|
|
16
|
+
} from './helpers.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {import('./rules.js').Violation} Violation
|
|
20
|
+
* @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Rule: `no-missing-local-import`.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} appDir absolute path to the app root
|
|
27
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
28
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
29
|
+
* @returns {void}
|
|
30
|
+
*/
|
|
31
|
+
export function checkNoMissingLocalImport(appDir, files, violations) {
|
|
32
|
+
// --- Rule: no-missing-local-import ---
|
|
33
|
+
// A named value import of a symbol an app-internal module does not export is a
|
|
34
|
+
// runtime crash (the binding is undefined) that the elision-based checks miss,
|
|
35
|
+
// so `check` can stay green while `typecheck` is red (a dropped schema table
|
|
36
|
+
// orphaning a gallery module is the motivating dogfood case). Conservative by
|
|
37
|
+
// construction: only app-internal specifiers resolving to a known app file,
|
|
38
|
+
// only named value imports, and only when the target's exports are fully
|
|
39
|
+
// enumerable (see enumerableExports / importedValueNames).
|
|
40
|
+
{
|
|
41
|
+
// Fully-blanked view (blankStrings=true): string AND template AND comment
|
|
42
|
+
// bodies blank to spaces, position-preserving. The default `scan` keeps
|
|
43
|
+
// plain-string bodies VERBATIM (so callers can read `register('tag')`),
|
|
44
|
+
// which would let an `import`/`export` inside a string be matched, so use
|
|
45
|
+
// the fully-blanked view for both the export map and the import scan. The
|
|
46
|
+
// real specifier is read back from `content` at the same (length-preserved)
|
|
47
|
+
// offset.
|
|
48
|
+
const maskedByAbs = new Map();
|
|
49
|
+
for (const f of files) maskedByAbs.set(f.abs, redactStringsAndTemplates(f.content, true));
|
|
50
|
+
const exportsByAbs = new Map();
|
|
51
|
+
for (const f of files) exportsByAbs.set(f.abs, enumerableExports(maskedByAbs.get(f.abs)));
|
|
52
|
+
// `import\s+` excludes `import.meta` and a dynamic `import(`. The clause is
|
|
53
|
+
// `[^'";]*?` so it cannot swallow a side-effect `import '...'` (its quote) or
|
|
54
|
+
// bridge across a `;` into the next statement's `from`.
|
|
55
|
+
const reImport = /\bimport\s+([^'";]*?)\bfrom\s*(['"])/g;
|
|
56
|
+
for (const { abs, rel, content } of files) {
|
|
57
|
+
const masked = maskedByAbs.get(abs);
|
|
58
|
+
reImport.lastIndex = 0;
|
|
59
|
+
let m;
|
|
60
|
+
while ((m = reImport.exec(masked))) {
|
|
61
|
+
const quote = m[2];
|
|
62
|
+
const specStart = reImport.lastIndex; // just past the opening quote
|
|
63
|
+
const specEnd = content.indexOf(quote, specStart);
|
|
64
|
+
if (specEnd < 0) continue;
|
|
65
|
+
const spec = content.slice(specStart, specEnd);
|
|
66
|
+
if (!/^(?:\.|#)/.test(spec)) continue; // app-internal only (relative or #alias)
|
|
67
|
+
const names = importedValueNames(m[1]);
|
|
68
|
+
if (!names || names.length === 0) continue;
|
|
69
|
+
const target = resolveImport(spec, abs, appDir);
|
|
70
|
+
const exp = exportsByAbs.get(target);
|
|
71
|
+
if (exp == null) continue; // not an app file, or exports not enumerable
|
|
72
|
+
for (const name of names) {
|
|
73
|
+
if (exp.has(name)) continue;
|
|
74
|
+
violations.push({
|
|
75
|
+
rule: 'no-missing-local-import',
|
|
76
|
+
file: rel,
|
|
77
|
+
message: `Imports \`${name}\` from \`${spec}\`, but that module does not export \`${name}\`. The binding is undefined at runtime and crashes on first use (often a renamed or removed export, e.g. a dropped schema table).`,
|
|
78
|
+
fix: `Add \`${name}\` to \`${spec}\`, correct the imported name, or remove the import.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Rule: `form-action-not-a-get-action`.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} appDir absolute path to the app root
|
|
90
|
+
* @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
|
|
91
|
+
* @param {Violation[]} violations accumulator the rule pushes onto
|
|
92
|
+
* @returns {void}
|
|
93
|
+
*/
|
|
94
|
+
export function checkFormActionNotAGetAction(appDir, files, violations) {
|
|
95
|
+
// --- Rule: form-action-not-a-get-action (#1155) ---
|
|
96
|
+
// `<form action=${fn}>` binds a server action to a POST submission. An action
|
|
97
|
+
// declaring `method = 'GET'` cannot serve one: it is CSRF-exempt and takes its
|
|
98
|
+
// args in the url, so the dispatcher answers 405. Catch it at edit time.
|
|
99
|
+
{
|
|
100
|
+
// A `'use server'` file's declared method, or absent when it declares none.
|
|
101
|
+
const methodByAbs = new Map();
|
|
102
|
+
for (const f of files) {
|
|
103
|
+
if (!/\.server\.m?[jt]s$/.test(f.rel)) continue;
|
|
104
|
+
if (!hasUseServerDirective(f.content)) continue;
|
|
105
|
+
const m = /\bexport\s+const\s+method\s*(?::[^=]*)?=\s*['"`]([A-Za-z]+)['"`]/.exec(f.scan);
|
|
106
|
+
if (m) methodByAbs.set(f.abs, m[1].toUpperCase());
|
|
107
|
+
}
|
|
108
|
+
if (methodByAbs.size) {
|
|
109
|
+
const reImport = /\bimport\s+([^'";]*?)\bfrom\s*(['"])/g;
|
|
110
|
+
for (const { abs, rel, content } of files) {
|
|
111
|
+
// PLACEHOLDER redaction, not either mask, and the choice is forced.
|
|
112
|
+
// The default mask blanks a tagged template outright, so a real
|
|
113
|
+
// `html`<form action=${fn}>`` disappears and the rule could never fire;
|
|
114
|
+
// the fully-blanked mask does the same. Placeholder mode keeps `${...}`
|
|
115
|
+
// holes as REAL code while turning each literal BODY into one opaque
|
|
116
|
+
// `__STR_n__` token, so a live binding is readable and a `<form
|
|
117
|
+
// action=${x}>` shown as text inside a docs sample is a single token
|
|
118
|
+
// with no hole in it. That distinction is the whole carve-out: the
|
|
119
|
+
// framework's own website renders this exact shape as a code sample.
|
|
120
|
+
const { redacted, literals } = redactToPlaceholders(content);
|
|
121
|
+
// Both binding shapes count the same here: `action=` on a `<form>`
|
|
122
|
+
// (#1155) and `formaction=` on a `<button>` / `<input>` submitter
|
|
123
|
+
// (#1207) each put the named action behind a form submission, and a
|
|
124
|
+
// GET-declared action cannot serve either. `classifyActionHole` owns
|
|
125
|
+
// the tag-and-attribute pairing (js-scan.js).
|
|
126
|
+
const bound = new Set();
|
|
127
|
+
for (const m of redacted.matchAll(/__STR_(\d+)__\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g)) {
|
|
128
|
+
if (!classifyActionHole(literals[Number(m[1])] || '')) continue;
|
|
129
|
+
bound.add(m[2]);
|
|
130
|
+
}
|
|
131
|
+
if (!bound.size) continue;
|
|
132
|
+
reImport.lastIndex = 0;
|
|
133
|
+
let im;
|
|
134
|
+
while ((im = reImport.exec(redacted))) {
|
|
135
|
+
// The specifier is a placeholder token in this view, so read the real
|
|
136
|
+
// one back out of `literals`.
|
|
137
|
+
const tok = /__STR_(\d+)__/.exec(redacted.slice(reImport.lastIndex, reImport.lastIndex + 24));
|
|
138
|
+
if (!tok) continue;
|
|
139
|
+
const spec = literals[Number(tok[1])] || '';
|
|
140
|
+
if (!/^(?:\.|#)/.test(spec)) continue;
|
|
141
|
+
const names = importedLocalNames(im[1]);
|
|
142
|
+
if (!names.length) continue;
|
|
143
|
+
const target = resolveImport(spec, abs, appDir);
|
|
144
|
+
// ONLY a GET is contradictory, and the rule must say exactly what the
|
|
145
|
+
// dispatcher does or it is a false positive. A browser form always
|
|
146
|
+
// submits as a POST, and the dispatcher runs a PUT / PATCH / DELETE
|
|
147
|
+
// action on that POST: the declared verb governs the RPC transport,
|
|
148
|
+
// not whether the function can serve a form. A GET is different in
|
|
149
|
+
// kind (CSRF-exempt, args in the url, no body), which is why it is
|
|
150
|
+
// the one the dispatcher answers with a 405.
|
|
151
|
+
const method = target ? methodByAbs.get(target) : undefined;
|
|
152
|
+
if (method !== 'GET') continue;
|
|
153
|
+
for (const { local, imported } of names) {
|
|
154
|
+
if (!bound.has(local)) continue;
|
|
155
|
+
violations.push({
|
|
156
|
+
rule: 'form-action-not-a-get-action',
|
|
157
|
+
file: rel,
|
|
158
|
+
message: `Binds \`${imported}\` to a form, but \`${spec}\` declares \`method = 'GET'\`. A GET action rides its arguments in the url and skips the CSRF check, so it cannot answer a form POST; the submission is a 405.`,
|
|
159
|
+
fix: `Drop the \`method\` export from \`${spec}\` (an action with no method is a POST, which is what a form submits), or bind an action that takes a POST.`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|