@webjsdev/server 0.8.63 → 0.8.65

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.
Files changed (47) hide show
  1. package/index.d.ts +7 -2
  2. package/package.json +2 -2
  3. package/src/browser-entries.js +64 -0
  4. package/src/check/helpers.js +365 -0
  5. package/src/check/rules-actions.js +174 -0
  6. package/src/check/rules-components.js +275 -0
  7. package/src/check/rules-imports.js +166 -0
  8. package/src/check/rules-registry.js +128 -0
  9. package/src/check/rules-routing.js +224 -0
  10. package/src/check/rules-typescript.js +177 -0
  11. package/src/check/rules.js +127 -0
  12. package/src/check/runner-support.js +405 -0
  13. package/src/check/runner.js +113 -0
  14. package/src/check.js +4 -1774
  15. package/src/component-elision.js +173 -26
  16. package/src/dev/config.js +338 -0
  17. package/src/dev/handler.js +1372 -0
  18. package/src/dev/helpers.js +542 -0
  19. package/src/dev/serve.js +840 -0
  20. package/src/dev/server.js +322 -0
  21. package/src/dev-classify.js +174 -0
  22. package/src/dev-reload-worker.js +133 -7
  23. package/src/dev-styles.js +97 -0
  24. package/src/dev.js +22 -3077
  25. package/src/form-dispatch.js +12 -3
  26. package/src/importmap.js +6 -3
  27. package/src/listener-core.js +16 -2
  28. package/src/module-graph.js +112 -7
  29. package/src/router.js +11 -1
  30. package/src/ssr/client-router-flag.js +42 -0
  31. package/src/ssr/document.js +361 -0
  32. package/src/ssr/env-shim.js +51 -0
  33. package/src/ssr/escape.js +29 -0
  34. package/src/ssr/head.js +857 -0
  35. package/src/ssr/preloads.js +195 -0
  36. package/src/ssr/render.js +1481 -0
  37. package/src/ssr/responses.js +201 -0
  38. package/src/ssr.js +28 -2445
  39. package/src/vendor/audit.js +400 -0
  40. package/src/vendor/integrity.js +245 -0
  41. package/src/vendor/jspm.js +348 -0
  42. package/src/vendor/manifest.js +96 -0
  43. package/src/vendor/pins.js +892 -0
  44. package/src/vendor/providers.js +35 -0
  45. package/src/vendor/resolver.js +225 -0
  46. package/src/vendor/scanner.js +132 -0
  47. package/src/vendor.js +29 -2371
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Routing and template rules: what a route handler may throw, what a non-root
3
+ * layout may emit, and which holes a template may carry.
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
+ isServerActionFile, isComponentFile,
11
+ } from './helpers.js';
12
+
13
+ /**
14
+ * @typedef {import('./rules.js').Violation} Violation
15
+ * @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
16
+ */
17
+
18
+
19
+ /**
20
+ * Rule: `no-redirect-in-api-route`.
21
+ *
22
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
23
+ * @param {Violation[]} violations accumulator the rule pushes onto
24
+ * @returns {void}
25
+ */
26
+ export function checkNoRedirectInApiRoute(files, violations) {
27
+ // --- Rule: no-redirect-in-api-route ---
28
+ // `redirect()` from `@webjsdev/core` throws a control-flow signal designed
29
+ // for the SSR page renderer. In a `route.ts` API handler it goes uncaught
30
+ // and produces a 500. API handlers must use `Response.redirect(url, 303)`
31
+ // instead. Page functions, layouts, and server actions may still use
32
+ // `redirect()` (caught by the SSR / action pipeline).
33
+ {
34
+ const ROUTE_FILE = /(?:^|\/)route\.m?[jt]s$/;
35
+ for (const { rel, scan } of files) {
36
+ if (!ROUTE_FILE.test(rel)) continue;
37
+ // `redirect` reaches the route file in one of two statically-visible ways:
38
+ // 1. A NAMED import from `@webjsdev/core` (possibly aliased):
39
+ // `import { redirect }`, `import { redirect as r }`, `import { …, redirect, … }`.
40
+ // 2. A NAMESPACE import then a member call:
41
+ // `import * as core from '@webjsdev/core'` then `core.redirect(...)`.
42
+ // The named case flags a bare `redirect(` call; the namespace case flags
43
+ // `<ns>.redirect(`. `Response.redirect(` and any other `obj.redirect(` are
44
+ // the standard API and stay fine. A `redirect()` thrown inside a
45
+ // '`use server`' action the route calls DIRECTLY (an uncaught 500) needs
46
+ // cross-file analysis and is left to the AST rework (#753).
47
+ const namedM = /\bimport\s+\{[^}]*\bredirect\b(?:\s+as\s+(\w+))?\s*[^}]*\}\s+from\s+['"]@webjsdev\/core['"]/.exec(scan);
48
+ const nsM = /\bimport\s+\*\s+as\s+(\w+)\s+from\s+['"]@webjsdev\/core['"]/.exec(scan);
49
+ // A file can carry BOTH a named `redirect` import AND a namespace import,
50
+ // so check every matcher independently (not mutually exclusive): a named
51
+ // import means a bare `<localName>(`, a namespace import means
52
+ // `<ns>.redirect(`. The `member` flag distinguishes the two so a bare
53
+ // named call can screen out `Response.redirect(` / `obj.redirect(`.
54
+ /** @type {Array<{ re: RegExp, member: boolean }>} */
55
+ const matchers = [];
56
+ if (namedM) {
57
+ const localName = namedM[1] || 'redirect';
58
+ matchers.push({ re: new RegExp(`(?<!\\.)\\b${localName}\\s*\\(`, 'g'), member: false });
59
+ }
60
+ if (nsM) {
61
+ matchers.push({ re: new RegExp(`\\b${nsM[1]}\\.redirect\\s*\\(`, 'g'), member: true });
62
+ }
63
+ let flagged = false;
64
+ for (const { re, member } of matchers) {
65
+ if (flagged) break;
66
+ let m;
67
+ while ((m = re.exec(scan)) !== null) {
68
+ if (!member) {
69
+ // Screen out `Response.redirect(` / `someObj.redirect(` sharing the
70
+ // local name: a preceding member-access dot means it is not the import.
71
+ const before = scan.slice(Math.max(0, m.index - 20), m.index);
72
+ if (/\w\.$/.test(before)) continue;
73
+ }
74
+ violations.push({
75
+ rule: 'no-redirect-in-api-route',
76
+ file: rel,
77
+ message:
78
+ `\`redirect()\` from \`@webjsdev/core\` throws a control-flow signal for the SSR page renderer; in a \`route.ts\` handler it goes uncaught and returns a 500.`,
79
+ fix: `Use \`Response.redirect(url, 303)\` for external redirects, or return a 3xx Response directly. The \`redirect()\` sentinel is only valid in page functions, layouts, and server actions (where the SSR pipeline catches it).`,
80
+ });
81
+ flagged = true; // one violation per file is enough
82
+ break;
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Rule: `no-interpolation-in-raw-text-element`.
91
+ *
92
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
93
+ * @param {Violation[]} violations accumulator the rule pushes onto
94
+ * @returns {void}
95
+ */
96
+ export function checkNoInterpolationInRawTextElement(files, violations) {
97
+ // --- Rule: no-interpolation-in-raw-text-element ---
98
+ // A `${...}` hole inside a `<style>` or `<script>` element in an `html`
99
+ // template is an SSR/client asymmetry: `renderToString` emits it, but the
100
+ // client parser drops a raw-text hole as a `noop` (the compile cache is keyed
101
+ // on the static strings, so a per-render value cannot be baked in), so the
102
+ // element paints at SSR then wipes to empty on hydration.
103
+ //
104
+ // Scoped to COMPONENTS. The drop only happens on the CLIENT renderer, which
105
+ // runs for components (hydration + re-render). Pages and layouts render
106
+ // server-only (never hydrate), so a page's `<style>${STYLES.text}</style>` is
107
+ // a legitimate, taught pattern and must NOT be flagged. Scan raw source with
108
+ // comments stripped (the tag text lives in a template string, which the
109
+ // redacted `scan` view blanks). One violation per file.
110
+ {
111
+ for (const { rel, content } of files) {
112
+ // Only files that define a hydrating custom element. A page/layout that
113
+ // interpolates a `css` result into a `<style>` is server-only and fine.
114
+ if (!/class\s+\w+\s+extends\s+WebComponent/.test(content)) continue;
115
+ const stripped = content
116
+ .replace(/\/\/.*$/gm, '')
117
+ .replace(/\/\*[\s\S]*?\*\//g, '');
118
+ for (const tag of ['style', 'script']) {
119
+ // `<tag ...> ... ${ ... </tag>`, where the hole sits before the close
120
+ // tag. The negative lookahead keeps the match from crossing `</tag>`.
121
+ const re = new RegExp(
122
+ `<${tag}\\b[^>]*>(?:(?!<\\/${tag}>)[\\s\\S])*?\\$\\{(?:(?!<\\/${tag}>)[\\s\\S])*?<\\/${tag}>`,
123
+ 'i',
124
+ );
125
+ if (re.test(stripped)) {
126
+ violations.push({
127
+ rule: 'no-interpolation-in-raw-text-element',
128
+ file: rel,
129
+ message: `An interpolation (\`\${...}\`) sits inside a <${tag}> element in an html template. The server renderer emits it but the client renderer drops it, so it paints at SSR then wipes to empty on hydration.`,
130
+ fix:
131
+ tag === 'style'
132
+ ? `Move the CSS out of the raw-text hole: use \`static styles\` (shadow DOM) or a \`css\` template for a component, or put page CSS in the layout. Static \`<style>...</style>\` with no \`\${}\` is fine.`
133
+ : `Build the script body outside the raw-text element (set attributes/properties via bindings, or compute the value before the template). Static \`<script>...</script>\` with no \`\${}\` is fine.`,
134
+ });
135
+ break; // one violation per file is enough
136
+ }
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Rule: `no-server-env-in-components`.
144
+ *
145
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
146
+ * @param {Violation[]} violations accumulator the rule pushes onto
147
+ * @returns {void}
148
+ */
149
+ export function checkNoServerEnvInComponents(files, violations) {
150
+ // --- Rule: no-server-env-in-components ---
151
+ // Catches `process.env.X` reads in component files where X is not a
152
+ // WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
153
+ // two categories to the browser; any other read either leaks a secret
154
+ // into the SSR'd HTML or reads as undefined after hydration.
155
+ {
156
+ for (const { abs, rel, content } of files) {
157
+ if (!isComponentFile(rel)) continue;
158
+ if (isServerActionFile(abs, content)) continue;
159
+
160
+ const re = /\bprocess\.env\.([A-Z][A-Z0-9_]*)\b/g;
161
+ const seen = new Set();
162
+ let m;
163
+ while ((m = re.exec(content)) !== null) {
164
+ const name = m[1];
165
+ if (name.startsWith('WEBJS_PUBLIC_')) continue;
166
+ if (name === 'NODE_ENV') continue;
167
+ if (seen.has(name)) continue;
168
+ seen.add(name);
169
+ violations.push({
170
+ rule: 'no-server-env-in-components',
171
+ file: rel,
172
+ message: `Component reads process.env.${name}; server-only env vars must not be read in components (would leak into SSR'd HTML and read as undefined after hydration)`,
173
+ fix: `Either rename to WEBJS_PUBLIC_${name} if the value is intended for the browser, or read process.env.${name} in a page function / server action / middleware and pass a derived value to the component as an attribute.`,
174
+ });
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Rule: `shell-in-non-root-layout`.
182
+ *
183
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
184
+ * @param {Violation[]} violations accumulator the rule pushes onto
185
+ * @returns {void}
186
+ */
187
+ export function checkShellInNonRootLayout(files, violations) {
188
+ // --- Rule: shell-in-non-root-layout ---
189
+ // Only app/layout.{js,ts} may write <!doctype>/<html>/<head>/<body>. The
190
+ // framework auto-emits the shell around the whole composition; a nested
191
+ // shell ends up duplicated and silently dropped by the HTML parser.
192
+ {
193
+ // Root layout = exactly "app/layout.js" or "app/layout.ts".
194
+ const ROOT_LAYOUT = /^app\/layout\.(?:js|mjs|ts|mts)$/;
195
+ // Any other layout or page under app/ (including pages, nested layouts).
196
+ const LAYOUT_OR_PAGE = /^app\/(?:.+\/)?(?:layout|page)\.(?:js|mjs|ts|mts)$/;
197
+ // Shell tags. Case-insensitive, allow whitespace, allow attributes for <html>/<body>.
198
+ const SHELL_RE = /<!doctype\b|<html\b|<head\b|<body\b/i;
199
+ for (const { rel, content } of files) {
200
+ if (ROOT_LAYOUT.test(rel)) continue;
201
+ if (!LAYOUT_OR_PAGE.test(rel)) continue;
202
+ // Strip line comments + /* … */ block comments + ` ` string-template
203
+ // tag content is fine: we're looking at the literal HTML in the
204
+ // returned `html` template, which won't be inside a code comment.
205
+ // A naive substring scan is good enough; false positives only when
206
+ // someone genuinely embeds `<html>` inside a string literal that
207
+ // isn't a layout shell (rare and probably an honest code smell).
208
+ const stripped = content
209
+ .replace(/\/\/.*$/gm, '')
210
+ .replace(/\/\*[\s\S]*?\*\//g, '');
211
+ const m = stripped.match(SHELL_RE);
212
+ if (m) {
213
+ violations.push({
214
+ rule: 'shell-in-non-root-layout',
215
+ file: rel,
216
+ message:
217
+ `Non-root layout/page contains ${m[0]}: only the root layout (app/layout.{js,ts}) may write the shell. The framework auto-emits <!doctype>/<html>/<head>/<body> around the whole composition; a nested shell ends up duplicated and dropped by the HTML parser.`,
218
+ fix:
219
+ 'Remove the <!doctype>/<html>/<head>/<body> wrapper from this file. Use the `metadata` export for <title>/<meta>/og/twitter, return inline <link>/<style>/<script> for head-bound resources (they auto-hoist), and put any `<html lang>` / `<body class>` overrides in app/layout.{js,ts} instead.',
220
+ });
221
+ }
222
+ }
223
+ }
224
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * TypeScript rules. WebJs strips types rather than compiling them, so
3
+ * non-erasable syntax is a 500 at strip time, not a build error.
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 { readFile } from 'node:fs/promises';
10
+ import { join, relative, sep } from 'node:path';
11
+ import { walk } from '../fs-walk.js';
12
+ import {
13
+ redactStringsAndTemplates,
14
+ } from '../js-scan.js';
15
+
16
+ /**
17
+ * @typedef {import('./rules.js').Violation} Violation
18
+ * @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
19
+ */
20
+
21
+
22
+ /**
23
+ * Rule: `erasable-typescript-only`.
24
+ *
25
+ * @param {string} appDir absolute path to the app root
26
+ * @param {Violation[]} violations accumulator the rule pushes onto
27
+ * @returns {Promise<void>}
28
+ */
29
+ export async function checkErasableTypescriptOnly(appDir, violations) {
30
+ // --- Rule: erasable-typescript-only ---
31
+ // The dev server's type-stripper is Node's built-in
32
+ // module.stripTypeScriptTypes, which rejects non-erasable TS (enum,
33
+ // namespace with values, constructor parameter properties, legacy
34
+ // decorators, `import = require`). There is no fallback: non-erasable
35
+ // syntax is rejected at request time with a 500. Enforce TS-side
36
+ // rejection of those patterns via `compilerOptions.erasableSyntaxOnly:
37
+ // true` in tsconfig.json so violations surface as red squiggles in
38
+ // the editor before they ever hit the dev server. The companion
39
+ // no-non-erasable-typescript rule (below) catches violations even if
40
+ // the tsconfig flag is unset.
41
+ {
42
+ let tsconfigContent = null;
43
+ try {
44
+ tsconfigContent = await readFile(join(appDir, 'tsconfig.json'), 'utf8');
45
+ } catch {
46
+ // No tsconfig.json (pure JS app). Skip the rule.
47
+ }
48
+ if (tsconfigContent != null) {
49
+ let parsed = null;
50
+ try {
51
+ const stripped = tsconfigContent
52
+ .replace(/\/\/.*$/gm, '')
53
+ .replace(/\/\*[\s\S]*?\*\//g, '')
54
+ .replace(/,(\s*[}\]])/g, '$1');
55
+ parsed = JSON.parse(stripped);
56
+ } catch {
57
+ parsed = null;
58
+ }
59
+ const compilerOptions = parsed && typeof parsed === 'object' ? parsed.compilerOptions : null;
60
+ const flag = compilerOptions && typeof compilerOptions === 'object' ? compilerOptions.erasableSyntaxOnly : undefined;
61
+ if (flag !== true) {
62
+ violations.push({
63
+ rule: 'erasable-typescript-only',
64
+ file: 'tsconfig.json',
65
+ message:
66
+ flag === false
67
+ ? '`compilerOptions.erasableSyntaxOnly` is `false`. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators) fails at strip time and the dev server returns a 500. webjs is buildless end-to-end and has no bundler fallback; turn the flag on so the TypeScript compiler catches non-erasable constructs as red squiggles at edit time.'
68
+ : '`compilerOptions.erasableSyntaxOnly` is not set. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Setting this flag makes the TypeScript compiler flag non-erasable syntax as a red squiggle in the editor instead of letting it silently slip through to a 500 at runtime.',
69
+ fix:
70
+ 'Set `"erasableSyntaxOnly": true` under `compilerOptions` in tsconfig.json. Replace any existing `enum` declarations with `const X = { ... } as const` plus a `type X = typeof X[keyof typeof X]` union. Replace constructor parameter properties with explicit field declarations + assignments.',
71
+ });
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Rule: `no-non-erasable-typescript`.
79
+ *
80
+ * @param {string} appDir absolute path to the app root
81
+ * @param {Violation[]} violations accumulator the rule pushes onto
82
+ * @returns {Promise<void>}
83
+ */
84
+ export async function checkNoNonErasableTypescript(appDir, violations) {
85
+ // --- Rule: no-non-erasable-typescript ---
86
+ // Scans .ts source for the four non-erasable TypeScript constructs
87
+ // that the runtime stripper rejects. Complement to
88
+ // erasable-typescript-only: the flag check catches the case where
89
+ // the user opts into the tsconfig flag; this scan catches the
90
+ // case where the flag is missing OR the user has bypassed it and
91
+ // written offending syntax anyway. Both rules ship enabled by
92
+ // default so violators get the strongest signal possible.
93
+ {
94
+ /** @type {Array<{ name: string, regex: RegExp, fix: string }>} */
95
+ const NON_ERASABLE_PATTERNS = [
96
+ {
97
+ name: 'enum',
98
+ // Matches `enum X {`, `export enum X {`, `const enum X {`,
99
+ // `declare enum X {`. Requires uppercase first letter on the
100
+ // identifier to avoid matching variables literally named "enum"
101
+ // in user code (rare but possible).
102
+ regex: /^[ \t]*(?:export[ \t]+)?(?:declare[ \t]+)?(?:const[ \t]+)?enum[ \t]+[A-Z]\w*[ \t]*\{/m,
103
+ fix: 'Replace `enum Foo { A, B }` with `const Foo = { A: "A", B: "B" } as const; type Foo = typeof Foo[keyof typeof Foo];`.',
104
+ },
105
+ {
106
+ name: 'namespace with values',
107
+ // Matches `namespace Foo { ... <value statement> ... }` at top
108
+ // level. Type-only namespaces (which ARE erasable) won't contain
109
+ // `let|const|var|function|class` as statements, so this catches
110
+ // only the value-carrying form. False positives possible for
111
+ // type-only namespaces that contain those words in type aliases;
112
+ // accept this as a soft warning.
113
+ regex: /^[ \t]*(?:export[ \t]+)?namespace[ \t]+\w+[ \t]*\{[\s\S]*?\b(?:let|const|var|function|class)\b/m,
114
+ fix: 'Replace `namespace Foo { export const x = 1 }` with `export const Foo = { x: 1 } as const;` or split the contents into separate modules.',
115
+ },
116
+ {
117
+ name: 'constructor parameter property',
118
+ // Matches `constructor(public x: T)`, `constructor(private foo, ...)`,
119
+ // `constructor(readonly bar)`. Looks for one of the four access
120
+ // modifiers immediately followed by an identifier inside the
121
+ // constructor's parameter list.
122
+ regex: /constructor[ \t]*\([^)]*\b(?:public|private|protected|readonly)[ \t]+\w+/,
123
+ fix: 'Replace `constructor(public x: number)` with `x: number; constructor(x: number) { this.x = x; }`.',
124
+ },
125
+ {
126
+ name: 'import = require',
127
+ // TypeScript-style CommonJS import. Catches `import foo =
128
+ // require("bar")` and `export import foo = require("bar")`.
129
+ regex: /^[ \t]*(?:export[ \t]+)?import[ \t]+\w+[ \t]*=[ \t]*require[ \t]*\(/m,
130
+ fix: 'Replace `import foo = require("bar")` with `import * as foo from "bar"` or `import { something } from "bar"`.',
131
+ },
132
+ ];
133
+
134
+ // Walk every .ts / .mts file under appDir, skipping node_modules,
135
+ // build outputs, version control, and the framework's own private
136
+ // folders. Match the conventional excludes that fs-walk.js's caller
137
+ // contract expects.
138
+ for await (const abs of walk(appDir, (p) => /\.m?ts$/.test(p))) {
139
+ // Skip anything inside node_modules or common build / cache dirs.
140
+ const relPath = relative(appDir, abs);
141
+ if (
142
+ relPath.includes('node_modules' + sep) ||
143
+ relPath.startsWith('dist' + sep) ||
144
+ relPath.startsWith('build' + sep) ||
145
+ relPath.startsWith('.next' + sep) ||
146
+ relPath.startsWith('.git' + sep) ||
147
+ relPath.split(sep).some((s) => s.startsWith('_'))
148
+ ) {
149
+ continue;
150
+ }
151
+ let content;
152
+ try {
153
+ content = await readFile(abs, 'utf8');
154
+ } catch {
155
+ continue;
156
+ }
157
+ // Redact comments, string contents, and template-literal bodies
158
+ // so docs-page code examples like `<pre>enum Direction { ... }</pre>`
159
+ // inside `html\`...\`` template literals don't trip the rule.
160
+ // The redactor preserves line + column so the reported line
161
+ // number still maps to the right place in the original.
162
+ const scan = redactStringsAndTemplates(content);
163
+ for (const { name, regex, fix } of NON_ERASABLE_PATTERNS) {
164
+ const m = scan.match(regex);
165
+ if (m && typeof m.index === 'number') {
166
+ const line = content.slice(0, m.index).split('\n').length;
167
+ violations.push({
168
+ rule: 'no-non-erasable-typescript',
169
+ file: relPath,
170
+ message: `Non-erasable TypeScript construct (${name}) detected at line ${line}. The framework's type-stripper rejects this at request time with a 500.`,
171
+ fix,
172
+ });
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
@@ -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));